PluginProbe
VigIA – AI Visibility, Analytics & Control / trunk
VigIA – AI Visibility, Analytics & Control vtrunk
2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.0 2.4.5 2.4.4 2.4.3 2.4.2 2.4.1 2.4.0 2.3.0 2.2.0 2.1.0 2.0.3 2.0.2 2.0.1 2.0.0 1.12.1 1.12.0 1.11.0 trunk 1.0.0 1.1.0 All 55 releases
vigia / vigia.php

vigia.php in VigIA – AI Visibility, Analytics & Control trunk, at vigia.php

1,865 lines 80.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Name: VigIA - AI Visibility, Analytics & Control
4 * Plugin URI: https://servicios.ayudawp.com
5 * Description: Monitor, control, and optimize how AI systems interact with your WordPress site. Track 60+ AI crawlers, manage access via robots.txt, and boost your AI visibility with llms.txt, JSON-LD, Markdown for Agents, and AI Visibility Score.
6 * Version: 2.6.4
7 * Author: Fernando Tellado
8 * Author URI: https://ayudawp.com
9 * License: GPL v2 or later
10 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
11 * Text Domain: vigia
12 * Requires at least: 6.9
13 * Requires PHP: 7.4
14 * Tested up to: 7.1
15 *
16 * @package VigIA
17 */
18
19 // Prevent direct access.
20 if ( ! defined( 'ABSPATH' ) ) {
21 exit;
22 }
23
24 // Plugin constants.
25 define( 'VIGIA_VERSION', '2.6.4' );
26 define( 'VIGIA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
27 define( 'VIGIA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
28 define( 'VIGIA_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
29
30 /**
31 * Main plugin class
32 */
33 final class VigIA {
34
35 /**
36 * Single instance of the class
37 *
38 * @var VigIA
39 */
40 private static $instance = null;
41
42 /**
43 * Get single instance of the class
44 *
45 * @return VigIA
46 */
47 public static function get_instance() {
48 if ( null === self::$instance ) {
49 self::$instance = new self();
50 }
51 return self::$instance;
52 }
53
54 /**
55 * Constructor
56 */
57 private function __construct() {
58 $this->load_dependencies();
59 $this->init_hooks();
60 }
61
62 /**
63 * Load required files
64 */
65 private function load_dependencies() {
66 // Composer autoload for optional MCP server dependency. Loaded
67 // defensively so the plugin keeps working when the adapter is
68 // not installed via composer install.
69 if ( file_exists( VIGIA_PLUGIN_DIR . 'vendor/autoload.php' ) ) {
70 require_once VIGIA_PLUGIN_DIR . 'vendor/autoload.php';
71
72 // Boot the bundled adapter WITHOUT loading its mcp-adapter.php
73 // file. That file is the adapter's standalone-plugin wrapper: it
74 // declares the WP\MCP\Autoloader class and, in older releases,
75 // the WP\MCP\constants() function plus WP_MCP_DIR and
76 // WP_MCP_VERSION. Every one of those is a global name, so a second
77 // plugin shipping its own copy of the adapter turns them into
78 // "already defined" warnings and a fatal "Cannot redeclare class
79 // WP\MCP\Autoloader". WooCommerce, WP Rocket and Elementor all
80 // consume the adapter by calling McpAdapter::instance() on the
81 // autoloaded classes; this does the same.
82 //
83 // Guarding on function_exists( 'WP\MCP\constants' ) is NOT enough:
84 // upstream dropped that function, so recent copies pass the guard.
85 //
86 // WP_MCP_DIR is deliberately left undefined. Nothing in VigIA reads
87 // it, and pointing it at our directory would send another copy's
88 // Autoloader looking for its Composer autoloader inside our bundle.
89 if ( class_exists( '\\WP\\MCP\\Core\\McpAdapter' ) ) {
90 if ( ! defined( 'WP_MCP_VERSION' ) ) {
91 // Declared before instantiating: without it the adapter
92 // logs a deprecation notice on every request, its way of
93 // telling library consumers to install the standalone
94 // plugin. The name is fixed by upstream and cannot be
95 // prefixed. Read from the class that actually loaded, so
96 // the value stays true if another plugin's copy won the
97 // autoload race.
98 define( 'WP_MCP_VERSION', \WP\MCP\Core\McpAdapter::VERSION ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- upstream contract.
99 }
100
101 \WP\MCP\Core\McpAdapter::instance();
102 }
103 }
104
105 require_once VIGIA_PLUGIN_DIR . 'includes/class-sibling-visibility.php';
106 // Before the surfaces that consult it: Markdown for agents, llms.txt and
107 // the admin screens all gate what they publish through this class.
108 require_once VIGIA_PLUGIN_DIR . 'includes/class-content-access.php';
109 require_once VIGIA_PLUGIN_DIR . 'includes/class-database.php';
110 require_once VIGIA_PLUGIN_DIR . 'includes/class-settings.php';
111 require_once VIGIA_PLUGIN_DIR . 'includes/class-crawler-detector.php';
112 require_once VIGIA_PLUGIN_DIR . 'includes/class-blocker.php';
113 require_once VIGIA_PLUGIN_DIR . 'includes/class-robots-manager.php';
114 require_once VIGIA_PLUGIN_DIR . 'includes/class-email-alerts.php';
115 require_once VIGIA_PLUGIN_DIR . 'includes/class-llms-generator.php';
116 require_once VIGIA_PLUGIN_DIR . 'includes/class-visibility-analyzer.php';
117 require_once VIGIA_PLUGIN_DIR . 'includes/class-visibility-page.php';
118 require_once VIGIA_PLUGIN_DIR . 'includes/class-admin-page.php';
119 require_once VIGIA_PLUGIN_DIR . 'includes/class-extras-page.php';
120 require_once VIGIA_PLUGIN_DIR . 'includes/class-dashboard-widget.php';
121 require_once VIGIA_PLUGIN_DIR . 'includes/class-rest-api.php';
122 require_once VIGIA_PLUGIN_DIR . 'includes/class-abilities.php';
123 require_once VIGIA_PLUGIN_DIR . 'includes/class-mcp-server.php';
124 require_once VIGIA_PLUGIN_DIR . 'includes/class-promo-banner.php';
125 require_once VIGIA_PLUGIN_DIR . 'includes/class-markdown-endpoints.php';
126 require_once VIGIA_PLUGIN_DIR . 'includes/class-jsonld-generator.php';
127 require_once VIGIA_PLUGIN_DIR . 'includes/class-command-palette.php';
128 }
129
130 /**
131 * Initialize hooks
132 */
133 private function init_hooks() {
134 // Activation and deactivation.
135 register_activation_hook( __FILE__, array( $this, 'activate' ) );
136 register_deactivation_hook( __FILE__, array( $this, 'deactivate' ) );
137
138 // Initialize blocker early (before tracking).
139 add_action( 'plugins_loaded', array( 'VigIA_Blocker', 'init' ), 1 );
140
141 // Track AI crawler visits on the shutdown hook, when the request's
142 // final HTTP status is known. Hooking on init (as before) always
143 // recorded 200 because WordPress resolves 404s and redirects later.
144 add_action( 'shutdown', array( $this, 'track_crawler_visit' ) );
145
146 // Initialize components.
147 add_action( 'admin_menu', array( 'VigIA_Admin_Page', 'register_menu' ) );
148 add_action( 'admin_menu', array( 'VigIA_Visibility_Page', 'register_menu' ) );
149 add_action( 'admin_menu', array( 'VigIA_Extras_Page', 'register_menu' ) );
150 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
151 add_action( 'wp_dashboard_setup', array( 'VigIA_Dashboard_Widget', 'register' ) );
152 add_action( 'rest_api_init', array( 'VigIA_Rest_API', 'register_routes' ) );
153
154 // Abilities API (WordPress 6.9+).
155 if ( function_exists( 'wp_register_ability' ) ) {
156 VigIA_Abilities::init();
157 }
158
159 // MCP server (requires the WordPress MCP Adapter via Composer).
160 VigIA_MCP_Server::init();
161
162 // Markdown endpoints for AI agents.
163 add_action( 'plugins_loaded', array( 'VigIA_Markdown_Endpoints', 'init' ), 5 );
164
165 // JSON-LD structured data.
166 add_action( 'wp', array( 'VigIA_JsonLD_Generator', 'init' ) );
167
168 // Command Palette (Cmd/Ctrl+K) navigation and quick actions.
169 VigIA_Command_Palette::init();
170
171 // Scheduled tasks.
172 add_action( 'vigia_daily_cleanup', array( 'VigIA_Settings', 'run_cleanup' ) );
173 add_action( 'vigia_send_email_alerts', array( 'VigIA_Email_Alerts', 'send_scheduled_alerts' ) );
174 add_action( 'vigia_llms_regenerate', array( 'VigIA_LLMS_Generator', 'cron_regenerate' ) );
175 add_action( 'vigia_backfill_content_type', array( $this, 'run_content_type_backfill' ) );
176 add_action( 'vigia_optimize_indexes', array( $this, 'run_index_optimization' ) );
177 add_action( 'vigia_warm_stats_cache', array( 'VigIA_Database', 'warm_stats_cache' ) );
178 add_action( 'vigia_warm_stats_cache_now', array( 'VigIA_Database', 'warm_stats_cache' ) );
179
180 // Run schema migrations on every admin request — idempotent, only
181 // touches dbDelta when DB_VERSION is newer than the stored version.
182 add_action( 'admin_init', array( 'VigIA_Database', 'maybe_upgrade_schema' ) );
183
184 // Rebuild the generated files after an update, on the same terms.
185 add_action( 'admin_init', array( $this, 'maybe_upgrade_version' ) );
186
187 // Reconcile ceded emission with the Visibility sibling: when Visibility
188 // owns a signal, drop VigIA's now-shadowing physical artifacts so the two
189 // don't fight over the file (a physical llms.txt / robots block at the
190 // site root would shadow Visibility's virtual output). Idempotent and
191 // cheap: only touches disk when a file is actually there to remove.
192 add_action( 'admin_init', array( $this, 'reconcile_visibility_cession' ) );
193
194 // Settings link in plugins page.
195 add_filter( 'plugin_action_links_' . VIGIA_PLUGIN_BASENAME, array( $this, 'add_settings_link' ) );
196
197 // Activation notice.
198 add_action( 'admin_notices', array( $this, 'activation_notice' ) );
199 add_action( 'wp_ajax_vigia_dismiss_notice', array( $this, 'dismiss_notice' ) );
200
201 // AJAX handlers.
202 $this->register_ajax_handlers();
203 }
204
205 /**
206 * Register AJAX handlers
207 */
208 private function register_ajax_handlers() {
209 // Settings.
210 add_action( 'wp_ajax_vigia_save_settings', array( $this, 'ajax_save_settings' ) );
211 add_action( 'wp_ajax_vigia_delete_all_data', array( $this, 'ajax_delete_all_data' ) );
212 add_action( 'wp_ajax_vigia_optimize_indexes', array( $this, 'ajax_optimize_indexes' ) );
213 add_action( 'wp_ajax_vigia_add_custom_crawler', array( $this, 'ajax_add_custom_crawler' ) );
214 add_action( 'wp_ajax_vigia_remove_custom_crawler', array( $this, 'ajax_remove_custom_crawler' ) );
215 add_action( 'wp_ajax_vigia_toggle_crawlers_box', array( $this, 'ajax_toggle_crawlers_box' ) );
216
217 // Blocking.
218 add_action( 'wp_ajax_vigia_block_crawler', array( $this, 'ajax_block_crawler' ) );
219 add_action( 'wp_ajax_vigia_unblock_crawler', array( $this, 'ajax_unblock_crawler' ) );
220 add_action( 'wp_ajax_vigia_unblock_by_id', array( $this, 'ajax_unblock_by_id' ) );
221
222 // Robots.txt.
223 add_action( 'wp_ajax_vigia_add_robots_rule', array( $this, 'ajax_add_robots_rule' ) );
224 add_action( 'wp_ajax_vigia_remove_robots_rule', array( $this, 'ajax_remove_robots_rule' ) );
225 add_action( 'wp_ajax_vigia_get_robots_content', array( $this, 'ajax_get_robots_content' ) );
226
227 // Email alerts.
228 add_action( 'wp_ajax_vigia_save_email_settings', array( $this, 'ajax_save_email_settings' ) );
229 add_action( 'wp_ajax_vigia_test_email', array( $this, 'ajax_test_email' ) );
230
231 // LLMs.txt.
232 add_action( 'wp_ajax_vigia_generate_llms', array( $this, 'ajax_generate_llms' ) );
233 add_action( 'wp_ajax_vigia_save_llms_settings', array( $this, 'ajax_save_llms_settings' ) );
234 add_action( 'wp_ajax_vigia_delete_llms_files', array( $this, 'ajax_delete_llms_files' ) );
235
236 // LLMs.txt - New handlers for v1.2.0.
237 add_action( 'wp_ajax_vigia_search_posts', array( $this, 'ajax_search_posts' ) );
238 add_action( 'wp_ajax_vigia_get_taxonomies', array( $this, 'ajax_get_taxonomies' ) );
239
240 // Markdown endpoints.
241 add_action( 'wp_ajax_vigia_save_markdown_settings', array( $this, 'ajax_save_markdown_settings' ) );
242
243 // JSON-LD.
244 add_action( 'wp_ajax_vigia_save_jsonld_settings', array( $this, 'ajax_save_jsonld_settings' ) );
245
246 // Share Buttons & AI-powered Summaries tip.
247 add_action( 'wp_ajax_vigia_dismiss_aiss_tip', array( $this, 'ajax_dismiss_aiss_tip' ) );
248
249 // AI Visibility analyzer (v1.8.0).
250 add_action( 'wp_ajax_vigia_run_visibility_analysis', array( $this, 'ajax_run_visibility_analysis' ) );
251 add_action( 'wp_ajax_vigia_search_visibility_urls', array( $this, 'ajax_search_visibility_urls' ) );
252
253 // MCP one-click connect (v1.12.0).
254 add_action( 'wp_ajax_vigia_create_mcp_app_password', array( $this, 'ajax_create_mcp_app_password' ) );
255 add_action( 'wp_ajax_vigia_revoke_mcp_app_password', array( $this, 'ajax_revoke_mcp_app_password' ) );
256 add_action( 'wp_ajax_vigia_save_mcp_readonly', array( $this, 'ajax_save_mcp_readonly' ) );
257 }
258
259 /**
260 * Plugin activation
261 */
262 public function activate() {
263 VigIA_Database::create_tables();
264
265 // Set activation notice flag.
266 update_option( 'vigia_activation_notice', true );
267
268 // Schedule daily cleanup.
269 if ( ! wp_next_scheduled( 'vigia_daily_cleanup' ) ) {
270 wp_schedule_event( time(), 'daily', 'vigia_daily_cleanup' );
271 }
272
273 // Schedule the content_type backfill cron for sites upgrading from
274 // pre-2.0.0. Newly installed sites will simply find no rows to process.
275 if ( ! wp_next_scheduled( 'vigia_backfill_content_type' ) ) {
276 wp_schedule_event( time() + 60, 'hourly', 'vigia_backfill_content_type' );
277 }
278
279 // Bring an existing table up to the composite indexes, in the
280 // background. A fresh install already has them from create_tables().
281 VigIA_Database::schedule_index_optimization();
282
283 // Keep the long date ranges warm so nobody waits for them.
284 if ( ! wp_next_scheduled( 'vigia_warm_stats_cache' ) ) {
285 wp_schedule_event( time() + ( 5 * MINUTE_IN_SECONDS ), 'hourly', 'vigia_warm_stats_cache' );
286 }
287
288 // Schedule email alerts if enabled.
289 VigIA_Email_Alerts::schedule_alerts();
290 }
291
292 /**
293 * Plugin deactivation
294 */
295 public function deactivate() {
296 wp_clear_scheduled_hook( 'vigia_daily_cleanup' );
297 wp_clear_scheduled_hook( 'vigia_send_email_alerts' );
298 wp_clear_scheduled_hook( 'vigia_llms_regenerate' );
299 wp_clear_scheduled_hook( 'vigia_backfill_content_type' );
300 wp_clear_scheduled_hook( 'vigia_optimize_indexes' );
301 wp_clear_scheduled_hook( 'vigia_warm_stats_cache' );
302 wp_clear_scheduled_hook( 'vigia_warm_stats_cache_now' );
303 }
304
305 /**
306 * Cron handler for the content_type backfill queue.
307 *
308 * Drains in batches and re-schedules itself a minute later while there is
309 * still work left, so a site upgrading with a long history finishes in
310 * hours instead of the weeks an hourly-only 500-row tick would take. The
311 * legacy "other" bucket is swept by the same handler, once.
312 *
313 * Nothing here ever runs inside a page load: the whole point is that the
314 * dashboard never pays for this work.
315 */
316 public function run_content_type_backfill() {
317 $filled = VigIA_Database::backfill_content_types( 1000 );
318
319 // Only start sweeping the legacy "other" bucket once every
320 // never-classified row has a value, so the visible column is right
321 // first and the cosmetic re-bucketing comes after.
322 if ( 0 === $filled ) {
323 VigIA_Database::reclassify_other_bucket( 1000 );
324 }
325
326 $more_work = ( $filled > 0 ) || ! get_option( 'vigia_other_bucket_swept', false );
327
328 if ( $more_work && ! wp_next_scheduled( 'vigia_backfill_content_type' ) ) {
329 wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'vigia_backfill_content_type' );
330 }
331 }
332
333 /**
334 * Cron handler that creates the analytics indexes.
335 *
336 * Kept out of any page load: on a table with a long history the ALTER can
337 * take minutes, which is exactly the kind of thing that must not happen
338 * while an admin waits for a screen to paint.
339 *
340 * @since 2.5.0
341 */
342 public function run_index_optimization() {
343 VigIA_Database::create_performance_indexes();
344 }
345
346 /**
347 * Reconcile ceded emission with the Visibility sibling.
348 *
349 * Runs on admin_init. When Visibility owns a signal that VigIA writes to a
350 * physical file (llms.txt / llms-full.txt and the robots.txt AI block), VigIA
351 * removes its own artifact so a stale physical file at the site root does not
352 * shadow Visibility's virtual output. The runtime emitters already bail via
353 * VigIA_Sibling_Visibility::should_defer(); this is the on-disk cleanup that
354 * the bail alone cannot do. Both helpers are idempotent and only touch disk
355 * when there is actually something of VigIA's to remove.
356 */
357 public function reconcile_visibility_cession() {
358 // Touches the filesystem (removes VigIA's own llms.txt / robots block),
359 // so gate on the same capability that manages these settings. Both
360 // helpers are otherwise idempotent and cheap when there is nothing to do.
361 if ( ! current_user_can( 'manage_options' ) ) {
362 return;
363 }
364
365 if ( VigIA_Sibling_Visibility::should_defer( 'llms' ) ) {
366 VigIA_LLMS_Generator::cleanup_for_cession();
367 }
368
369 if ( VigIA_Sibling_Visibility::should_defer( 'robots' ) ) {
370 VigIA_Robots_Manager::cleanup_for_cession();
371 }
372 }
373
374 /**
375 * Carry a site over to the running version, once per update.
376 *
377 * llms.txt and llms-full.txt are written to disk, so a site that upgrades
378 * keeps serving whatever was in them until something regenerates them.
379 * Refinements to which entries go in therefore only reach an existing site
380 * if the update rebuilds the files itself, rather than waiting for the next
381 * scheduled run, which may be a month out or turned off entirely.
382 *
383 * Runs on `admin_init`, so the version only advances once someone with the
384 * capability to manage these files loads a page. A regeneration failure
385 * leaves the stored version alone and is retried on the next pageload.
386 */
387 public function maybe_upgrade_version() {
388 $stored = get_option( 'vigia_version', '0.0.0' );
389
390 if ( version_compare( $stored, VIGIA_VERSION, '>=' ) ) {
391 return;
392 }
393
394 if ( ! current_user_can( 'manage_options' ) ) {
395 return;
396 }
397
398 // Repair a physical robots.txt whose markers another plugin glued to the
399 // line above. It does not heal on its own: the glued marker used to be
400 // invisible to our cleanup, so the broken line stayed and every save
401 // appended one more copy of the block. No-op when there is nothing glued.
402 VigIA_Robots_Manager::repair_physical_robots();
403
404 // A file on disk is the signal: the llms generator has no on/off flag of
405 // its own, it either has written the files or it has not. Nothing to
406 // rebuild otherwise, and the generator declines the job anyway when
407 // llms.txt is ceded to Visibility.
408 if ( VigIA_LLMS_Generator::llms_exists() || VigIA_LLMS_Generator::llms_full_exists() ) {
409 $result = VigIA_LLMS_Generator::generate( VigIA_LLMS_Generator::get_settings() );
410
411 // Nothing to regenerate from, or the sibling owns the file now: both
412 // are settled states, not something a later pageload would fix.
413 if ( is_wp_error( $result )
414 && ! in_array( $result->get_error_code(), array( 'no_content', 'ceded_to_visibility' ), true ) ) {
415 return;
416 }
417 }
418
419 update_option( 'vigia_version', VIGIA_VERSION );
420 }
421
422 /**
423 * Record an AI crawler visit for the current request.
424 *
425 * Hooked on `shutdown` so http_response_code() reports the real status
426 * WordPress sent (200, 404, 301, 410…). Running earlier (e.g. on init)
427 * always saw 200 because the 404/redirect outcome is resolved after init.
428 */
429 public function track_crawler_visit() {
430 // Only track frontend requests; never admin, AJAX, cron or REST.
431 if ( ! is_admin() && ! wp_doing_ajax() && ! wp_doing_cron() && ! defined( 'REST_REQUEST' ) ) {
432 VigIA_Crawler_Detector::track_request();
433 }
434 }
435
436 /**
437 * Enqueue admin assets
438 *
439 * @param string $hook Current admin page hook.
440 */
441 public function enqueue_admin_assets( $hook ) {
442 // Only load on plugin pages and dashboard.
443 $plugin_pages = array( 'toplevel_page_vigia', 'vigia_page_vigia-visibility', 'vigia_page_vigia-extras', 'index.php' );
444 if ( ! in_array( $hook, $plugin_pages, true ) ) {
445 return;
446 }
447
448 // Load thickbox for plugin install modals.
449 if ( 'toplevel_page_vigia' === $hook ) {
450 add_thickbox();
451 }
452
453 // Load thickbox for visibility page plugin recommendations.
454 if ( 'vigia_page_vigia-visibility' === $hook ) {
455 add_thickbox();
456 }
457
458 // Media library for JSON-LD logo picker.
459 if ( 'vigia_page_vigia-extras' === $hook ) {
460 wp_enqueue_media();
461 }
462
463 wp_enqueue_style(
464 'vigia-admin',
465 VIGIA_PLUGIN_URL . 'assets/css/admin-styles.css',
466 array(),
467 VIGIA_VERSION
468 );
469
470 // Extras page styles.
471 if ( 'vigia_page_vigia-extras' === $hook ) {
472 wp_enqueue_style(
473 'vigia-extras',
474 VIGIA_PLUGIN_URL . 'assets/css/extras-styles.css',
475 array( 'vigia-admin' ),
476 VIGIA_VERSION
477 );
478 }
479
480 // Visibility page styles (v1.8.0).
481 if ( 'vigia_page_vigia-visibility' === $hook ) {
482 wp_enqueue_style(
483 'vigia-extras',
484 VIGIA_PLUGIN_URL . 'assets/css/extras-styles.css',
485 array( 'vigia-admin' ),
486 VIGIA_VERSION
487 );
488 wp_enqueue_style(
489 'vigia-visibility',
490 VIGIA_PLUGIN_URL . 'assets/css/visibility-styles.css',
491 array( 'vigia-admin' ),
492 VIGIA_VERSION
493 );
494 }
495
496 wp_enqueue_script(
497 'vigia-chart',
498 VIGIA_PLUGIN_URL . 'assets/js/chart.min.js',
499 array(),
500 '4.5.0',
501 true
502 );
503
504 wp_enqueue_script(
505 'vigia-admin',
506 VIGIA_PLUGIN_URL . 'assets/js/admin-scripts.js',
507 array( 'jquery', 'vigia-chart' ),
508 VIGIA_VERSION,
509 true
510 );
511
512 // Extras page scripts.
513 if ( 'vigia_page_vigia-extras' === $hook ) {
514 wp_enqueue_script(
515 'vigia-extras',
516 VIGIA_PLUGIN_URL . 'assets/js/extras-scripts.js',
517 array( 'jquery', 'vigia-admin' ),
518 VIGIA_VERSION,
519 true
520 );
521 }
522
523 // Visibility page scripts (v1.8.0).
524 if ( 'vigia_page_vigia-visibility' === $hook ) {
525 wp_enqueue_script(
526 'vigia-visibility',
527 VIGIA_PLUGIN_URL . 'assets/js/visibility-scripts.js',
528 array( 'jquery' ),
529 VIGIA_VERSION,
530 true
531 );
532
533 wp_localize_script(
534 'vigia-visibility',
535 'vigiaVisData',
536 array(
537 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
538 'ajaxNonce' => wp_create_nonce( 'vigia_ajax_nonce' ),
539 'homeUrl' => home_url( '/' ),
540 'pluginInstallUrl' => admin_url( 'plugin-install.php' ),
541 'pluginsUrl' => admin_url( 'plugins.php' ),
542 'strings' => array(
543 'analyze' => __( 'Analyze', 'vigia' ),
544 'analyzing' => __( 'Analyzing...', 'vigia' ),
545 'analyzingDetail' => __( 'Analyzing AI visibility: checking robots.txt, llms.txt, schemas, sitemap, feed, performance...', 'vigia' ),
546 'reanalyze' => __( 'Re-analyze (clear cache)', 'vigia' ),
547 'cachedInfo' => __( 'Results are cached for 24 hours. Click to force a fresh analysis.', 'vigia' ),
548 'errorTitle' => __( 'Error analyzing page', 'vigia' ),
549 'errorGeneric' => __( 'Could not retrieve page information.', 'vigia' ),
550 'homepage' => __( 'Homepage', 'vigia' ),
551 'contentPage' => __( 'Content page', 'vigia' ),
552 'grade' => __( 'Grade', 'vigia' ),
553 'score' => __( 'Score', 'vigia' ),
554 'pageType' => __( 'Page type', 'vigia' ),
555 'points' => __( 'points', 'vigia' ),
556 'statusExcellent' => __( 'Excellent', 'vigia' ),
557 'statusGood' => __( 'Good', 'vigia' ),
558 'statusFair' => __( 'Fair', 'vigia' ),
559 'statusPoor' => __( 'Poor', 'vigia' ),
560 'recommendationsTitle' => __( 'Recommendations to improve your score', 'vigia' ),
561 ),
562 )
563 );
564 }
565
566 // Get current settings for JS.
567 $settings = VigIA_Settings::get_settings();
568
569 // Get category labels and colors for JS.
570 $categories = VigIA_Crawler_Detector::get_category_labels();
571 $category_colors = VigIA_Crawler_Detector::get_category_colors();
572
573 // Get blocking data for JS.
574 $blocked_ua = array();
575 $blocked_ips = array();
576 $ua_blocks = VigIA_Blocker::get_blocked_by_type( 'useragent' );
577 $ip_blocks = VigIA_Blocker::get_blocked_by_type( 'ip' );
578 foreach ( $ua_blocks as $block ) {
579 $blocked_ua[] = $block['name'];
580 }
581 foreach ( $ip_blocks as $block ) {
582 $blocked_ips[] = $block['pattern'];
583 }
584 $robots_disallow = VigIA_Robots_Manager::get_ai_rules()['disallow'];
585
586 wp_localize_script(
587 'vigia-admin',
588 'vigiaData',
589 array(
590 'restUrl' => esc_url_raw( rest_url( 'vigia/v1/' ) ),
591 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
592 'nonce' => wp_create_nonce( 'wp_rest' ),
593 'ajaxNonce' => wp_create_nonce( 'vigia_ajax_nonce' ),
594 'settings' => $settings,
595 'extrasUrl' => admin_url( 'admin.php?page=vigia-extras' ),
596 'siteUrl' => untrailingslashit( home_url() ),
597 'aissActive' => class_exists( 'AyudaWP_AISS_Database' ) ? '1' : '0',
598 // Category colours and labels for the crawler chips of the "Most
599 // crawled pages" breakdown, which groups them by category.
600 'categoryColors' => VigIA_Crawler_Detector::get_category_colors(),
601 'categoryLabels' => VigIA_Crawler_Detector::get_category_labels(),
602 'strings' => array(
603 'loading' => __( 'Loading...', 'vigia' ),
604 'error' => __( 'Error loading data', 'vigia' ),
605 'optimizeRunning' => __( 'Working… on a large table this can take a few minutes. You can close this page, it will finish on its own.', 'vigia' ),
606 'optimizeFailed' => __( 'It did not finish. Reload the page to check: if the button is still here, try again or leave it to the scheduled run.', 'vigia' ),
607 'noData' => __( 'No data available', 'vigia' ),
608 'requests' => __( 'Requests', 'vigia' ),
609 'previousPeriod' => __( 'Previous period', 'vigia' ),
610 'others' => __( 'Others', 'vigia' ),
611 'exported' => __( 'Data exported successfully', 'vigia' ),
612 'settingsSaved' => __( 'Settings saved', 'vigia' ),
613 'confirmDelete' => __( 'Are you sure you want to delete ALL crawler data? This action cannot be undone.', 'vigia' ),
614 'dataDeleted' => __( 'All data has been deleted', 'vigia' ),
615 'crawlerAdded' => __( 'Custom crawler added', 'vigia' ),
616 'crawlerRemoved' => __( 'Custom crawler removed', 'vigia' ),
617 'confirmRemove' => __( 'Are you sure you want to remove this custom crawler?', 'vigia' ),
618 /* translators: 1: number of items shown, 2: total number of items */
619 'showingOf' => __( 'Showing %1$s of %2$s', 'vigia' ),
620 // Share Buttons & AI-powered Summaries integration.
621 'clicks' => __( 'Clicks', 'vigia' ),
622 // Most crawled pages: trend column and expandable crawler breakdown.
623 'showCrawlers' => __( 'Show crawlers for this page', 'vigia' ),
624 'crawlersOnPage' => __( 'Crawlers on this page', 'vigia' ),
625 'trendNew' => __( 'new', 'vigia' ),
626 /* translators: 1: visits in the previous period, 2: visits in the current period */
627 'trendBefore' => __( 'Before: %1$s → Now: %2$s', 'vigia' ),
628 // Blocking action labels.
629 'addDisallow' => __( 'Disallow', 'vigia' ),
630 'blockUA' => __( 'Block User-Agent', 'vigia' ),
631 'blockIP' => __( 'Block IP address', 'vigia' ),
632 // Block status labels.
633 'disallowed' => __( 'Disallowed', 'vigia' ),
634 'uaBlocked' => __( 'UA blocked', 'vigia' ),
635 'ipBlocked' => __( 'IP blocked', 'vigia' ),
636 'fullyBlocked' => __( 'Fully blocked', 'vigia' ),
637 'phpBlocked' => __( 'PHP blocked', 'vigia' ),
638 'disallowedOnly' => __( 'Disallowed in robots.txt', 'vigia' ),
639 'blockActions' => __( 'Block actions', 'vigia' ),
640 // Notice messages.
641 'blockedVia' => __( 'blocked via', 'vigia' ),
642 'manageInExtras' => __( 'Manage in Extras', 'vigia' ),
643 'blocked' => __( 'Blocked successfully', 'vigia' ),
644 'unblocked' => __( 'Unblocked successfully', 'vigia' ),
645 'emailTestSent' => __( 'Test email sent', 'vigia' ),
646 'llmsGenerated' => __( 'LLMs files generated successfully', 'vigia' ),
647 'markdownSaved' => __( 'Markdown settings saved', 'vigia' ),
648 'jsonldSaved' => __( 'JSON-LD settings saved', 'vigia' ),
649 'selectImage' => __( 'Select image', 'vigia' ),
650 'useImage' => __( 'Use this image', 'vigia' ),
651 'llmsDeleted' => __( 'File deleted', 'vigia' ),
652 'confirmDeleteLlms' => __( 'Are you sure you want to delete this file?', 'vigia' ),
653 'robotsRuleAdded' => __( 'Robots.txt rule added', 'vigia' ),
654 'robotsRuleRemoved' => __( 'Robots.txt rule removed', 'vigia' ),
655 // Activity table v2.0.0 strings.
656 'allCrawlers' => __( 'All crawlers', 'vigia' ),
657 /* translators: %d: number of crawlers selected in the multi-select. */
658 'crawlersSelected' => __( '%d crawlers selected', 'vigia' ),
659 /* translators: %d: number of active filters. */
660 'filterBadgeSingular' => __( '%d active filter', 'vigia' ),
661 /* translators: %d: number of active filters. */
662 'filterBadgePlural' => __( '%d active filters', 'vigia' ),
663 /* translators: 1: first row index, 2: last row index, 3: total rows. */
664 'pagerRange' => __( '%1$s–%2$s of %3$s', 'vigia' ),
665 'first' => __( 'First', 'vigia' ),
666 'previous' => __( 'Previous', 'vigia' ),
667 'next' => __( 'Next', 'vigia' ),
668 'last' => __( 'Last', 'vigia' ),
669 'contentTypeLabels' => VigIA_Rest_API::get_localized_content_type_labels(),
670 // LLMs Generator v1.2.0 strings.
671 'selectCrawler' => __( 'Please select a crawler', 'vigia' ),
672 'enterBothFields' => __( 'Please enter both name and pattern', 'vigia' ),
673 'enterIP' => __( 'Please enter an IP address', 'vigia' ),
674 'sending' => __( 'Sending...', 'vigia' ),
675 'testEmailSent' => __( 'Test email sent', 'vigia' ),
676 'noResults' => __( 'No results found', 'vigia' ),
677 'manuallyAdded' => __( 'Manually added', 'vigia' ),
678 'excluded' => __( 'Excluded', 'vigia' ),
679 /* translators: 1: number of items, 2: details string */
680 'estimatedContent' => __( 'Estimated content: %1$d items (%2$s)', 'vigia' ),
681 'selectContentTypes' => __( 'Select content types to see estimated count.', 'vigia' ),
682 'siteNameRequired' => __( 'Site name is required', 'vigia' ),
683 'selectContent' => __( 'Please select at least one content type or add content manually', 'vigia' ),
684 'generating' => __( 'Generating...', 'vigia' ),
685 'allIncluded' => __( 'All included', 'vigia' ),
686 'allExcluded' => __( 'All excluded', 'vigia' ),
687 /* translators: %d: number of excluded terms */
688 'excludedCount' => __( '%d excluded', 'vigia' ),
689 'includeAll' => __( 'Include all', 'vigia' ),
690 'excludeAll' => __( 'Exclude all', 'vigia' ),
691 'uncheckToExclude' => __( 'Uncheck to exclude specific terms', 'vigia' ),
692 // Period indicators (v1.3.0).
693 /* translators: %d: number of days */
694 'lastDays' => __( 'Last %d days', 'vigia' ),
695 'today' => __( 'Today', 'vigia' ),
696 'allTime' => __( 'All time', 'vigia' ),
697 'loadMore' => __( 'Load more', 'vigia' ),
698 /* translators: 1: start index, 2: end index, 3: total items. Example: 1–10 of 85 */
699 'pagerRange' => __( '%1$s–%2$s of %3$s', 'vigia' ),
700 'remove' => __( 'Remove', 'vigia' ),
701 'unblock' => __( 'Unblock', 'vigia' ),
702 'disallow' => __( 'Disallow', 'vigia' ),
703 'noRulesConfigured' => __( 'No robots.txt rules configured for AI crawlers.', 'vigia' ),
704 'noUaBlocks' => __( 'No User-Agent blocks configured.', 'vigia' ),
705 'noIpBlocks' => __( 'No IP blocks configured.', 'vigia' ),
706 'alreadyBlockedPhp' => __( 'Already blocked via PHP', 'vigia' ),
707 'crawler' => __( 'Crawler', 'vigia' ),
708 'status' => __( 'Status', 'vigia' ),
709 'actions' => __( 'Actions', 'vigia' ),
710 // MCP one-click connect (v1.12.0).
711 'copied' => __( 'Copied!', 'vigia' ),
712 'copyFailed' => __( 'Could not copy to clipboard. Select the text manually.', 'vigia' ),
713 'confirmRevokeMcp' => __( 'Revoke the current VigIA MCP password and generate a new one?', 'vigia' ),
714 'saving' => __( 'Saving…', 'vigia' ),
715 'saved' => __( 'Saved', 'vigia' ),
716 'mergerEmpty' => __( 'Paste your current config first.', 'vigia' ),
717 'mergerOk' => __( 'Merged. Copy the result and save it as the config file.', 'vigia' ),
718 'mergerInvalidJson' => __( 'Your file is not valid JSON. Make sure to copy the entire file.', 'vigia' ),
719 'mergerNotObject' => __( 'The root of the file must be a JSON object.', 'vigia' ),
720 'mergerNoCreds' => __( 'Generate the password first, then come back here.', 'vigia' ),
721 ),
722 )
723 );
724
725 // Categories data for JS.
726 wp_localize_script(
727 'vigia-admin',
728 'vigiaDataCategories',
729 array(
730 'labels' => $categories,
731 'colors' => $category_colors,
732 )
733 );
734
735 // Blocking data for JS.
736 wp_localize_script(
737 'vigia-admin',
738 'vigiaBlockedCrawlers',
739 $blocked_ua
740 );
741 wp_localize_script(
742 'vigia-admin',
743 'vigiaRobotsDisallow',
744 $robots_disallow
745 );
746 wp_localize_script(
747 'vigia-admin',
748 'vigiaBlockedIPs',
749 $blocked_ips
750 );
751
752 // Inline script for notice dismiss.
753 $notice_script = "
754 jQuery(document).ready(function($) {
755 $('.vigia-activation-notice').on('click', '.notice-dismiss', function() {
756 var nonce = $(this).closest('.notice').data('nonce');
757 $.post(ajaxurl, {
758 action: 'vigia_dismiss_notice',
759 nonce: nonce
760 });
761 });
762 });
763 ";
764 wp_add_inline_script( 'vigia-admin', $notice_script );
765 }
766
767 /**
768 * Add settings link to plugins page
769 *
770 * @param array $links Existing plugin action links.
771 * @return array Modified links.
772 */
773 public function add_settings_link( $links ) {
774 $visibility_link = sprintf(
775 '<a href="%s">%s</a>',
776 esc_url( admin_url( 'admin.php?page=vigia-visibility' ) ),
777 esc_html__( 'AI Score', 'vigia' )
778 );
779 $analytics_link = sprintf(
780 '<a href="%s">%s</a>',
781 esc_url( admin_url( 'admin.php?page=vigia' ) ),
782 esc_html__( 'Analytics', 'vigia' )
783 );
784 $extras_link = sprintf(
785 '<a href="%s">%s</a>',
786 esc_url( admin_url( 'admin.php?page=vigia-extras' ) ),
787 esc_html__( 'Extras', 'vigia' )
788 );
789 array_unshift( $links, $extras_link );
790 array_unshift( $links, $analytics_link );
791 array_unshift( $links, $visibility_link );
792 return $links;
793 }
794
795 /**
796 * Display activation notice
797 */
798 public function activation_notice() {
799 if ( ! get_option( 'vigia_activation_notice' ) ) {
800 return;
801 }
802
803 $screen = get_current_screen();
804 if ( ! $screen || 'plugins' !== $screen->id ) {
805 return;
806 }
807
808 $nonce = wp_create_nonce( 'vigia_dismiss_notice' );
809 ?>
810 <div class="notice notice-success is-dismissible vigia-activation-notice" data-nonce="<?php echo esc_attr( $nonce ); ?>">
811 <p>
812 <strong><?php esc_html_e( 'VigIA - AI Crawler Activity Analytics & Control is now active!', 'vigia' ); ?></strong>
813 <?php esc_html_e( 'The plugin is now tracking AI crawler visits. Check your AI Visibility Score and start optimizing.', 'vigia' ); ?>
814 </p>
815 <p>
816 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigia-visibility' ) ); ?>" class="button button-primary">
817 <?php esc_html_e( 'Check AI Score', 'vigia' ); ?>
818 </a>
819 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigia' ) ); ?>" class="button button-secondary" style="margin-left: 8px;">
820 <?php esc_html_e( 'View Analytics', 'vigia' ); ?>
821 </a>
822 <a href="<?php echo esc_url( admin_url( 'admin.php?page=vigia-extras' ) ); ?>" class="button button-secondary" style="margin-left: 8px;">
823 <?php esc_html_e( 'Configure Extras', 'vigia' ); ?>
824 </a>
825 </p>
826 </div>
827 <script>
828 jQuery(document).ready(function($) {
829 $('.vigia-activation-notice').on('click', '.notice-dismiss', function() {
830 $.post(ajaxurl, {
831 action: 'vigia_dismiss_notice',
832 nonce: '<?php echo esc_js( $nonce ); ?>'
833 });
834 });
835 });
836 </script>
837 <?php
838 }
839
840 /**
841 * Dismiss activation notice via AJAX
842 */
843 public function dismiss_notice() {
844 check_ajax_referer( 'vigia_dismiss_notice', 'nonce' );
845
846 // The nonce only proves the request came from a page we rendered, not
847 // that the sender may change a site option. Every other handler here
848 // checks the capability; this one did not.
849 if ( ! current_user_can( 'manage_options' ) ) {
850 wp_die( '', '', array( 'response' => 403 ) );
851 }
852
853 delete_option( 'vigia_activation_notice' );
854 wp_die();
855 }
856
857 /**
858 * Dismiss Share Buttons & AI-powered Summaries tip via AJAX
859 */
860 public function ajax_dismiss_aiss_tip() {
861 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
862
863 if ( ! current_user_can( 'manage_options' ) ) {
864 wp_send_json_error();
865 }
866
867 update_option( 'vigia_aiss_tip_dismissed', true );
868 wp_send_json_success();
869 }
870
871 /**
872 * AJAX: Save settings
873 */
874 public function ajax_save_settings() {
875 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
876
877 if ( ! current_user_can( 'manage_options' ) ) {
878 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
879 }
880
881 $retention_days = isset( $_POST['retention_days'] ) ? absint( $_POST['retention_days'] ) : 0;
882 $delete_on_uninstall = isset( $_POST['delete_on_uninstall'] ) && 'true' === $_POST['delete_on_uninstall'];
883
884 VigIA_Settings::update_settings(
885 array(
886 'retention_days' => $retention_days,
887 'delete_on_uninstall' => $delete_on_uninstall,
888 )
889 );
890
891 wp_send_json_success();
892 }
893
894 /**
895 * AJAX: Delete all data
896 */
897 public function ajax_delete_all_data() {
898 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
899
900 if ( ! current_user_can( 'manage_options' ) ) {
901 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
902 }
903
904 VigIA_Settings::delete_all_data();
905
906 wp_send_json_success();
907 }
908
909 /**
910 * AJAX: build the analytics indexes now instead of waiting for cron.
911 *
912 * The background job covers the normal case, but plenty of sites run with
913 * WP-Cron disabled or get so little traffic that it barely fires, and those
914 * are exactly the sites where the dashboard is slow. This gives the user a
915 * way to trigger it and see the result.
916 *
917 * @since 2.5.0
918 */
919 public function ajax_optimize_indexes() {
920 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
921
922 if ( ! current_user_can( 'manage_options' ) ) {
923 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
924 }
925
926 // Long tables take a while; ask for room where the host allows it.
927 // function_exists() returns false when the host disables it, so no
928 // error silencing is needed.
929 if ( function_exists( 'set_time_limit' ) ) {
930 set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- Deliberate, user-initiated maintenance action: an ALTER TABLE on a long history can outlast the default limit, and this only runs behind a nonce plus manage_options.
931 }
932
933 // Finish the job even if the admin closes the tab. Without this, an
934 // ALTER on a large table is tied to the browser being there to wait for
935 // it, which is a silly thing to ask of somebody who just pressed a
936 // maintenance button. Creating the indexes is idempotent, so a run that
937 // does get cut short simply leaves the rest for the next attempt.
938 ignore_user_abort( true );
939
940 VigIA_Database::create_performance_indexes();
941
942 $status = VigIA_Database::get_index_status();
943
944 if ( $status['ready'] ) {
945 wp_send_json_success(
946 array(
947 'message' => __( 'Database optimized. The statistics tables should load noticeably faster now.', 'vigia' ),
948 'ready' => true,
949 )
950 );
951 }
952
953 wp_send_json_error(
954 array(
955 'message' => __( 'Could not create every index. Your database user may not have permission to alter tables; ask your host to run the optimization.', 'vigia' ),
956 'ready' => false,
957 )
958 );
959 }
960
961 /**
962 * AJAX: Add custom crawler
963 */
964 public function ajax_add_custom_crawler() {
965 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
966
967 if ( ! current_user_can( 'manage_options' ) ) {
968 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
969 }
970
971 $user_agent = isset( $_POST['user_agent'] ) ? sanitize_text_field( wp_unslash( $_POST['user_agent'] ) ) : '';
972 $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
973 $company = isset( $_POST['company'] ) ? sanitize_text_field( wp_unslash( $_POST['company'] ) ) : '';
974 $category = isset( $_POST['category'] ) ? sanitize_key( wp_unslash( $_POST['category'] ) ) : 'other';
975
976 if ( empty( $user_agent ) || empty( $name ) ) {
977 wp_send_json_error( __( 'User agent and name are required', 'vigia' ) );
978 }
979
980 VigIA_Settings::add_custom_crawler( $user_agent, $name, $company, $category );
981
982 $crawlers = VigIA_Settings::get_custom_crawlers();
983 wp_send_json_success( array( 'crawlers' => $crawlers ) );
984 }
985
986 /**
987 * AJAX: Remove custom crawler
988 */
989 public function ajax_remove_custom_crawler() {
990 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
991
992 if ( ! current_user_can( 'manage_options' ) ) {
993 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
994 }
995
996 $crawler_id = isset( $_POST['crawler_id'] ) ? sanitize_key( wp_unslash( $_POST['crawler_id'] ) ) : '';
997
998 if ( empty( $crawler_id ) ) {
999 wp_send_json_error( __( 'Crawler ID is required', 'vigia' ) );
1000 }
1001
1002 VigIA_Settings::remove_custom_crawler( $crawler_id );
1003
1004 $crawlers = VigIA_Settings::get_custom_crawlers();
1005 wp_send_json_success( array( 'crawlers' => $crawlers ) );
1006 }
1007
1008 /**
1009 * AJAX: Toggle crawlers box collapsed state
1010 */
1011 public function ajax_toggle_crawlers_box() {
1012 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1013
1014 if ( ! current_user_can( 'manage_options' ) ) {
1015 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1016 }
1017
1018 $collapsed = isset( $_POST['collapsed'] ) && 'true' === $_POST['collapsed'];
1019 VigIA_Settings::update( 'crawlers_box_collapsed', $collapsed );
1020
1021 wp_send_json_success();
1022 }
1023
1024 /**
1025 * AJAX: Block crawler (User-Agent or IP)
1026 */
1027 public function ajax_block_crawler() {
1028 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1029
1030 if ( ! current_user_can( 'manage_options' ) ) {
1031 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1032 }
1033
1034 $block_type = isset( $_POST['block_type'] ) ? sanitize_key( wp_unslash( $_POST['block_type'] ) ) : 'useragent';
1035 $method = isset( $_POST['method'] ) ? sanitize_key( wp_unslash( $_POST['method'] ) ) : 'php';
1036
1037 // Handle robots.txt disallow (backwards compatible).
1038 if ( 'robots' === $method || 'disallow' === $block_type ) {
1039 $crawler_name = isset( $_POST['crawler_name'] ) ? sanitize_text_field( wp_unslash( $_POST['crawler_name'] ) ) : '';
1040 if ( empty( $crawler_name ) ) {
1041 wp_send_json_error( __( 'Crawler name is required', 'vigia' ) );
1042 }
1043 VigIA_Robots_Manager::add_disallow( $crawler_name );
1044
1045 wp_send_json_success(
1046 array(
1047 'message' => __( 'Crawler added to robots.txt Disallow', 'vigia' ),
1048 'extrasUrl' => admin_url( 'admin.php?page=vigia-extras' ),
1049 )
1050 );
1051 return;
1052 }
1053
1054 // Handle IP block.
1055 if ( 'ip' === $block_type ) {
1056 $ip = isset( $_POST['ip'] ) ? sanitize_text_field( wp_unslash( $_POST['ip'] ) ) : '';
1057 $name = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
1058
1059 if ( empty( $ip ) ) {
1060 wp_send_json_error( __( 'IP address is required', 'vigia' ) );
1061 }
1062
1063 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
1064 wp_send_json_error( __( 'Invalid IP address', 'vigia' ) );
1065 }
1066
1067 $result = VigIA_Blocker::add_ip_block( $name, $ip );
1068
1069 if ( ! $result ) {
1070 wp_send_json_error( __( 'IP is already blocked', 'vigia' ) );
1071 }
1072
1073 wp_send_json_success(
1074 array(
1075 'message' => __( 'IP address blocked', 'vigia' ),
1076 'extrasUrl' => admin_url( 'admin.php?page=vigia-extras' ),
1077 )
1078 );
1079 return;
1080 }
1081
1082 // Handle User-Agent block.
1083 $crawler_name = isset( $_POST['crawler_name'] ) ? sanitize_text_field( wp_unslash( $_POST['crawler_name'] ) ) : '';
1084 $user_agent = isset( $_POST['user_agent'] ) ? sanitize_text_field( wp_unslash( $_POST['user_agent'] ) ) : '';
1085
1086 if ( empty( $user_agent ) && empty( $crawler_name ) ) {
1087 wp_send_json_error( __( 'User-Agent pattern is required', 'vigia' ) );
1088 }
1089
1090 $pattern = ! empty( $user_agent ) ? $user_agent : $crawler_name;
1091 $name = ! empty( $crawler_name ) ? $crawler_name : $user_agent;
1092
1093 $result = VigIA_Blocker::add_useragent_block( $name, $pattern );
1094
1095 if ( ! $result ) {
1096 wp_send_json_error( __( 'User-Agent is already blocked', 'vigia' ) );
1097 }
1098
1099 wp_send_json_success(
1100 array(
1101 'message' => __( 'User-Agent blocked', 'vigia' ),
1102 'extrasUrl' => admin_url( 'admin.php?page=vigia-extras' ),
1103 )
1104 );
1105 }
1106
1107 /**
1108 * AJAX: Unblock crawler
1109 */
1110 public function ajax_unblock_crawler() {
1111 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1112
1113 if ( ! current_user_can( 'manage_options' ) ) {
1114 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1115 }
1116
1117 // New method: unblock by ID.
1118 $block_id = isset( $_POST['block_id'] ) ? sanitize_text_field( wp_unslash( $_POST['block_id'] ) ) : '';
1119
1120 if ( ! empty( $block_id ) ) {
1121 VigIA_Blocker::remove_block( $block_id );
1122 wp_send_json_success( array( 'message' => __( 'Block removed', 'vigia' ) ) );
1123 return;
1124 }
1125
1126 // Legacy method: unblock by crawler name.
1127 $crawler_name = isset( $_POST['crawler_name'] ) ? sanitize_text_field( wp_unslash( $_POST['crawler_name'] ) ) : '';
1128 $method = isset( $_POST['method'] ) ? sanitize_key( wp_unslash( $_POST['method'] ) ) : 'php';
1129
1130 if ( empty( $crawler_name ) ) {
1131 wp_send_json_error( __( 'Crawler name or block ID is required', 'vigia' ) );
1132 }
1133
1134 if ( 'robots' === $method ) {
1135 VigIA_Robots_Manager::remove_disallow( $crawler_name );
1136 } else {
1137 VigIA_Blocker::remove_blocked_crawler( $crawler_name );
1138 }
1139
1140 wp_send_json_success( array( 'message' => __( 'Block removed', 'vigia' ) ) );
1141 }
1142
1143 /**
1144 * AJAX: Unblock by ID (new method)
1145 */
1146 public function ajax_unblock_by_id() {
1147 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1148
1149 if ( ! current_user_can( 'manage_options' ) ) {
1150 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1151 }
1152
1153 $block_id = isset( $_POST['block_id'] ) ? sanitize_text_field( wp_unslash( $_POST['block_id'] ) ) : '';
1154
1155 if ( empty( $block_id ) ) {
1156 wp_send_json_error( __( 'Block ID is required', 'vigia' ) );
1157 }
1158
1159 VigIA_Blocker::remove_block( $block_id );
1160
1161 wp_send_json_success( array( 'message' => __( 'Block removed', 'vigia' ) ) );
1162 }
1163
1164 /**
1165 * AJAX: Add robots.txt rule
1166 */
1167 public function ajax_add_robots_rule() {
1168 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1169
1170 if ( ! current_user_can( 'manage_options' ) ) {
1171 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1172 }
1173
1174 $crawler_name = isset( $_POST['crawler_name'] ) ? sanitize_text_field( wp_unslash( $_POST['crawler_name'] ) ) : '';
1175 $action_type = isset( $_POST['action_type'] ) ? sanitize_key( wp_unslash( $_POST['action_type'] ) ) : 'disallow';
1176
1177 if ( empty( $crawler_name ) ) {
1178 wp_send_json_error( __( 'Crawler name is required', 'vigia' ) );
1179 }
1180
1181 if ( 'allow' === $action_type ) {
1182 VigIA_Robots_Manager::add_allow( $crawler_name );
1183 } else {
1184 VigIA_Robots_Manager::add_disallow( $crawler_name );
1185 }
1186
1187 wp_send_json_success(
1188 array(
1189 'rules' => VigIA_Robots_Manager::get_ai_rules(),
1190 'preview' => VigIA_Robots_Manager::get_preview(),
1191 )
1192 );
1193 }
1194
1195 /**
1196 * AJAX: Remove robots.txt rule
1197 */
1198 public function ajax_remove_robots_rule() {
1199 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1200
1201 if ( ! current_user_can( 'manage_options' ) ) {
1202 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1203 }
1204
1205 $crawler_name = isset( $_POST['crawler_name'] ) ? sanitize_text_field( wp_unslash( $_POST['crawler_name'] ) ) : '';
1206 $action_type = isset( $_POST['action_type'] ) ? sanitize_key( wp_unslash( $_POST['action_type'] ) ) : 'disallow';
1207
1208 if ( empty( $crawler_name ) ) {
1209 wp_send_json_error( __( 'Crawler name is required', 'vigia' ) );
1210 }
1211
1212 if ( 'allow' === $action_type ) {
1213 VigIA_Robots_Manager::remove_allow( $crawler_name );
1214 } else {
1215 VigIA_Robots_Manager::remove_disallow( $crawler_name );
1216 }
1217
1218 wp_send_json_success(
1219 array(
1220 'rules' => VigIA_Robots_Manager::get_ai_rules(),
1221 'preview' => VigIA_Robots_Manager::get_preview(),
1222 )
1223 );
1224 }
1225
1226 /**
1227 * AJAX: Get robots.txt content
1228 */
1229 public function ajax_get_robots_content() {
1230 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1231
1232 if ( ! current_user_can( 'manage_options' ) ) {
1233 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1234 }
1235
1236 wp_send_json_success(
1237 array(
1238 'content' => VigIA_Robots_Manager::get_current_robots(),
1239 'preview' => VigIA_Robots_Manager::get_preview(),
1240 )
1241 );
1242 }
1243
1244 /**
1245 * AJAX: Save email settings
1246 */
1247 public function ajax_save_email_settings() {
1248 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1249
1250 if ( ! current_user_can( 'manage_options' ) ) {
1251 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1252 }
1253
1254 $enabled = isset( $_POST['enabled'] ) && 'true' === $_POST['enabled'];
1255 $frequency = isset( $_POST['frequency'] ) ? sanitize_key( wp_unslash( $_POST['frequency'] ) ) : 'weekly';
1256 $level = isset( $_POST['level'] ) ? sanitize_key( wp_unslash( $_POST['level'] ) ) : 'normal';
1257 $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
1258
1259 VigIA_Email_Alerts::save_settings(
1260 array(
1261 'enabled' => $enabled,
1262 'frequency' => $frequency,
1263 'level' => $level,
1264 'email' => $email,
1265 )
1266 );
1267
1268 // Reschedule alerts based on new settings.
1269 VigIA_Email_Alerts::schedule_alerts();
1270
1271 wp_send_json_success();
1272 }
1273
1274 /**
1275 * AJAX: Test email
1276 */
1277 public function ajax_test_email() {
1278 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1279
1280 if ( ! current_user_can( 'manage_options' ) ) {
1281 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1282 }
1283
1284 $result = VigIA_Email_Alerts::send_test_email();
1285
1286 if ( $result ) {
1287 wp_send_json_success();
1288 } else {
1289 wp_send_json_error( __( 'Failed to send test email', 'vigia' ) );
1290 }
1291 }
1292
1293 /**
1294 * AJAX: Generate LLMs files (v1.2.0 - updated with new settings structure)
1295 */
1296 public function ajax_generate_llms() {
1297 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1298
1299 if ( ! current_user_can( 'manage_options' ) ) {
1300 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1301 }
1302
1303 // Build settings array directly from POST (no merging with old values).
1304 $settings = array(
1305 'site_name' => isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '',
1306 'site_description' => isset( $_POST['site_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['site_description'] ) ) : '',
1307 'post_types' => isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) $_POST['post_types'] ) : array(),
1308 'taxonomy_filters' => isset( $_POST['taxonomy_filters'] ) ? $this->sanitize_taxonomy_filters( map_deep( wp_unslash( $_POST['taxonomy_filters'] ), 'sanitize_text_field' ) ) : array(),
1309 'manual_includes' => isset( $_POST['manual_includes'] ) ? array_map( 'absint', (array) $_POST['manual_includes'] ) : array(),
1310 'manual_excludes' => isset( $_POST['manual_excludes'] ) ? array_map( 'absint', (array) $_POST['manual_excludes'] ) : array(),
1311 'exclude_patterns' => isset( $_POST['exclude_patterns'] ) ? sanitize_textarea_field( wp_unslash( $_POST['exclude_patterns'] ) ) : '',
1312 'exclude_noindex' => isset( $_POST['exclude_noindex'] ) && 'true' === $_POST['exclude_noindex'],
1313 'generate_full' => isset( $_POST['generate_full'] ) && 'true' === $_POST['generate_full'],
1314 'full_mode' => isset( $_POST['full_mode'] ) ? sanitize_key( wp_unslash( $_POST['full_mode'] ) ) : 'full',
1315 'auto_regenerate' => isset( $_POST['auto_regenerate'] ) ? sanitize_key( wp_unslash( $_POST['auto_regenerate'] ) ) : 'manual',
1316 'robots_llms' => isset( $_POST['robots_llms'] ) && 'true' === $_POST['robots_llms'],
1317 'robots_llms_full' => isset( $_POST['robots_llms_full'] ) && 'true' === $_POST['robots_llms_full'],
1318 );
1319
1320 if ( empty( $settings['site_name'] ) ) {
1321 $settings['site_name'] = get_bloginfo( 'name' );
1322 }
1323
1324 if ( empty( $settings['post_types'] ) && empty( $settings['manual_includes'] ) ) {
1325 wp_send_json_error( __( 'Please select at least one content type or add content manually', 'vigia' ) );
1326 }
1327
1328 // Use the new combined save_and_generate method.
1329 $result = VigIA_LLMS_Generator::save_and_generate( $settings );
1330
1331 if ( is_wp_error( $result ) ) {
1332 wp_send_json_error( $result->get_error_message() );
1333 }
1334
1335 wp_send_json_success( $result );
1336 }
1337
1338 /**
1339 * AJAX: Save LLMs settings only (without generating files)
1340 */
1341 public function ajax_save_llms_settings() {
1342 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1343
1344 if ( ! current_user_can( 'manage_options' ) ) {
1345 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1346 }
1347
1348 // Build settings array directly from POST.
1349 $settings = array(
1350 'site_name' => isset( $_POST['site_name'] ) ? sanitize_text_field( wp_unslash( $_POST['site_name'] ) ) : '',
1351 'site_description' => isset( $_POST['site_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['site_description'] ) ) : '',
1352 'post_types' => isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) $_POST['post_types'] ) : array(),
1353 'taxonomy_filters' => isset( $_POST['taxonomy_filters'] ) ? $this->sanitize_taxonomy_filters( map_deep( wp_unslash( $_POST['taxonomy_filters'] ), 'sanitize_text_field' ) ) : array(),
1354 'manual_includes' => isset( $_POST['manual_includes'] ) ? array_map( 'absint', (array) $_POST['manual_includes'] ) : array(),
1355 'manual_excludes' => isset( $_POST['manual_excludes'] ) ? array_map( 'absint', (array) $_POST['manual_excludes'] ) : array(),
1356 'exclude_patterns' => isset( $_POST['exclude_patterns'] ) ? sanitize_textarea_field( wp_unslash( $_POST['exclude_patterns'] ) ) : '',
1357 'exclude_noindex' => isset( $_POST['exclude_noindex'] ) && 'true' === $_POST['exclude_noindex'],
1358 'generate_full' => isset( $_POST['generate_full'] ) && 'true' === $_POST['generate_full'],
1359 'full_mode' => isset( $_POST['full_mode'] ) ? sanitize_key( wp_unslash( $_POST['full_mode'] ) ) : 'full',
1360 'auto_regenerate' => isset( $_POST['auto_regenerate'] ) ? sanitize_key( wp_unslash( $_POST['auto_regenerate'] ) ) : 'manual',
1361 'robots_llms' => isset( $_POST['robots_llms'] ) && 'true' === $_POST['robots_llms'],
1362 'robots_llms_full' => isset( $_POST['robots_llms_full'] ) && 'true' === $_POST['robots_llms_full'],
1363 );
1364
1365 if ( empty( $settings['site_name'] ) ) {
1366 $settings['site_name'] = get_bloginfo( 'name' );
1367 }
1368
1369 VigIA_LLMS_Generator::save_settings( $settings );
1370
1371 wp_send_json_success();
1372 }
1373
1374 /**
1375 * AJAX: Delete LLMs files (single or both)
1376 */
1377 public function ajax_delete_llms_files() {
1378 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1379
1380 if ( ! current_user_can( 'manage_options' ) ) {
1381 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1382 }
1383
1384 $file = isset( $_POST['file'] ) ? sanitize_file_name( wp_unslash( $_POST['file'] ) ) : '';
1385
1386 // Delete specific file.
1387 if ( ! empty( $file ) ) {
1388 if ( 'llms.txt' === $file ) {
1389 $result = VigIA_LLMS_Generator::delete_file( 'llms.txt' );
1390 } elseif ( 'llms-full.txt' === $file ) {
1391 $result = VigIA_LLMS_Generator::delete_file( 'llms-full.txt' );
1392 } else {
1393 wp_send_json_error( __( 'Invalid file', 'vigia' ) );
1394 return;
1395 }
1396
1397 if ( $result ) {
1398 wp_send_json_success( array( 'message' => __( 'File deleted', 'vigia' ) ) );
1399 } else {
1400 wp_send_json_error( __( 'Failed to delete file', 'vigia' ) );
1401 }
1402 return;
1403 }
1404
1405 // Delete all files (legacy).
1406 $result = VigIA_LLMS_Generator::delete_files();
1407
1408 if ( $result ) {
1409 wp_send_json_success();
1410 } else {
1411 wp_send_json_error( __( 'Failed to delete files', 'vigia' ) );
1412 }
1413 }
1414
1415 /**
1416 * AJAX: Search posts for LLMs manual include/exclude (v1.2.0)
1417 */
1418 public function ajax_search_posts() {
1419 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1420
1421 if ( ! current_user_can( 'manage_options' ) ) {
1422 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1423 }
1424
1425 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
1426 $exclude_ids = isset( $_POST['exclude_ids'] ) ? array_map( 'absint', (array) $_POST['exclude_ids'] ) : array();
1427
1428 $results = VigIA_LLMS_Generator::search_posts( $search, $exclude_ids, 20 );
1429
1430 wp_send_json_success( $results );
1431 }
1432
1433 /**
1434 * AJAX: Get taxonomies for a post type (v1.2.0)
1435 */
1436 public function ajax_get_taxonomies() {
1437 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1438
1439 if ( ! current_user_can( 'manage_options' ) ) {
1440 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1441 }
1442
1443 $post_type = isset( $_POST['post_type'] ) ? sanitize_key( wp_unslash( $_POST['post_type'] ) ) : '';
1444
1445 if ( empty( $post_type ) ) {
1446 wp_send_json_error( __( 'Post type is required', 'vigia' ) );
1447 }
1448
1449 $taxonomies = VigIA_LLMS_Generator::get_post_type_taxonomies( $post_type );
1450
1451 wp_send_json_success( $taxonomies );
1452 }
1453
1454 /**
1455 * AJAX: Save markdown endpoint settings (v1.5.0)
1456 */
1457 public function ajax_save_markdown_settings() {
1458 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1459
1460 if ( ! current_user_can( 'manage_options' ) ) {
1461 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1462 }
1463
1464 $settings = array(
1465 'enabled' => isset( $_POST['enabled'] ) && 'true' === $_POST['enabled'],
1466 'enable_md_urls' => isset( $_POST['enable_md_urls'] ) && 'true' === $_POST['enable_md_urls'],
1467 'enable_negotiation' => isset( $_POST['enable_negotiation'] ) && 'true' === $_POST['enable_negotiation'],
1468 'enable_link_header' => isset( $_POST['enable_link_header'] ) && 'true' === $_POST['enable_link_header'],
1469 'enable_link_tag' => isset( $_POST['enable_link_tag'] ) && 'true' === $_POST['enable_link_tag'],
1470 'respect_llms_filters' => isset( $_POST['respect_llms_filters'] ) && 'true' === $_POST['respect_llms_filters'],
1471 'post_types' => isset( $_POST['post_types'] ) ? array_map( 'sanitize_key', (array) $_POST['post_types'] ) : array( 'post', 'page' ),
1472 'taxonomies' => isset( $_POST['taxonomies'] ) ? array_map( 'sanitize_key', (array) $_POST['taxonomies'] ) : array(),
1473 );
1474
1475 VigIA_Markdown_Endpoints::save_settings( $settings );
1476
1477 wp_send_json_success();
1478 }
1479
1480 /**
1481 * AJAX: Save JSON-LD settings (v1.7.0)
1482 */
1483 public function ajax_save_jsonld_settings() {
1484 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1485
1486 if ( ! current_user_can( 'manage_options' ) ) {
1487 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1488 }
1489
1490 $settings = array(
1491 'site_identity_enabled' => isset( $_POST['site_identity_enabled'] ) && 'true' === $_POST['site_identity_enabled'],
1492 'entity_type' => isset( $_POST['entity_type'] ) ? sanitize_key( wp_unslash( $_POST['entity_type'] ) ) : 'Organization',
1493 'entity_name' => isset( $_POST['entity_name'] ) ? sanitize_text_field( wp_unslash( $_POST['entity_name'] ) ) : '',
1494 'entity_description' => isset( $_POST['entity_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['entity_description'] ) ) : '',
1495 'entity_logo' => isset( $_POST['entity_logo'] ) ? esc_url_raw( wp_unslash( $_POST['entity_logo'] ) ) : '',
1496 'entity_url' => isset( $_POST['entity_url'] ) ? esc_url_raw( wp_unslash( $_POST['entity_url'] ) ) : '',
1497 'search_action' => isset( $_POST['search_action'] ) && 'true' === $_POST['search_action'],
1498 'same_as' => isset( $_POST['same_as'] ) ? sanitize_textarea_field( wp_unslash( $_POST['same_as'] ) ) : '',
1499 'ai_discovery_enabled' => isset( $_POST['ai_discovery_enabled'] ) && 'true' === $_POST['ai_discovery_enabled'],
1500 'ai_discovery_llms' => isset( $_POST['ai_discovery_llms'] ) && 'true' === $_POST['ai_discovery_llms'],
1501 'ai_discovery_llms_full' => isset( $_POST['ai_discovery_llms_full'] ) && 'true' === $_POST['ai_discovery_llms_full'],
1502 'ai_discovery_markdown' => isset( $_POST['ai_discovery_markdown'] ) && 'true' === $_POST['ai_discovery_markdown'],
1503 'output_page' => isset( $_POST['output_page'] ) ? sanitize_text_field( wp_unslash( $_POST['output_page'] ) ) : 'front_page',
1504 );
1505
1506 // Validate entity_type.
1507 if ( ! in_array( $settings['entity_type'], array( 'Organization', 'Person' ), true ) ) {
1508 $settings['entity_type'] = 'Organization';
1509 }
1510
1511 VigIA_JsonLD_Generator::save_settings( $settings );
1512
1513 wp_send_json_success();
1514 }
1515
1516 /**
1517 * AJAX: Run AI visibility analysis (v1.8.0)
1518 *
1519 * @since 1.8.0
1520 */
1521 public function ajax_run_visibility_analysis() {
1522 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1523
1524 if ( ! current_user_can( 'manage_options' ) ) {
1525 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1526 }
1527
1528 $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
1529 if ( empty( $url ) ) {
1530 $url = home_url( '/' );
1531 }
1532
1533 // Validate URL belongs to this site.
1534 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
1535 $url_host = wp_parse_url( $url, PHP_URL_HOST );
1536 if ( $site_host !== $url_host ) {
1537 wp_send_json_error( __( 'Only URLs from this site can be analyzed.', 'vigia' ) );
1538 }
1539
1540 // Clear page HTML cache if requested (Re-analyze button).
1541 $clear_cache = isset( $_POST['clear_cache'] ) && '1' === $_POST['clear_cache'];
1542 if ( $clear_cache ) {
1543 VigIA_Visibility_Analyzer::clear_page_cache( $url );
1544 }
1545
1546 // Run analysis: page HTML is cached, plugin state and
1547 // recommendations are always evaluated fresh.
1548 $result = VigIA_Visibility_Analyzer::analyze( $url );
1549
1550 if ( ! $result['success'] ) {
1551 wp_send_json_error( $result['error'] );
1552 }
1553
1554 wp_send_json_success( $result );
1555 }
1556
1557 /**
1558 * AJAX: Search internal URLs for the visibility URL selector (v1.8.0)
1559 *
1560 * @since 1.8.0
1561 */
1562 public function ajax_search_visibility_urls() {
1563 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1564
1565 if ( ! current_user_can( 'manage_options' ) ) {
1566 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1567 }
1568
1569 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
1570 $results = VigIA_Visibility_Analyzer::search_urls( $search, 10 );
1571
1572 wp_send_json_success( $results );
1573 }
1574
1575 /**
1576 * AJAX: Generate a WordPress Application Password for the MCP server
1577 * and return ready-to-paste connection commands for the major clients
1578 * (Claude Code, Cursor, Claude Desktop).
1579 *
1580 * The plain password is only returned in this single response. WordPress
1581 * stores the hash, so neither this plugin nor the database keep the
1582 * cleartext value after the AJAX call resolves.
1583 *
1584 * @since 1.12.0
1585 */
1586 public function ajax_create_mcp_app_password() {
1587 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1588
1589 if ( ! current_user_can( 'manage_options' ) ) {
1590 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1591 }
1592
1593 if ( ! function_exists( 'wp_is_application_passwords_available' ) || ! wp_is_application_passwords_available() ) {
1594 wp_send_json_error( __( 'Application Passwords are not available on this site. They require HTTPS or have been disabled by a filter.', 'vigia' ) );
1595 }
1596
1597 if ( ! class_exists( '\\WP_Application_Passwords' ) ) {
1598 wp_send_json_error( __( 'WP_Application_Passwords class is not available. Update WordPress to 5.6 or later.', 'vigia' ) );
1599 }
1600
1601 $user = wp_get_current_user();
1602 if ( ! $user || ! $user->ID ) {
1603 wp_send_json_error( __( 'Could not resolve current user.', 'vigia' ) );
1604 }
1605
1606 $name = VigIA_Extras_Page::MCP_APP_PASSWORD_NAME;
1607
1608 // Refuse to create a duplicate. The user must revoke the existing
1609 // entry first to avoid silent collisions in the password manager.
1610 $existing = \WP_Application_Passwords::get_user_application_passwords( $user->ID );
1611 if ( is_array( $existing ) ) {
1612 foreach ( $existing as $pw ) {
1613 if ( isset( $pw['name'] ) && $name === $pw['name'] ) {
1614 wp_send_json_error( __( 'A VigIA MCP Application Password already exists. Revoke it first.', 'vigia' ) );
1615 }
1616 }
1617 }
1618
1619 $created = \WP_Application_Passwords::create_new_application_password(
1620 $user->ID,
1621 array(
1622 'name' => $name,
1623 'app_id' => 'vigia',
1624 )
1625 );
1626
1627 if ( is_wp_error( $created ) ) {
1628 wp_send_json_error( $created->get_error_message() );
1629 }
1630
1631 // create_new_application_password() returns [ $new_password, $new_item ].
1632 list( $plain_password, $item ) = $created;
1633
1634 $username = $user->user_login;
1635 $endpoint_url = home_url( '/wp-json/vigia/v1/mcp' );
1636 // WordPress strips non-alphanumeric chars from the submitted password
1637 // before validating, so the base64 form works whether we include the
1638 // visual spaces or not. Strip them to avoid ambiguity in copy/paste.
1639 $password_no_spaces = preg_replace( '/\s+/', '', $plain_password );
1640 $auth_basic = base64_encode( $username . ':' . $password_no_spaces ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- HTTP Basic auth requires base64.
1641
1642 $claudecode_cmd = sprintf(
1643 'claude mcp add --transport http vigia %s --header "Authorization: Basic %s"',
1644 $endpoint_url,
1645 $auth_basic
1646 );
1647
1648 // Cursor — server entry with type/url/headers at the root of the
1649 // entry (no nested "transport" object: that's an SDK runtime
1650 // concept, not a config-file key, and Claude Desktop rejects
1651 // entries that nest it).
1652 $cursor_server_block = array(
1653 'type' => 'http',
1654 'url' => $endpoint_url,
1655 'headers' => array(
1656 'Authorization' => 'Basic ' . $auth_basic,
1657 ),
1658 );
1659 $cursor_full_json = self::format_mcp_full_json( 'vigia', $cursor_server_block );
1660
1661 // Claude Desktop — only supports stdio servers natively. To talk
1662 // to a remote HTTP MCP server we have to launch `mcp-remote` as a
1663 // local stdio bridge that proxies the connection. This is the
1664 // pattern documented by Anthropic and all working examples in
1665 // the wild use it. Requires Node.js / npx to be installed on the
1666 // user's machine (npx auto-fetches mcp-remote on first run).
1667 $claudedesktop_server_block = array(
1668 'command' => 'npx',
1669 'args' => array(
1670 '-y',
1671 'mcp-remote',
1672 $endpoint_url,
1673 '--header',
1674 'Authorization: Basic ' . $auth_basic,
1675 ),
1676 );
1677 $claudedesktop_full_json = self::format_mcp_full_json( 'vigia', $claudedesktop_server_block );
1678
1679 wp_send_json_success(
1680 array(
1681 'username' => $username,
1682 'password' => $plain_password,
1683 'endpoint' => $endpoint_url,
1684 'uuid' => isset( $item['uuid'] ) ? $item['uuid'] : '',
1685 'created' => isset( $item['created'] ) ? (int) $item['created'] : 0,
1686 'claudecode_cmd' => $claudecode_cmd,
1687 // Full-file JSON for clients that read a config file. Merge
1688 // instructions for users who already have a config live in
1689 // the readme.txt FAQ instead of bloating the settings page.
1690 'cursor_full' => $cursor_full_json,
1691 'claudedesktop_full' => $claudedesktop_full_json,
1692 // Raw values for clients that don't have a dedicated snippet
1693 // (Codex CLI, Continue, Cline, Antigravity, Zed, custom).
1694 'generic_url' => $endpoint_url,
1695 'generic_header' => 'Authorization: Basic ' . $auth_basic,
1696 )
1697 );
1698 }
1699
1700 /**
1701 * Encode data as pretty-printed JSON using 2-space indentation.
1702 *
1703 * PHP's JSON_PRETTY_PRINT hard-codes 4 spaces, but the configuration
1704 * files of every MCP-aware client we target (Claude Desktop, Cursor,
1705 * Codex, Continue…) use 2-space indentation. Aligning the output to
1706 * 2 spaces means the snippets we produce match whatever the user
1707 * already has in their file.
1708 *
1709 * @param mixed $data Anything wp_json_encode() accepts.
1710 * @return string
1711 */
1712 private static function pretty_json_2spaces( $data ) {
1713 $json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
1714 if ( false === $json ) {
1715 return '';
1716 }
1717 return preg_replace_callback(
1718 '/^( {4,})/m',
1719 static function ( $matches ) {
1720 $depth = (int) ( strlen( $matches[1] ) / 4 );
1721 return str_repeat( ' ', $depth );
1722 },
1723 $json
1724 );
1725 }
1726
1727 /**
1728 * Render the full claude_desktop_config.json / mcp.json content as a
1729 * standalone document with `mcpServers` at the root. Merge variants
1730 * (property only, single entry) intentionally live in the readme FAQ
1731 * rather than the settings UI to keep the panel readable.
1732 *
1733 * @param string $name Server identifier.
1734 * @param array $block Server configuration.
1735 * @return string
1736 */
1737 private static function format_mcp_full_json( $name, $block ) {
1738 return self::pretty_json_2spaces(
1739 array(
1740 'mcpServers' => array(
1741 $name => $block,
1742 ),
1743 )
1744 );
1745 }
1746
1747 /**
1748 * AJAX: Revoke the VigIA MCP Application Password for the current user.
1749 *
1750 * Only revokes entries whose name matches the canonical VigIA MCP name,
1751 * so a manipulated UUID cannot be used to delete unrelated passwords.
1752 *
1753 * @since 1.12.0
1754 */
1755 public function ajax_revoke_mcp_app_password() {
1756 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1757
1758 if ( ! current_user_can( 'manage_options' ) ) {
1759 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1760 }
1761
1762 if ( ! class_exists( '\\WP_Application_Passwords' ) ) {
1763 wp_send_json_error( __( 'WP_Application_Passwords class is not available.', 'vigia' ) );
1764 }
1765
1766 $user = wp_get_current_user();
1767 if ( ! $user || ! $user->ID ) {
1768 wp_send_json_error( __( 'Could not resolve current user.', 'vigia' ) );
1769 }
1770
1771 $uuid = isset( $_POST['uuid'] ) ? sanitize_text_field( wp_unslash( $_POST['uuid'] ) ) : '';
1772 $name = VigIA_Extras_Page::MCP_APP_PASSWORD_NAME;
1773
1774 $passwords = \WP_Application_Passwords::get_user_application_passwords( $user->ID );
1775 $target = null;
1776 if ( is_array( $passwords ) ) {
1777 foreach ( $passwords as $pw ) {
1778 $matches_uuid = ! empty( $uuid ) && isset( $pw['uuid'] ) && $pw['uuid'] === $uuid;
1779 $matches_name = isset( $pw['name'] ) && $name === $pw['name'];
1780 if ( $matches_name && ( empty( $uuid ) || $matches_uuid ) ) {
1781 $target = $pw;
1782 break;
1783 }
1784 }
1785 }
1786
1787 if ( ! $target ) {
1788 wp_send_json_error( __( 'No matching VigIA MCP Application Password found.', 'vigia' ) );
1789 }
1790
1791 $deleted = \WP_Application_Passwords::delete_application_password( $user->ID, $target['uuid'] );
1792
1793 if ( is_wp_error( $deleted ) ) {
1794 wp_send_json_error( $deleted->get_error_message() );
1795 }
1796
1797 wp_send_json_success();
1798 }
1799
1800 /**
1801 * AJAX: Persist the MCP read-only toggle.
1802 *
1803 * The toggle stores a boolean option that the
1804 * `vigia_can_write_via_abilities` filter listener (registered in
1805 * VigIA_MCP_Server::init) reads at request time to short-circuit
1806 * mutating abilities to false.
1807 *
1808 * @since 1.12.0
1809 */
1810 public function ajax_save_mcp_readonly() {
1811 check_ajax_referer( 'vigia_ajax_nonce', 'nonce' );
1812
1813 if ( ! current_user_can( 'manage_options' ) ) {
1814 wp_send_json_error( __( 'Unauthorized', 'vigia' ) );
1815 }
1816
1817 $enabled = isset( $_POST['enabled'] ) && 'true' === $_POST['enabled'];
1818 update_option( 'vigia_mcp_read_only', $enabled );
1819
1820 wp_send_json_success( array( 'enabled' => $enabled ) );
1821 }
1822
1823 /**
1824 * Sanitize taxonomy filters array (v1.2.0)
1825 *
1826 * @param array $filters Raw filters array.
1827 * @return array Sanitized filters.
1828 */
1829 private function sanitize_taxonomy_filters( $filters ) {
1830 if ( ! is_array( $filters ) ) {
1831 return array();
1832 }
1833
1834 $sanitized = array();
1835 foreach ( $filters as $post_type => $taxonomies ) {
1836 $post_type = sanitize_key( $post_type );
1837 if ( ! is_array( $taxonomies ) ) {
1838 continue;
1839 }
1840
1841 $sanitized[ $post_type ] = array();
1842 foreach ( $taxonomies as $taxonomy => $terms ) {
1843 $taxonomy = sanitize_key( $taxonomy );
1844 if ( ! is_array( $terms ) ) {
1845 continue;
1846 }
1847 $sanitized[ $post_type ][ $taxonomy ] = array_map( 'absint', $terms );
1848 }
1849 }
1850
1851 return $sanitized;
1852 }
1853 }
1854
1855 /**
1856 * Initialize the plugin
1857 *
1858 * @return VigIA
1859 */
1860 function vigia() {
1861 return VigIA::get_instance();
1862 }
1863
1864 // Start the plugin.
1865 vigia();