'', 'cpu' => '', 'link' => '', 'inbox' => '', 'check-circle'=> '', 'star' => '', 'search' => '', 'sliders' => '', 'share-2' => '', 'globe' => '', 'trending-up' => '', 'zap' => '', 'play' => '', 'refresh-cw' => '', 'key' => '', 'alert-triangle' => '', ]; /** Returns an SVG icon markup string. Static strings only, no user data. */ public static function icon($name, $class = 'lbraid-icon') { if (!isset(self::$icons[$name])) return ''; return ''; } /** Echoes an SVG icon in a view. */ public static function the_icon($name, $class = 'lbraid-icon') { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG from the fixed $icons map above; the only dynamic part is the class name, escaped with esc_attr() in icon(). echo self::icon($name, $class); } public function __construct() { add_action('admin_menu', [$this, 'register_menu']); add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']); add_action('admin_init', [$this, 'maybe_redirect_to_wizard']); add_action('admin_init', [$this, 'review_notice_action']); add_action('admin_notices', [$this, 'review_notice']); add_filter('admin_body_class', [$this, 'wizard_body_class']); add_action('add_meta_boxes', [$this, 'register_metaboxes']); add_filter('manage_posts_columns', [$this, 'add_link_columns']); add_action('manage_posts_custom_column', [$this, 'render_link_columns'], 10, 2); add_filter('manage_pages_columns', [$this, 'add_link_columns']); add_action('manage_pages_custom_column', [$this, 'render_link_columns'], 10, 2); // Add columns for custom post types $types = lbraid_post_types(); foreach ($types as $type) { if (!in_array($type, ['post', 'page'])) { add_filter("manage_{$type}_posts_columns", [$this, 'add_link_columns']); add_action("manage_{$type}_posts_custom_column", [$this, 'render_link_columns'], 10, 2); } } } /** * Setup is complete once the user has finished the wizard, or when the plugin * was already in use before the wizard existed (an API key or a filled index). */ private function setup_done() { if (get_option('lbraid_setup_done')) return true; if (get_option('lbraid_openai_key') || get_option('lbraid_anthropic_key')) { update_option('lbraid_setup_done', 1, false); return true; } global $wpdb; $table = $wpdb->prefix . 'lbraid_post_index'; if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) && (int) $wpdb->get_var("SELECT COUNT(*) FROM $table")) { update_option('lbraid_setup_done', 1, false); return true; } return false; } /** After activation, sends a new user straight to the wizard. */ public function maybe_redirect_to_wizard() { if (!get_transient('lbraid_activation_redirect')) return; delete_transient('lbraid_activation_redirect'); if (isset($_GET['activate-multi']) || !current_user_can('manage_options') || $this->setup_done()) return; wp_safe_redirect(admin_url('admin.php?page=' . (lbraid_has_pro_code() ? 'linkbraid-wizard' : 'linkbraid'))); exit; } /** Body classes: marks plugin pages (background repaint) and the wizard full-screen mode. */ public function wizard_body_class($classes) { $lbraid_page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; if (strpos($lbraid_page, 'linkbraid') === 0) { $classes .= ' lbraid-admin-page'; if ($lbraid_page === 'linkbraid-wizard') $classes .= ' lbraid-wizard-mode'; } return $classes; } /** Suggestion metabox in the post editor: same endpoints, validator and history as the panel. */ public function register_metaboxes() { if (!current_user_can('manage_options')) return; foreach (lbraid_post_types() as $type) { add_meta_box('linkbraid-metabox', __( 'Linkbraid — internal linking', 'linkbraid' ), [$this, 'render_metabox'], $type, 'normal', 'low'); } } public function render_metabox($post) { if ($post->post_status !== 'publish') { echo '

' . esc_html__( 'Link suggestions become available once the content is published. Drafts are neither indexed nor scanned.', 'linkbraid' ) . '

'; return; } ?>
— —
'); if (lbraid_has_pro_code() && !$this->setup_done()) { add_menu_page('Linkbraid', 'Linkbraid', 'manage_options', 'linkbraid-wizard', [$this, 'render_wizard'], $menu_icon, 30); return; } if (lbraid_has_pro_code()) { // The wizard must stay REACHABLE after setup too: the GSC connection // returns the browser to page=linkbraid-wizard, and during Freemius // activation mode our submenus get stripped, which used to wp_die() // that return. A top-level registration keeps access; removing it // right away keeps the menu clean. add_menu_page('Linkbraid', __( 'Setup wizard', 'linkbraid' ), 'manage_options', 'linkbraid-wizard', [$this, 'render_wizard'], $menu_icon, 31); remove_menu_page('linkbraid-wizard'); } add_menu_page( 'Linkbraid', 'Linkbraid', 'manage_options', 'linkbraid', [$this, 'render_dashboard'], $menu_icon, 30 ); add_submenu_page( 'linkbraid', __( 'Overview', 'linkbraid' ), __( 'Overview', 'linkbraid' ), 'manage_options', 'linkbraid', [$this, 'render_dashboard'] ); add_submenu_page( 'linkbraid', __( 'Knots — link suggestions', 'linkbraid' ), __( 'Knots · Suggestions', 'linkbraid' ), 'manage_options', 'linkbraid-posts', [$this, 'render_posts_page'] ); add_submenu_page( 'linkbraid', 'Braid Map', 'Braid Map', 'manage_options', 'linkbraid-map', [$this, 'render_map_page'] ); add_submenu_page( 'linkbraid', 'Strands', 'Strands', 'manage_options', 'linkbraid-strands', [$this, 'render_strands_page'] ); add_submenu_page( 'linkbraid', 'Autobraid', 'Autobraid', 'manage_options', 'linkbraid-autobraid', [$this, 'render_autobraid_page'] ); add_submenu_page( 'linkbraid', __( 'Reports', 'linkbraid' ), __( 'Reports', 'linkbraid' ), 'manage_options', 'linkbraid-reports', [$this, 'render_reports_page'] ); add_submenu_page( 'linkbraid', __( 'Priority pages', 'linkbraid' ), __( 'Priority pages', 'linkbraid' ), 'manage_options', 'linkbraid-priority', [$this, 'render_priority_page'] ); if (lbraid_has_pro_code()) add_submenu_page( 'linkbraid', __( 'Search Console', 'linkbraid' ), __( 'Search Console', 'linkbraid' ), 'manage_options', 'linkbraid-gsc', [$this, 'render_gsc_page'] ); add_submenu_page( 'linkbraid', __( 'Settings', 'linkbraid' ), __( 'Settings', 'linkbraid' ), 'manage_options', 'linkbraid-settings', [$this, 'render_settings_page'] ); add_submenu_page( 'linkbraid', __( 'Change history', 'linkbraid' ), __( 'History', 'linkbraid' ), 'manage_options', 'linkbraid-history', [$this, 'render_history_page'] ); // Visible "Setup wizard" entry; access itself comes from the top-level // registration above, so this one is purely a menu link. if (lbraid_has_pro_code()) add_submenu_page( 'linkbraid', __( 'Setup wizard', 'linkbraid' ), __( 'Setup wizard', 'linkbraid' ), 'manage_options', 'linkbraid-wizard', [$this, 'render_wizard'] ); } public function render_wizard() { // Return from the GSC connection started inside the wizard: the notice renders inside the wizard card. $gsc_notice = current_user_can('manage_options') ? $this->gsc_return_notice() : null; include LINKBRAID_PATH . 'admin/views/wizard.php'; } /** Days of use, after the first link scan, before Linkbraid asks for a review. */ const REVIEW_AFTER_DAYS = 7; const REVIEW_URL = 'https://wordpress.org/support/plugin/linkbraid/reviews/#new-post'; /** * A one-time review request on Linkbraid's own screens only: shown after a * week of use and a completed link scan. "Maybe later" snoozes it for 30 * days; the other two buttons end it for this user. */ public function review_notice() { if (!current_user_can('manage_options')) return; $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || strpos((string) $screen->id, 'linkbraid') === false || strpos((string) $screen->id, 'linkbraid-wizard') !== false) return; $first_seen = (int) get_option('lbraid_first_seen'); if (!$first_seen) { update_option('lbraid_first_seen', time(), false); return; } if (time() - $first_seen < self::REVIEW_AFTER_DAYS * DAY_IN_SECONDS) return; $state = (string) get_user_meta(get_current_user_id(), 'lbraid_review_notice', true); if ($state === 'done' || (ctype_digit($state) && time() < (int) $state)) return; global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- a single-row existence check on the plugin's own table, only on Linkbraid screens. if (!$wpdb->get_var("SELECT 1 FROM {$wpdb->prefix}lbraid_existing_links LIMIT 1")) return; $action_url = function ($action) { return wp_nonce_url(add_query_arg('lbraid_review', $action), 'lbraid_review'); }; ?>

post_type, lbraid_post_types(), true) && current_user_can('manage_options'); if (strpos($hook, 'linkbraid') === false && !$is_editor) return; wp_enqueue_style( 'linkbraid-tokens', LINKBRAID_URL . 'admin/css/tokens.css', [], LINKBRAID_VERSION . '-ui3' ); wp_enqueue_style( 'linkbraid-admin', LINKBRAID_URL . 'admin/css/admin.css', ['linkbraid-tokens'], LINKBRAID_VERSION . '-ui3' ); wp_enqueue_script( 'linkbraid-admin', LINKBRAID_URL . 'admin/js/admin.js', ['jquery'], LINKBRAID_VERSION, true ); // D3.js for link map — bundled locally (ISC license): no external // executable code, per the wordpress.org guidelines, and the map // works on hardened hosts that block third-party CDNs. if (strpos($hook, 'linkbraid-map') !== false) { wp_enqueue_script( 'lbraid-d3', LINKBRAID_URL . 'admin/js/vendor/d3.v7.min.js', [], '7.8.5', true ); wp_enqueue_script( 'linkbraid-map', LINKBRAID_URL . 'admin/js/link-map.js', ['jquery', 'lbraid-d3'], LINKBRAID_VERSION, true ); } wp_localize_script('linkbraid-admin', 'lbraidLinker', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'restUrl' => rest_url('linkbraid/v1/'), 'nonce' => wp_create_nonce('lbraid_nonce'), 'restNonce'=> wp_create_nonce('wp_rest'), 'pro' => lbraid_has_pro_code(), 'i18n' => require LINKBRAID_PATH . 'includes/i18n-js.php', ]); } // Posts list columns public function add_link_columns($columns) { $columns['lbraid_outgoing'] = __( '↗ Outgoing', 'linkbraid' ); $columns['lbraid_incoming'] = __( '↙ Incoming', 'linkbraid' ); return $columns; } public function render_link_columns($column, $post_id) { if ($column === 'lbraid_outgoing') { $count = Linkbraid_Database::get_link_count($post_id, 'outgoing'); $class = $count === 0 ? 'lbraid-count-zero' : 'lbraid-count-ok'; echo '' . (int) $count . ''; } if ($column === 'lbraid_incoming') { $count = Linkbraid_Database::get_link_count($post_id, 'incoming'); $class = $count === 0 ? 'lbraid-count-zero' : 'lbraid-count-ok'; echo '' . (int) $count . ''; } } // Page renderers public function render_dashboard() { include LINKBRAID_PATH . 'admin/views/dashboard.php'; } public function render_posts_page() { include LINKBRAID_PATH . 'admin/views/posts.php'; } public function render_map_page() { include LINKBRAID_PATH . 'admin/views/link-map.php'; } public function render_reports_page() { include LINKBRAID_PATH . 'admin/views/reports.php'; } public function render_autobraid_page() { include LINKBRAID_PATH . 'admin/views/autobraid.php'; } public function render_strands_page() { include LINKBRAID_PATH . 'admin/views/strands.php'; } public function render_history_page() { include LINKBRAID_PATH . 'admin/views/history.php'; } public function render_priority_page() { include LINKBRAID_PATH . 'admin/views/priority.php'; } /** Return from the Linkbraid proxy (the one-click connection). Used by the settings page and the wizard. */ private function gsc_return_notice() { if (!lbraid_has_pro_code() || !current_user_can('manage_options') || !isset($_GET['lbraid_gsc'])) return null; // The OAuth state parameter is a WP nonce created when the connection started; // Google and the proxy echo it back verbatim, so it validates the request origin. $state = sanitize_text_field(wp_unslash($_GET['state'] ?? '')); $state_ok = $state !== '' && wp_verify_nonce($state, 'lbraid_gsc_proxy'); $gsc = new Linkbraid_GSC(); if ($_GET['lbraid_gsc'] === '1' && !empty($_GET['otc'])) { if ($state_ok && $gsc->handle_proxy_callback(sanitize_text_field(wp_unslash($_GET['otc'])), $state)) { return ['type' => 'success', 'text' => __( 'Google Search Console: account connected. Pick a property below and save the settings.', 'linkbraid' )]; } return ['type' => 'error', 'text' => $gsc->last_error ?: __( 'The connection with Google could not be completed.', 'linkbraid' )]; } // The failure reason is echoed only on a nonce-verified return; otherwise show the generic text. $reason = $state_ok ? (sanitize_text_field(wp_unslash($_GET['reason'] ?? '')) ?: __( 'denied', 'linkbraid' )) : __( 'denied', 'linkbraid' ); /* translators: %s: dynamic value inserted at runtime. */ return ['type' => 'error', 'text' => sprintf( __( 'The connection with Google was interrupted (%s). Try again.', 'linkbraid' ), $reason )]; } public function render_settings_page() { if (!current_user_can('manage_options')) return; $gsc_notice = $this->gsc_return_notice(); if ($gsc_notice) { echo '

