'',
'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__( '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 ) ) . '