'array', 'sanitize_callback' => array( Settings::class, 'sanitize' ), 'show_in_rest' => false, ) ); } /** * Tabs. * * @return array */ public static function tabs() { return array( 'general' => __( 'General', 'linkcortex' ), 'linking' => __( 'Linking rules', 'linkcortex' ), 'ai' => __( 'AI & Cost', 'linkcortex' ), 'advanced' => __( 'Advanced', 'linkcortex' ), 'status' => __( 'Status', 'linkcortex' ), ); } /** * Current tab from the query string (whitelisted). * * @return string */ public static function current_tab() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab switch. $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'general'; return array_key_exists( $tab, self::tabs() ) ? $tab : 'general'; } /** * Render the screen. * * @return void */ public static function render() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to change these settings.', 'linkcortex' ) ); } $tab = self::current_tab(); echo '
'; echo '

' . esc_html__( 'LinkCortex Settings', 'linkcortex' ) . '

'; settings_errors(); echo ''; if ( 'status' === $tab ) { self::render_status(); echo '
'; return; } echo '
'; settings_fields( self::GROUP ); call_user_func( array( __CLASS__, 'tab_' . $tab ) ); submit_button(); echo '
'; if ( 'advanced' === $tab ) { self::render_tools(); } if ( 'ai' === $tab ) { /** * Renders additional AI & Cost sections (usage, prices, calculator), outside the settings form. */ do_action( 'ailsug_settings_ai_tab' ); } echo ''; } /** * Field name helper. * * @param string $key Setting key. * @return string */ private static function name( $key ) { return Settings::OPTION . '[' . $key . ']'; } /** * Hidden list of checkbox fields on a tab, so unchecked boxes save as false. * * @param string[] $keys Keys. * @return void */ private static function bools( array $keys ) { printf( '', esc_attr( self::name( '_ailsug_bools' ) ), esc_attr( implode( ',', $keys ) ) ); } /** * Render a number row. * * @param string $key Setting key. * @param string $label Label. * @param string $help Help text. * @param int $min Min. * @param int $max Max. * @return void */ private static function number_row( $key, $label, $help, $min, $max ) { $id = 'ailsug-' . $key; echo ''; printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), (int) Settings::get( $key ), (int) $min, (int) $max ); echo '

' . esc_html( $help ) . '

'; } /** * Render a checkbox row. * * @param string $key Setting key. * @param string $label Label. * @param string $help Help text. * @return void */ private static function checkbox_row( $key, $label, $help ) { $id = 'ailsug-' . $key; echo '' . esc_html( $label ) . ''; printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), checked( (bool) Settings::get( $key ), true, false ), esc_html( $label ) ); echo '

' . esc_html( $help ) . '

'; } /** * Render an ID-list row. * * @param string $key Setting key. * @param string $label Label. * @param string $help Help text. * @return void */ private static function ids_row( $key, $label, $help ) { $id = 'ailsug-' . $key; echo ''; printf( '', esc_attr( $id ), esc_attr( self::name( $key ) ), esc_attr( implode( ', ', (array) Settings::get( $key ) ) ) ); echo '

' . esc_html( $help ) . '