' . esc_html($gsc_notice['text']) . '

'; } // Handle GSC OAuth callback. The state parameter is a WP nonce created when // the authorization started and echoed back verbatim by Google. if (lbraid_has_pro_code() && isset($_GET['gsc_callback']) && isset($_GET['code']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['state'] ?? '')), 'lbraid_gsc_oauth')) { $gsc = new Linkbraid_GSC(); $success = $gsc->handle_callback(sanitize_text_field(wp_unslash($_GET['code'])), sanitize_text_field(wp_unslash($_GET['state'] ?? ''))); if ($success) { echo '

' . esc_html__( 'Google Search Console: account connected.', 'linkbraid' ) . '

'; } else { echo '

' . esc_html__( 'Could not connect to Search Console. Check the client ID and the client secret.', 'linkbraid' ) . '

'; } } // Handle GSC disconnect if (lbraid_has_pro_code() && isset($_POST['lbraid_gsc_disconnect']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'] ?? '')), 'lbraid_settings')) { $gsc = new Linkbraid_GSC(); $gsc->disconnect(); echo '

' . esc_html__( 'Disconnected from Google Search Console.', 'linkbraid' ) . '

'; } if (isset($_POST['lbraid_save_settings']) && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'] ?? '')), 'lbraid_settings')) { // The AI tab renders only with the Pro code; in Lite a save must not wipe keys stored earlier. if (lbraid_has_pro_code()) { $provider = sanitize_key($_POST['llm_provider'] ?? 'openai'); update_option('lbraid_llm_provider', in_array($provider, ['openai', 'anthropic', 'openrouter'], true) ? $provider : 'openai'); update_option('lbraid_openai_key', sanitize_text_field($_POST['openai_key'] ?? '')); update_option('lbraid_openai_model', sanitize_text_field($_POST['openai_model'] ?? 'gpt-4o')); update_option('lbraid_anthropic_key', sanitize_text_field($_POST['anthropic_key'] ?? '')); update_option('lbraid_anthropic_model', sanitize_text_field($_POST['anthropic_model'] ?? 'claude-sonnet-4-20250514')); if (isset($_POST['openrouter_key'])) update_option('lbraid_openrouter_key', sanitize_text_field(wp_unslash($_POST['openrouter_key']))); if (isset($_POST['openrouter_model'])) update_option('lbraid_openrouter_model', sanitize_text_field(wp_unslash($_POST['openrouter_model'])) ?: 'openai/gpt-5-mini'); } update_option('lbraid_max_links', max(1, min(20, intval($_POST['max_links'] ?? 8)))); update_option('lbraid_exclude_home', isset($_POST['exclude_home']) ? 1 : 0); update_option('lbraid_click_tracking', isset($_POST['click_tracking']) ? 1 : 0); $retention = absint($_POST['click_retention'] ?? 90); update_option('lbraid_click_retention_days', in_array($retention, [30, 90, 180, 365], true) ? $retention : 90); // Post types $post_types = isset($_POST['post_types']) ? array_map('sanitize_text_field', $_POST['post_types']) : ['post', 'page']; update_option('lbraid_post_types', implode(',', $post_types)); // Taxonomies whose archives are link targets ('' disables them entirely). $taxonomies = isset($_POST['taxonomies']) && is_array($_POST['taxonomies']) ? array_values(array_filter(array_map('sanitize_key', wp_unslash($_POST['taxonomies'])), 'taxonomy_exists')) : []; update_option('lbraid_taxonomies', implode(',', $taxonomies)); delete_transient(Linkbraid_Terms::URL_CACHE); // Ignore lists if (isset($_POST['ignored_posts'])) { $ignored = array_values(array_unique(array_filter(array_map('intval', explode(',', sanitize_text_field(wp_unslash($_POST['ignored_posts']))))))); update_option('lbraid_ignored_posts', implode(',', $ignored)); } if (isset($_POST['ignored_phrases'])) update_option('lbraid_ignored_phrases', sanitize_textarea_field(wp_unslash($_POST['ignored_phrases']))); // ACF scanning (the checkbox renders only when ACF is active) if (function_exists('get_field_objects')) update_option('lbraid_scan_acf', isset($_POST['scan_acf']) ? 1 : 0); // External link attributes (render-time) update_option('lbraid_ext_new_tab', isset($_POST['ext_new_tab']) ? 1 : 0); update_option('lbraid_ext_nofollow', isset($_POST['ext_nofollow']) ? 1 : 0); foreach (['nofollow_domains' => 'lbraid_nofollow_domains', 'sponsored_domains' => 'lbraid_sponsored_domains', 'dofollow_domains' => 'lbraid_dofollow_domains', 'new_tab_domains' => 'lbraid_new_tab_domains'] as $field => $option) { if (isset($_POST[$field])) update_option($option, sanitize_textarea_field(wp_unslash($_POST[$field]))); } // GSC: save only the fields present in the form (client fields are not rendered once connected). if (isset($_POST['gsc_client_id'])) update_option('lbraid_gsc_client_id', sanitize_text_field(wp_unslash($_POST['gsc_client_id']))); if (isset($_POST['gsc_client_secret']) && trim(sanitize_text_field(wp_unslash($_POST['gsc_client_secret']))) !== '') update_option('lbraid_gsc_client_secret', sanitize_text_field(wp_unslash($_POST['gsc_client_secret']))); if (lbraid_has_pro_code() && isset($_POST['gsc_site_url'])) { $gsc_site = sanitize_text_field(wp_unslash($_POST['gsc_site_url'])); if ($gsc_site === '' || Linkbraid_GSC::property_matches_site($gsc_site)) { update_option('lbraid_gsc_site_url', $gsc_site); } else { /* translators: %s: the Search Console property address that was rejected. */ echo '

' . esc_html( sprintf( __( "The property %s was not saved: it does not match this site's domain. Linkbraid only accepts Search Console properties of the site it runs on.", 'linkbraid' ), $gsc_site ) ) . '

'; } } echo '

' . esc_html__( 'Settings saved.', 'linkbraid' ) . '

'; } include LINKBRAID_PATH . 'admin/views/settings.php'; } public function render_gsc_page() { include LINKBRAID_PATH . 'admin/views/gsc.php'; } }