PluginProbe
VigIA – AI Visibility, Analytics & Control / 2.6.3
VigIA – AI Visibility, Analytics & Control v2.6.3
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 2.6.3, at vigia.php

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