'; } /** * General tab. * * @return void */ private static function tab_general() { self::bools( array( 'post_types', 'statuses', 'use_seo_adapters', 'delete_on_uninstall' ) ); echo ''; echo ''; echo ''; self::ids_row( 'excluded_posts', __( 'Never touch these pages', 'linkcortex' ), __( 'Post IDs separated by commas. These pages are skipped as sources and as targets.', 'linkcortex' ) ); self::ids_row( 'excluded_terms', __( 'Skip these categories/tags', 'linkcortex' ), __( 'Category or tag IDs separated by commas. Pages in them are skipped.', 'linkcortex' ) ); $mode = Settings::get( 'update_mode' ); echo ''; self::checkbox_row( 'use_seo_adapters', __( 'Use keywords from my SEO plugin', 'linkcortex' ), __( 'If Yoast SEO, Rank Math, All in One SEO or SEOPress already has a focus keyword for a page, use it and skip the AI step for that page (saves money).', 'linkcortex' ) ); self::checkbox_row( 'delete_on_uninstall', __( 'Delete all plugin data when uninstalling', 'linkcortex' ), __( 'Off by default. Links already added to your pages are never removed.', 'linkcortex' ) ); echo ''; } /** * Linking rules tab. * * @return void */ private static function tab_linking() { self::bools( array( 'new_tab' ) ); echo ''; self::number_row( 'max_suggestions', __( 'Suggestions per page', 'linkcortex' ), __( 'The most link ideas to show for one page.', 'linkcortex' ), 1, 20 ); self::number_row( 'max_links_per_apply', __( 'New links per page at a time', 'linkcortex' ), __( 'The most links added to one page in one go. Keeps pages readable.', 'linkcortex' ), 1, 20 ); self::number_row( 'max_same_target', __( 'Links to the same page', 'linkcortex' ), __( 'How many times one page may link to the same target. 1 is best practice.', 'linkcortex' ), 1, 5 ); self::number_row( 'min_score', __( 'Minimum relevance score', 'linkcortex' ), __( 'Hide suggestions scored below this (1–100). Higher means fewer but better ideas.', 'linkcortex' ), 1, 100 ); self::number_row( 'min_link_distance', __( 'Words between links', 'linkcortex' ), __( 'Keep at least this many words between two new links.', 'linkcortex' ), 0, 1000 ); self::checkbox_row( 'new_tab', __( 'Open links in a new tab', 'linkcortex' ), __( 'Usually not recommended for internal links.', 'linkcortex' ) ); self::ids_row( 'priority_targets', __( 'Priority pages', 'linkcortex' ), __( 'Post IDs of your most important pages (for example product or service pages). They are suggested more often.', 'linkcortex' ) ); echo ''; } /** * AI & Cost tab. * * @return void */ private static function tab_ai() { self::bools( array( 'strict_budget' ) ); $status = Availability::status(); echo '

' . esc_html__( 'Connected AI', 'linkcortex' ) . '

'; self::ai_status_box( $status ); echo ''; $quality = Settings::get( 'quality' ); echo ''; $only = (string) Settings::get( 'only_model' ); $options = array(); foreach ( Availability::text_models() as $provider => $models ) { foreach ( $models as $model ) { $options[ $provider . ':' . $model ] = $provider . ' — ' . $model; } } if ( '' !== $only && ! isset( $options[ $only ] ) ) { $options[ $only ] = $only . ' ' . __( '(not currently available)', 'linkcortex' ); } echo ''; echo ''; self::number_row( 'alert_threshold', __( 'Warn me at (% of budget)', 'linkcortex' ), __( 'Show a warning on the dashboard when estimated spend reaches this share of the cap.', 'linkcortex' ), 1, 100 ); echo ''; self::checkbox_row( 'strict_budget', __( 'Strict budget mode', 'linkcortex' ), __( 'Recommended. When the price of the AI model is unknown, paid AI runs are paused until you enter its price below. If you turn this off, the budget cap cannot be enforced for models with unknown prices.', 'linkcortex' ) ); echo ''; echo ''; } /** * Advanced tab. * * @return void */ private static function tab_advanced() { self::bools( array( 'debug_log', 'verbose_log' ) ); echo ''; self::number_row( 'batch_size', __( 'Pages per batch', 'linkcortex' ), __( 'How many pages are processed in each background step. Lower is gentler on slow hosting.', 'linkcortex' ), 1, 50 ); $schedule = Settings::get( 'schedule' ); echo ''; self::number_row( 'shortlist_size', __( 'Candidate pages per page', 'linkcortex' ), __( 'How many possible targets are shown to the AI for each page. More can find better links but costs more.', 'linkcortex' ), 3, 50 ); self::number_row( 'max_chunks', __( 'Paragraphs sent per page', 'linkcortex' ), __( 'Only the most relevant paragraphs are sent, never the whole page.', 'linkcortex' ), 1, 20 ); self::number_row( 'excerpt_words', __( 'Opening words read for keywords', 'linkcortex' ), __( 'How much of the start of each page the AI reads to find its keyword.', 'linkcortex' ), 50, 1000 ); self::checkbox_row( 'debug_log', __( 'Debug log', 'linkcortex' ), __( 'Records errors and timings (never page content) to help with troubleshooting.', 'linkcortex' ) ); self::checkbox_row( 'verbose_log', __( 'Verbose debug log', 'linkcortex' ), __( 'Warning: also records the text sent to the AI. Turn off when you are done.', 'linkcortex' ) ); echo ''; } /** * Tools below the Advanced form: export/import, reset. * * @return void */ private static function render_tools() { echo '

