resolver = $resolver; } /** * Initialize admin hooks. * * @since 1.0.0 * * @return void */ public function init(): void { add_action( 'admin_menu', array( $this, 'add_settings_page' ) ); add_action( 'admin_init', array( $this, 'register_settings' ) ); add_action( 'admin_init', array( $this, 'handle_clear_cache' ) ); add_action( 'admin_init', array( $this, 'handle_resolve_all' ) ); add_action( 'admin_init', array( $this, 'handle_clear_single' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); // Allow editors (edit_others_posts) to save settings via the Settings API. // options.php defaults to manage_options; this filter lowers it for our group. add_filter( 'option_page_capability_robotstxt_og_settings', array( $this, 'get_settings_capability' ) ); } /** * Return the capability required to save plugin settings. * * Allows users with edit_others_posts (editors and above) to save plugin * settings through the WordPress Settings API (options.php). * * @since 1.0.3 * * @return string Capability slug. */ public function get_settings_capability(): string { return 'edit_others_posts'; } /** * Handle clear single post cache action. * * @since 1.0.0 * * @return void */ public function handle_clear_single(): void { // Check if this is a single clear request. $post_id_raw = filter_input( INPUT_GET, 'robotstxt_og_clear_single', FILTER_SANITIZE_NUMBER_INT ); if ( null === $post_id_raw ) { return; } $post_id = absint( $post_id_raw ); if ( $post_id <= 0 ) { return; } // Verify nonce. $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_single_' . $post_id ) ) { wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) ); } // Check permissions. if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) ); } // Clear single post cache. $this->resolver->clear_cache( $post_id ); // Add admin notice. add_settings_error( 'robotstxt_og_messages', 'robotstxt_og_single_cleared', /* translators: %d: post ID */ sprintf( __( 'Successfully cleared cached fallback URL for post #%d.', 'robotstxt-og' ), $post_id ), 'success' ); // Redirect back to diagnostics tab. $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' ); wp_safe_redirect( add_query_arg( 'tab', 'diagnostics', remove_query_arg( array( 'robotstxt_og_clear_single', '_wpnonce' ), $page_url ) ) ); exit; } /** * Add settings page to WordPress admin menu. * * @since 1.0.0 * * @return void */ public function add_settings_page(): void { add_options_page( __( 'OpenGraph Settings', 'robotstxt-og' ), __( 'OpenGraph', 'robotstxt-og' ), 'edit_others_posts', 'robotstxt-og-settings', array( $this, 'render_settings_page' ) ); } /** * Register plugin settings. * * @since 1.0.0 * * @return void */ public function register_settings(): void { // Register settings. register_setting( 'robotstxt_og_settings', 'robotstxt_og_fallback_image', array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_image_url' ), 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_homepage_image', array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_image_url' ), 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_enable_facebook', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => true, ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_enable_twitter', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => true, ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_twitter_card_type', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'default' => 'summary_large_image', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_twitter_site', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_delete_data_on_uninstall', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_fb_app_id', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_fb_admins', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_fb_pages', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_pinterest_verify', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_pinterest_nopin', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_telegram_channel', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_slack_app_id', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_theme_color', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_hex_color', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_app_name', array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_web_app_capable', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_app_status_bar_style', array( 'type' => 'string', 'sanitize_callback' => array( $this, 'sanitize_status_bar_style' ), 'default' => '', ) ); register_setting( 'robotstxt_og_settings', 'robotstxt_og_format_detection', array( 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean', 'default' => false, ) ); // --- General Settings section --- add_settings_section( 'robotstxt_og_main_section', __( 'General Settings', 'robotstxt-og' ), array( $this, 'render_main_section' ), 'robotstxt-og-settings' ); add_settings_field( 'robotstxt_og_fallback_image', __( 'Global Fallback Image', 'robotstxt-og' ), array( $this, 'render_fallback_image_field' ), 'robotstxt-og-settings', 'robotstxt_og_main_section' ); add_settings_field( 'robotstxt_og_homepage_image', __( 'Homepage Image', 'robotstxt-og' ), array( $this, 'render_homepage_image_field' ), 'robotstxt-og-settings', 'robotstxt_og_main_section' ); add_settings_field( 'robotstxt_og_delete_data_on_uninstall', __( 'Data Management', 'robotstxt-og' ), array( $this, 'render_delete_data_field' ), 'robotstxt-og-settings', 'robotstxt_og_main_section' ); // --- Social Media Tags section --- add_settings_section( 'robotstxt_og_social_section', __( 'Social Media Tags', 'robotstxt-og' ), array( $this, 'render_social_section' ), 'robotstxt-og-settings' ); add_settings_field( 'robotstxt_og_enable_facebook', __( 'Facebook / OG Tags', 'robotstxt-og' ), array( $this, 'render_enable_facebook_field' ), 'robotstxt-og-settings', 'robotstxt_og_social_section' ); add_settings_field( 'robotstxt_og_enable_twitter', __( 'Twitter Card Tags', 'robotstxt-og' ), array( $this, 'render_enable_twitter_field' ), 'robotstxt-og-settings', 'robotstxt_og_social_section' ); add_settings_field( 'robotstxt_og_twitter_card_type', __( 'Twitter Card Type', 'robotstxt-og' ), array( $this, 'render_twitter_card_type_field' ), 'robotstxt-og-settings', 'robotstxt_og_social_section' ); add_settings_field( 'robotstxt_og_twitter_site', __( 'Twitter/X Site Handle', 'robotstxt-og' ), array( $this, 'render_twitter_site_field' ), 'robotstxt-og-settings', 'robotstxt_og_social_section' ); // --- Platform Integration section --- add_settings_section( 'robotstxt_og_platform_section', __( 'Platform Integration', 'robotstxt-og' ), array( $this, 'render_platform_section' ), 'robotstxt-og-settings' ); add_settings_field( 'robotstxt_og_fb_app_id', __( 'Facebook App ID', 'robotstxt-og' ), array( $this, 'render_fb_app_id_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); add_settings_field( 'robotstxt_og_fb_admins', __( 'Facebook Admins', 'robotstxt-og' ), array( $this, 'render_fb_admins_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); add_settings_field( 'robotstxt_og_fb_pages', __( 'Facebook Pages', 'robotstxt-og' ), array( $this, 'render_fb_pages_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); add_settings_field( 'robotstxt_og_pinterest_verify', __( 'Pinterest Domain Verification', 'robotstxt-og' ), array( $this, 'render_pinterest_verify_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); add_settings_field( 'robotstxt_og_pinterest_nopin', __( 'Disable Pinterest Pinning', 'robotstxt-og' ), array( $this, 'render_pinterest_nopin_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); add_settings_field( 'robotstxt_og_telegram_channel', __( 'Telegram Channel', 'robotstxt-og' ), array( $this, 'render_telegram_channel_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); add_settings_field( 'robotstxt_og_slack_app_id', __( 'Slack App ID', 'robotstxt-og' ), array( $this, 'render_slack_app_id_field' ), 'robotstxt-og-settings', 'robotstxt_og_platform_section' ); // --- Mobile / Web App section --- add_settings_section( 'robotstxt_og_mobile_section', __( 'Mobile / Web App', 'robotstxt-og' ), array( $this, 'render_mobile_section' ), 'robotstxt-og-settings' ); add_settings_field( 'robotstxt_og_theme_color', __( 'Theme Color', 'robotstxt-og' ), array( $this, 'render_theme_color_field' ), 'robotstxt-og-settings', 'robotstxt_og_mobile_section' ); add_settings_field( 'robotstxt_og_app_name', __( 'Web App Name', 'robotstxt-og' ), array( $this, 'render_app_name_field' ), 'robotstxt-og-settings', 'robotstxt_og_mobile_section' ); add_settings_field( 'robotstxt_og_web_app_capable', __( 'PWA Standalone Mode', 'robotstxt-og' ), array( $this, 'render_web_app_capable_field' ), 'robotstxt-og-settings', 'robotstxt_og_mobile_section' ); add_settings_field( 'robotstxt_og_app_status_bar_style', __( 'iOS Status Bar Style', 'robotstxt-og' ), array( $this, 'render_app_status_bar_field' ), 'robotstxt-og-settings', 'robotstxt_og_mobile_section' ); add_settings_field( 'robotstxt_og_format_detection', __( 'Disable Auto Phone Detection', 'robotstxt-og' ), array( $this, 'render_format_detection_field' ), 'robotstxt-og-settings', 'robotstxt_og_mobile_section' ); } /** * Sanitize and validate an image URL for use as an OG image. * * Validates URL format and checks compatibility (JPEG/PNG). For AVIF/WebP * and other incompatible formats, attempts to find a JPEG/PNG alternative. * Returns empty string and adds a settings error if the URL is invalid or * no compatible version can be found. * * @since 1.2.0 * * @param string $raw Raw value from the settings form. * @return string Validated, compatible image URL or empty string. */ public function sanitize_image_url( string $raw ): string { $url = esc_url_raw( trim( $raw ) ); if ( empty( $url ) ) { return ''; } if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { add_settings_error( 'robotstxt_og_messages', 'robotstxt_og_invalid_url', __( 'The image URL is not valid. Please enter a full URL starting with https://.', 'robotstxt-og' ), 'error' ); return ''; } $compatible = $this->resolver->ensure_compatible_format( $url ); if ( empty( $compatible ) ) { add_settings_error( 'robotstxt_og_messages', 'robotstxt_og_incompatible_format', __( 'The image URL is not in a compatible format (JPEG or PNG), and no compatible alternative could be found.', 'robotstxt-og' ), 'error' ); return ''; } return $compatible; } /** * Render main settings section description. * * @since 1.0.0 * * @return void */ public function render_main_section(): void { echo '

'; esc_html_e( 'Configure OpenGraph image fallback behavior.', 'robotstxt-og' ); echo '

'; } /** * Render fallback image field. * * @since 1.0.0 * * @return void */ public function render_fallback_image_field(): void { $option_value = get_option( 'robotstxt_og_fallback_image', '' ); $image_url = is_string( $option_value ) ? $option_value : ''; ?>

'; esc_html_e( 'Choose which social media meta tag groups to output and configure their behavior.', 'robotstxt-og' ); echo '

'; } /** * Render enable Facebook/OG tags checkbox. * * @since 1.1.0 * * @return void */ public function render_enable_facebook_field(): void { $enabled = get_option( 'robotstxt_og_enable_facebook', true ); ?>

__( 'Summary with large image', 'robotstxt-og' ), 'summary' => __( 'Summary (small image)', 'robotstxt-og' ), ); ?>

render_text_option( 'robotstxt_og_fb_app_id', '123456789012345', __( 'Facebook App ID, used for the fb:app_id meta tag and Insights.', 'robotstxt-og' ) ); } /** * Render Facebook Admins field. * * @since 1.2.0 * * @return void */ public function render_fb_admins_field(): void { $this->render_text_option( 'robotstxt_og_fb_admins', '100000000000001,100000000000002', __( 'Comma-separated Facebook user IDs of page admins (fb:admins).', 'robotstxt-og' ) ); } /** * Render Facebook Pages field. * * @since 1.2.0 * * @return void */ public function render_fb_pages_field(): void { $this->render_text_option( 'robotstxt_og_fb_pages', '1234567890', __( 'Facebook Page ID or comma-separated IDs (fb:pages).', 'robotstxt-og' ) ); } /** * Render Pinterest domain verification field. * * @since 1.2.0 * * @return void */ public function render_pinterest_verify_field(): void { $this->render_text_option( 'robotstxt_og_pinterest_verify', 'abcdef0123456789abcdef', __( 'Pinterest domain verification token (p:domain_verify).', 'robotstxt-og' ) ); } /** * Render Pinterest "disable pinning" field. * * @since 1.2.0 * * @return void */ public function render_pinterest_nopin_field(): void { $value = (bool) get_option( 'robotstxt_og_pinterest_nopin', false ); ?> render_text_option( 'robotstxt_og_telegram_channel', '@channel', __( 'Telegram channel handle (telegram:channel), including the leading @.', 'robotstxt-og' ) ); } /** * Render Slack App ID field. * * @since 1.2.0 * * @return void */ public function render_slack_app_id_field(): void { $this->render_text_option( 'robotstxt_og_slack_app_id', 'A0123456789', __( 'Slack App ID for custom unfurls (slack-app-id).', 'robotstxt-og' ) ); } /** * Sanitize the iOS status bar style to a safelist value. * * @since 1.2.0 * * @param mixed $value Raw value. * @return string */ public function sanitize_status_bar_style( $value ): string { $value = is_string( $value ) ? $value : ''; $allowed = array( '', 'default', 'black', 'black-translucent' ); return in_array( $value, $allowed, true ) ? $value : ''; } /** * Render the Mobile / Web App section description. * * @since 1.2.0 * * @return void */ public function render_mobile_section(): void { ?>

render_text_option( 'robotstxt_og_theme_color', '#ffffff', __( 'Hex color for the browser UI chrome (theme-color).', 'robotstxt-og' ) ); } /** * Render web app name field. * * @since 1.2.0 * * @return void */ public function render_app_name_field(): void { $this->render_text_option( 'robotstxt_og_app_name', 'My Site', __( 'Web app name; emitted as application-name and apple-mobile-web-app-title.', 'robotstxt-og' ) ); } /** * Render the PWA standalone toggle. * * @since 1.2.0 * * @return void */ public function render_web_app_capable_field(): void { $this->render_checkbox_option( 'robotstxt_og_web_app_capable', __( 'Emit mobile-web-app-capable and apple-mobile-web-app-capable (standalone PWA mode).', 'robotstxt-og' ) ); } /** * Render the iOS status bar style dropdown. * * @since 1.2.0 * * @return void */ public function render_app_status_bar_field(): void { $raw = get_option( 'robotstxt_og_app_status_bar_style', '' ); $value = is_string( $raw ) ? $raw : ''; $options = array( '' => __( 'Default (omit tag)', 'robotstxt-og' ), 'default' => 'default', 'black' => 'black', 'black-translucent' => 'black-translucent', ); ?> render_checkbox_option( 'robotstxt_og_format_detection', __( 'Emit format-detection to disable automatic phone-number linking.', 'robotstxt-og' ) ); } /** * Render settings page. * * @since 1.0.0 * * @return void */ public function render_settings_page(): void { // Check user capabilities. if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) ); } // Include view file. require_once ROBOTSTXT_OG_PATH . 'admin/views/settings-page.php'; } /** * Handle clear cache action. * * @since 1.0.0 * * @return void */ public function handle_clear_cache(): void { // Check if this is a cache clear request. $clear_cache = filter_input( INPUT_GET, 'robotstxt_og_clear_cache', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); if ( null === $clear_cache ) { return; } // Verify nonce. $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_cache' ) ) { wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) ); } // Check permissions. if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) ); } // Clear all caches. $count = $this->clear_all_caches(); // Add admin notice. add_settings_error( 'robotstxt_og_messages', 'robotstxt_og_cache_cleared', /* translators: %d: number of cleared cache entries */ sprintf( __( 'Successfully cleared %d cached fallback URLs.', 'robotstxt-og' ), $count ), 'success' ); // Redirect back to tools tab. $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' ); wp_safe_redirect( add_query_arg( 'tab', 'tools', remove_query_arg( array( 'robotstxt_og_clear_cache', '_wpnonce' ), $page_url ) ) ); exit; } /** * Handle resolve all images action. * * @since 1.0.0 * * @return void */ public function handle_resolve_all(): void { // Check if this is a resolve all request. $resolve_all = filter_input( INPUT_GET, 'robotstxt_og_resolve_all', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); if ( null === $resolve_all ) { return; } // Verify nonce. $nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_resolve_all' ) ) { wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) ); } // Check permissions. if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) ); } // Resolve all images. $result = $this->resolve_all_images(); // Add admin notice. add_settings_error( 'robotstxt_og_messages', 'robotstxt_og_images_resolved', /* translators: 1: number of successful resolutions 2: number of failed resolutions */ sprintf( __( 'Batch resolution complete. Success: %1$d, Failed: %2$d', 'robotstxt-og' ), $result['success'], $result['failed'] ), $result['failed'] > 0 ? 'warning' : 'success' ); // Redirect back to tools tab. $page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' ); wp_safe_redirect( add_query_arg( 'tab', 'tools', remove_query_arg( array( 'robotstxt_og_resolve_all', '_wpnonce' ), $page_url ) ) ); exit; } /** * Clear all cached fallback URLs. * * @since 1.0.0 * * @return int Number of entries deleted. */ private function clear_all_caches(): int { global $wpdb; // Bulk-delete all cache entries by meta key. No WP API performs this // in a single query while also returning the affected row count. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $result = $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_og_image_fallback_url' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key array( '%s' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return $result ? (int) $result : 0; } /** * Resolve all images in batch. * * @since 1.0.0 * * @return array{success: int, failed: int} Result counts. */ private function resolve_all_images(): array { // Query all posts with featured images. $posts = get_posts( array( 'post_type' => 'any', 'post_status' => 'any', 'posts_per_page' => -1, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS', ), ), 'fields' => 'ids', ) ); $success = 0; $failed = 0; foreach ( $posts as $post_id ) { // Clear cache first. $this->resolver->clear_cache( $post_id ); // Resolve image. $url = $this->resolver->resolve_image( $post_id ); if ( ! empty( $url ) ) { ++$success; } else { ++$failed; } } return array( 'success' => $success, 'failed' => $failed, ); } /** * Enqueue admin assets. * * @since 1.0.0 * * @param string $hook Current admin page hook. * @return void */ public function enqueue_admin_assets( string $hook ): void { // Only on our settings page. if ( 'settings_page_robotstxt-og-settings' !== $hook ) { return; } // Enqueue custom admin JS. wp_enqueue_script( 'robotstxt-og-admin', ROBOTSTXT_OG_URL . 'assets/admin.js', array( 'jquery' ), ROBOTSTXT_OG_VERSION, true ); // Pass translated strings to JavaScript. wp_localize_script( 'robotstxt-og-admin', 'robotstxt_og_admin', array( 'confirm_clear_cache' => __( 'Are you sure you want to clear all cached fallback URLs? This action cannot be undone.', 'robotstxt-og' ), 'confirm_resolve_all' => __( 'Are you sure you want to re-resolve all images? This may take some time on large sites.', 'robotstxt-og' ), ) ); // Enqueue custom admin CSS. wp_enqueue_style( 'robotstxt-og-admin', ROBOTSTXT_OG_URL . 'assets/admin.css', array(), ROBOTSTXT_OG_VERSION ); } }