settings_hook = (string) add_submenu_page( 'freshplugins-content-audit', __( 'Settings', 'freshplugins-content-audit' ), __( 'Settings', 'freshplugins-content-audit' ), 'manage_options', 'freshplugins-content-audit-settings', [ $this, 'render_settings' ] ); } public function enqueue_assets( string $hook ): void { if ( ! str_contains( $hook, 'freshplugins-content-audit' ) ) { return; } wp_enqueue_style( 'freshplugins-content-audit-admin', FPCA_PLUGIN_URL . 'admin/css/freshplugins-content-audit-admin.css', [], FPCA_VERSION ); wp_enqueue_script( 'freshplugins-content-audit-admin', FPCA_PLUGIN_URL . 'admin/js/freshplugins-content-audit-admin.js', [ 'jquery', 'jquery-ui-tooltip' ], FPCA_VERSION, true ); $fpca_usage = License::get_usage(); $localized = [ 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'fpca_nonce' ), 'is_pro' => License::is_pro(), // The monthly allowance as the proxy last reported it, or null when // nothing has been scored yet this month. Never a hard-coded figure: // the limit belongs to the scoring service. Consumers must treat // null as "unknown" and show no number. 'free_limit' => isset( $fpca_usage['limit'] ) ? (int) $fpca_usage['limit'] : null, 'scoring_ack' => (bool) get_option( 'fpca_scoring_ack', 0 ), // Freemius upgrade URL — only for non-Pro users; empty otherwise so // the JS falls back to the settings page. 'upgrade_url' => ( function_exists( 'fpca_fs' ) && ! License::is_pro() ) ? fpca_fs()->get_upgrade_url() : '', 'today' => date_i18n( get_option( 'date_format' ) ), 'gsc_connected' => ( new GSC_Connector() )->is_active(), 'gsc_configured' => ( new GSC_Connector() )->is_configured(), 'strings' => [ 'scoring' => __( 'Scoring…', 'freshplugins-content-audit' ), 'score_now' => __( 'Score Now', 'freshplugins-content-audit' ), 'score_selected' => __( 'Score Selected', 'freshplugins-content-audit' ), 'no_selection' => __( 'Select at least one post to score.', 'freshplugins-content-audit' ), 'score_error' => __( 'Could not score this post.', 'freshplugins-content-audit' ), 'not_scored' => __( 'Not scored', 'freshplugins-content-audit' ), // Free-tier rate-limit upsell notices. 'rate_limit_title' => __( 'Monthly scoring limit reached', 'freshplugins-content-audit' ), /* translators: %1$d: scores used this month, %2$d: monthly limit */ 'rate_limit_body' => __( 'You have used %1$d of %2$d scores this month.', 'freshplugins-content-audit' ), 'rate_limit_upgrade' => __( 'Upgrade to Pro for unlimited scoring', 'freshplugins-content-audit' ), /* translators: %d: number of scores remaining this month */ 'rate_limit_low' => __( 'Only %d scores left this month — upgrade to Pro for unlimited.', 'freshplugins-content-audit' ), /* translators: 1: scores used this month, 2: the free-tier monthly scoring limit */ 'tier_used' => __( '%1$d of %2$d scores used this month', 'freshplugins-content-audit' ), 'dismiss_notice' => __( 'Dismiss this notice', 'freshplugins-content-audit' ), 'assign_post' => __( 'Assign Post', 'freshplugins-content-audit' ), /* translators: %d: number of posts */ 'assign_posts' => __( 'Assign %d Posts', 'freshplugins-content-audit' ), 'assign_label' => __( 'Assign', 'freshplugins-content-audit' ), 'edit_assignees' => __( 'Edit Assignees', 'freshplugins-content-audit' ), // Also rendered server-side in assignments.php and dashboard.php — // all three chip renderers must show the same tooltip. 'remove_assignee' => __( 'Remove assignee', 'freshplugins-content-audit' ), 'unassigned' => __( 'Unassigned', 'freshplugins-content-audit' ), 'save_assignments' => __( 'Save Assignments', 'freshplugins-content-audit' ), 'add_assignees' => __( 'Add Assignees', 'freshplugins-content-audit' ), /* translators: shown in the bulk-assign modal */ 'bulk_assign_subtitle' => __( 'Checked users will be added to all selected posts. Existing assignments will not be removed.', 'freshplugins-content-audit' ), 'assign_prefix' => __( 'Assign: ', 'freshplugins-content-audit' ), 'saving' => __( 'Saving…', 'freshplugins-content-audit' ), 'saved' => __( 'Saved!', 'freshplugins-content-audit' ), 'error_saving' => __( 'Could not save. Please try again.', 'freshplugins-content-audit' ), /* translators: %d: number of posts that were updated */ 'bulk_updated' => __( '%d posts updated.', 'freshplugins-content-audit' ), /* translators: %d: number of posts skipped because the user cannot edit them */ 'bulk_skipped' => __( '%d skipped — you cannot edit them.', 'freshplugins-content-audit' ), 'request_failed' => __( 'Connection failed. Please try again.', 'freshplugins-content-audit' ), 'error_loading_users' => __( 'Could not load users. Please try again.', 'freshplugins-content-audit' ), 'no_users_found' => __( 'No users found.', 'freshplugins-content-audit' ), 'enrolling' => __( 'Enrolling…', 'freshplugins-content-audit' ), 'enroll_error' => __( 'Could not enrol posts. Please try again.', 'freshplugins-content-audit' ), 'status_saved' => __( 'Saved', 'freshplugins-content-audit' ), 'status_error' => __( 'Could not update status. Please try again.', 'freshplugins-content-audit' ), 'scoring_ack_error' => __( 'Could not save your acknowledgement. Please try again.', 'freshplugins-content-audit' ), 'gsc_sync_search_console' => __( 'Sync Search Console', 'freshplugins-content-audit' ), 'gsc_syncing' => __( 'Syncing…', 'freshplugins-content-audit' ), 'gsc_sync_done' => __( 'Sync complete.', 'freshplugins-content-audit' ), 'gsc_sync_error' => __( 'Sync failed. Please try again.', 'freshplugins-content-audit' ), 'gsc_property_saved' => __( 'Property saved.', 'freshplugins-content-audit' ), 'gsc_property_error' => __( 'Could not save property. Please try again.', 'freshplugins-content-audit' ), 'gsc_disconnect_confirm' => __( 'Disconnect Google Search Console? Your saved login credentials will be removed and you will need to reconnect to sync data again.', 'freshplugins-content-audit' ), ], ]; if ( FPCA_GSC_ENABLED ) { $localized['gsc_mock'] = FPCA_GSC_MOCK; } wp_localize_script( 'freshplugins-content-audit-admin', 'fpca', $localized ); // Settings screen only — the ACF detected-fields preview toggle. if ( '' !== $this->settings_hook && $hook === $this->settings_hook ) { wp_enqueue_script( 'freshplugins-content-audit-settings', FPCA_PLUGIN_URL . 'admin/js/freshplugins-content-audit-settings.js', [], FPCA_VERSION, true ); } } public function render_dashboard(): void { require_once FPCA_PLUGIN_DIR . 'admin/views/dashboard.php'; } public function render_assignments(): void { require_once FPCA_PLUGIN_DIR . 'admin/views/assignments.php'; } public function render_settings(): void { require_once FPCA_PLUGIN_DIR . 'admin/views/settings.php'; } // ------------------------------------------------------------------------- // Assign modal — injected once into the admin footer on Fresh Plugins Content Audit pages // ------------------------------------------------------------------------- public function render_assign_modal(): void { $screen = get_current_screen(); if ( ! $screen || ! str_contains( $screen->id, 'freshplugins-content-audit' ) ) { return; } ?> id, 'freshplugins-content-audit' ) ) { return; } ?> 'freshplugins-content-audit-settings', 'settings-updated' => '1' ], admin_url( 'admin.php' ) ) ); exit; } // ------------------------------------------------------------------------- // AJAX: score a single post // ------------------------------------------------------------------------- public function ajax_score_post(): void { check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } if ( ! get_option( 'fpca_scoring_ack', 0 ) ) { wp_send_json_error( [ 'message' => __( 'Please acknowledge the data disclosure before scoring.', 'freshplugins-content-audit' ) ] ); } $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; if ( ! $post_id ) { wp_send_json_error( [ 'message' => __( 'Invalid post ID.', 'freshplugins-content-audit' ) ] ); } $scorer = new Freshness_Scorer(); $result = $scorer->score( $post_id ); License::record_usage( $result['usage'] ?? null ); if ( $result['success'] ) { global $wpdb; $status = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT status FROM %i WHERE post_id = %d', $wpdb->prefix . 'fpca_posts', $post_id ) ) ?: 'unreviewed'; wp_send_json_success( [ 'post_id' => $post_id, 'score' => $result['score'], 'sub_scores' => $result['sub_scores'], 'reasons' => $result['reasons'], 'usage' => $result['usage'], 'status' => $status, ] ); } else { $data = [ 'message' => $result['error'] ]; if ( ! empty( $result['usage'] ) ) { $data['usage'] = $result['usage']; } wp_send_json_error( $data ); } } // ------------------------------------------------------------------------- // AJAX: score multiple posts via batch endpoint // ------------------------------------------------------------------------- public function ajax_score_posts_batch(): void { check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } if ( ! get_option( 'fpca_scoring_ack', 0 ) ) { wp_send_json_error( [ 'message' => __( 'Please acknowledge the data disclosure before scoring.', 'freshplugins-content-audit' ) ] ); } $post_ids = array_values( array_filter( array_map( 'intval', (array) ( $_POST['post_ids'] ?? [] ) ) ) ); if ( empty( $post_ids ) ) { wp_send_json_error( [ 'message' => __( 'No post IDs provided.', 'freshplugins-content-audit' ) ] ); } $scorer = new Freshness_Scorer(); $scored_results = $scorer->score_batch( $post_ids ); global $wpdb; $results = []; $errors = []; $usage = null; // Fetch every status in one query rather than once per post in the loop. $statuses = []; $scored_ids = array_map( 'intval', array_keys( $scored_results ) ); if ( ! empty( $scored_ids ) ) { $placeholders = implode( ', ', array_fill( 0, count( $scored_ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders -- $placeholders is a generated run of %d, one per element of $scored_ids, and PHPCS cannot count placeholders inside a variable. The table and every id are bound. $status_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, status FROM %i WHERE post_id IN ({$placeholders})", $wpdb->prefix . 'fpca_posts', ...$scored_ids ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders foreach ( (array) $status_rows as $status_row ) { $statuses[ (int) $status_row['post_id'] ] = (string) $status_row['status']; } } foreach ( $scored_results as $post_id => $result ) { if ( $result['success'] ) { $status = $statuses[ (int) $post_id ] ?? ''; $status = '' !== $status ? $status : 'unreviewed'; $results[ $post_id ] = [ 'score' => $result['score'], 'sub_scores' => $result['sub_scores'], 'reasons' => $result['reasons'], 'status' => $status, ]; } else { $errors[ $post_id ] = $result['error']; } if ( ! empty( $result['usage'] ) ) { $usage = $result['usage']; } } License::record_usage( $usage ); wp_send_json_success( [ 'results' => $results, 'errors' => $errors, 'usage' => $usage, ] ); } // ------------------------------------------------------------------------- // AJAX: acknowledge the scoring data-disclosure (one-time, before first score) // ------------------------------------------------------------------------- public function ajax_ack_scoring(): void { check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } update_option( 'fpca_scoring_ack', 1 ); wp_send_json_success(); } // ------------------------------------------------------------------------- // AJAX: enrol all existing published posts // ------------------------------------------------------------------------- public function ajax_enroll_existing(): void { check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } $tracker = new Post_Tracker(); $enrolled = $tracker->enroll_existing(); wp_send_json_success( [ 'enrolled' => $enrolled, 'total' => $tracker->enrolled_count(), /* translators: %d: number of posts enrolled */ 'message' => sprintf( __( '%d post(s) enrolled.', 'freshplugins-content-audit' ), $enrolled ), ] ); } // ------------------------------------------------------------------------- // GSC OAuth callback + AJAX handlers // ------------------------------------------------------------------------- public function handle_gsc_oauth_callback(): void { if ( ! FPCA_GSC_ENABLED ) { return; } if ( empty( $_GET['fr_gsc_code'] ) || ! is_string( $_GET['fr_gsc_code'] ) ) { return; } if ( ! isset( $_GET['page'] ) || 'freshplugins-content-audit-settings' !== $_GET['page'] ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } // The nonce is round-tripped through the OAuth `state` parameter. $nonce = is_string( $_GET['fr_gsc_nonce'] ?? null ) ? sanitize_text_field( wp_unslash( $_GET['fr_gsc_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, GSC_Connector::OAUTH_NONCE_ACTION ) ) { wp_safe_redirect( add_query_arg( [ 'page' => 'freshplugins-content-audit-settings', 'gsc_error' => rawurlencode( __( 'Could not verify the Google sign-in request. Please try connecting again.', 'freshplugins-content-audit' ) ), ], admin_url( 'admin.php' ) ) ); exit; } $code = sanitize_text_field( wp_unslash( $_GET['fr_gsc_code'] ) ); $result = ( new GSC_Connector() )->exchange_code( $code ); if ( is_wp_error( $result ) ) { wp_safe_redirect( add_query_arg( [ 'page' => 'freshplugins-content-audit-settings', 'gsc_error' => rawurlencode( $result->get_error_message() ) ], admin_url( 'admin.php' ) ) ); exit; } wp_safe_redirect( add_query_arg( [ 'page' => 'freshplugins-content-audit-settings', 'gsc_connected' => '1' ], admin_url( 'admin.php' ) ) ); exit; } public function ajax_gsc_sync(): void { if ( ! FPCA_GSC_ENABLED ) { wp_send_json_error( [ 'message' => __( 'Google Search Console is not available.', 'freshplugins-content-audit' ) ] ); } check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } $result = ( new GSC_Connector() )->sync_all_posts(); if ( ! empty( $result['error'] ) ) { wp_send_json_error( [ 'message' => $result['error'] ] ); } wp_send_json_success( [ 'synced' => (int) $result['synced'], /* translators: %d: number of posts synced from GSC */ 'message' => sprintf( __( 'Synced GSC data for %d post(s).', 'freshplugins-content-audit' ), (int) $result['synced'] ), ] ); } public function ajax_gsc_disconnect(): void { if ( ! FPCA_GSC_ENABLED ) { wp_send_json_error( [ 'message' => __( 'Google Search Console is not available.', 'freshplugins-content-audit' ) ] ); } check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } ( new GSC_Connector() )->disconnect(); wp_send_json_success(); } public function ajax_gsc_save_property(): void { if ( ! FPCA_GSC_ENABLED ) { wp_send_json_error( [ 'message' => __( 'Google Search Console is not available.', 'freshplugins-content-audit' ) ] ); } check_ajax_referer( 'fpca_nonce', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'freshplugins-content-audit' ) ] ); } $property = sanitize_text_field( wp_unslash( $_POST['property'] ?? '' ) ); if ( empty( $property ) ) { wp_send_json_error( [ 'message' => __( 'No property provided.', 'freshplugins-content-audit' ) ] ); } update_option( 'fpca_gsc_property', $property ); wp_send_json_success(); } }