' . esc_html__( 'Tools', 'linkcortex' ) . '

'; echo '
'; wp_nonce_field( 'ailsug_export_settings' ); echo ''; submit_button( __( 'Export settings', 'linkcortex' ), 'secondary', 'submit', false ); echo '
'; echo '
'; wp_nonce_field( 'ailsug_import_settings' ); echo ''; echo ' '; echo ' '; submit_button( __( 'Import', 'linkcortex' ), 'secondary', 'submit', false ); echo '
'; /** * Renders additional tools (e.g. reset index). */ do_action( 'ailsug_settings_tools' ); } /** * Status tab. * * @return void */ private static function render_status() { global $wpdb; $status = Availability::status(); echo '

' . esc_html__( 'AI connection', 'linkcortex' ) . '

'; self::ai_status_box( $status ); $prediction = ModelResolver::predict(); $last = get_option( 'ailsug_last_model', array() ); echo ''; self::status_row( __( 'Likely model for the next run', 'linkcortex' ), $prediction['likely'] ? implode( ' — ', $prediction['likely'] ) : __( 'Unknown', 'linkcortex' ) ); self::status_row( __( 'Model that answered last', 'linkcortex' ), is_array( $last ) && ! empty( $last['model'] ) ? $last['provider'] . ' — ' . $last['model'] : __( 'None yet', 'linkcortex' ) ); $tables = Activator::tables(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Live counts for a status screen. $counts = $wpdb->get_results( $wpdb->prepare( 'SELECT status, COUNT(*) AS n FROM %i GROUP BY status', $tables['queue'] ), OBJECT_K ); $parts = array(); foreach ( (array) $counts as $row ) { $parts[] = $row->status . ': ' . number_format_i18n( (int) $row->n ); } self::status_row( __( 'Background queue', 'linkcortex' ), $parts ? implode( ', ', $parts ) : __( 'Empty', 'linkcortex' ) ); $next = wp_next_scheduled( 'ailsug_cron_tick' ); if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { $cron = __( 'WordPress background tasks (WP-Cron) are turned off on this site. Use "Run now" on the dashboard, or ask your host to run cron.', 'linkcortex' ); } else { $cron = $next /* translators: %s: human time difference. */ ? sprintf( __( 'Working. Next background step in %s.', 'linkcortex' ), human_time_diff( time(), $next ) ) : __( 'Working. Nothing scheduled.', 'linkcortex' ); } self::status_row( __( 'Background tasks', 'linkcortex' ), $cron ); self::status_row( __( 'Database version', 'linkcortex' ), (string) get_option( Activator::DB_OPTION, '—' ) . ' (' . __( 'expected', 'linkcortex' ) . ' ' . AILSUG_DB_VERSION . ')' ); self::status_row( __( 'Plugin version', 'linkcortex' ), AILSUG_VERSION ); echo '
'; } /** * One status table row. * * @param string $label Label. * @param string $value Value. * @return void */ private static function status_row( $label, $value ) { echo '' . esc_html( $label ) . '' . esc_html( $value ) . ''; } /** * AI status box with next steps. * * @param array $status Availability status. * @return void */ public static function ai_status_box( array $status ) { $ok = ! empty( $status['available'] ); echo '
'; echo '

' . ( $ok ? esc_html__( '✓ Ready', 'linkcortex' ) : esc_html__( '✗ Not ready', 'linkcortex' ) ) . ' ' . esc_html( $status['message'] ) . '

'; if ( ! empty( $status['providers'] ) ) { /* translators: %s: comma separated provider ids. */ echo '

' . esc_html( sprintf( __( 'Connected providers: %s', 'linkcortex' ), implode( ', ', $status['providers'] ) ) ) . '

'; } if ( ! $ok && 'no_provider' === $status['code'] ) { echo '

' . esc_html__( 'To use AI features, install one of the official provider plugins and add your key in Settings → Connectors:', 'linkcortex' ) . '

' . esc_html__( 'Everything else (page index, link map, orphan pages, keywords you type yourself) works without AI.', 'linkcortex' ) . '

'; } echo '

'; if ( Availability::core_present() && current_user_can( 'manage_options' ) ) { echo '' . esc_html__( 'Open Settings → Connectors', 'linkcortex' ) . ' '; } $refresh = wp_nonce_url( admin_url( 'admin-post.php?action=ailsug_refresh_ai' ), 'ailsug_refresh_ai' ); echo '' . esc_html__( 'Check again', 'linkcortex' ) . ''; if ( ! empty( $status['checked_at'] ) ) { /* translators: %s: human time difference. */ echo ' ' . esc_html( sprintf( __( 'Checked %s ago.', 'linkcortex' ), human_time_diff( (int) $status['checked_at'] ) ) ) . ''; } echo '

'; } /** * Format micros as a plain decimal string. * * @param int $micros Micros. * @return string */ public static function money( $micros ) { return rtrim( rtrim( number_format( $micros / 1000000, 2, '.', '' ), '0' ), '.' ); } /** * Refresh AI availability. * * @return void */ public static function handle_refresh_ai() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_refresh_ai' ); Availability::forget(); delete_transient( 'ailsug_ai_models' ); Availability::status( true ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : Menu::url( 'settings', array( 'tab' => 'status' ) ) ); exit; } /** * Download settings as JSON. * * @return void */ public static function handle_export() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_export_settings' ); nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=linkcortex-settings.json' ); echo wp_json_encode( array( 'plugin' => 'linkcortex', 'version' => AILSUG_VERSION, 'settings' => Settings::all(), 'pricing' => get_option( 'ailsug_pricing_overrides', array() ), ), JSON_PRETTY_PRINT ); exit; } /** * Import settings from a JSON upload (values go through the normal sanitizer). * * @return void */ public static function handle_import() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'linkcortex' ) ); } check_admin_referer( 'ailsug_import_settings' ); $ok = false; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- tmp_name is checked with is_uploaded_file. $tmp = isset( $_FILES['ailsug_settings_file']['tmp_name'] ) ? (string) $_FILES['ailsug_settings_file']['tmp_name'] : ''; if ( $tmp && is_uploaded_file( $tmp ) && filesize( $tmp ) < 200000 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local uploaded file. $data = json_decode( (string) file_get_contents( $tmp ), true ); if ( is_array( $data ) && 'linkcortex' === ( $data['plugin'] ?? '' ) && is_array( $data['settings'] ?? null ) ) { $input = $data['settings']; // Convert stored micros back to the decimal fields the sanitizer expects. if ( isset( $input['budget_cap_micros'] ) ) { $input['budget_cap'] = self::money( (int) $input['budget_cap_micros'] ); } if ( isset( $input['confirm_micros'] ) ) { $input['confirm_amount'] = self::money( (int) $input['confirm_micros'] ); } unset( $input['budget_cap_micros'], $input['confirm_micros'] ); $input['_ailsug_bools'] = 'post_types,statuses'; update_option( Settings::OPTION, Settings::sanitize( $input ), false ); Settings::flush(); if ( isset( $data['pricing'] ) && is_array( $data['pricing'] ) && class_exists( '\\AILSUG\\Cost\\Pricing' ) ) { \AILSUG\Cost\Pricing::save_overrides( $data['pricing'] ); } $ok = true; } }//end if add_settings_error( 'ailsug', $ok ? 'imported' : 'import_failed', $ok ? __( 'Settings imported.', 'linkcortex' ) : __( 'That file could not be imported. Choose a settings file exported by this plugin.', 'linkcortex' ), $ok ? 'success' : 'error' ); set_transient( 'settings_errors', get_settings_errors(), 30 ); wp_safe_redirect( add_query_arg( 'settings-updated', 'true', Menu::url( 'settings', array( 'tab' => 'advanced' ) ) ) ); exit; } }