settings = $settings; } /** * Register routes. */ public function register() { add_action( 'rest_api_init', array( $this, 'routes' ) ); // The review ask's "really used it" signal, from one seam instead of // per-endpoint bookkeeping: any successful state-changing call to the // plugin's namespace by a managing user counts — see Review::use_worthy(). add_filter( 'rest_request_after_callbacks', array( $this, 'count_use' ), 10, 3 ); } /** * Count a successful plugin write as real use, for the review ask's gates. * Filter passthrough — the response is never altered. * * @param mixed $response Result to send (response or error). * @param array $handler Route handler (unused). * @param \WP_REST_Request $request The dispatched request. * @return mixed The response, untouched. */ public function count_use( $response, $handler, $request ) { if ( ! is_wp_error( $response ) && Review::use_worthy( $request->get_route(), $request->get_method() ) && current_user_can( 'manage_options' ) ) { Review::used(); } return $response; } /** * Define the routes. */ public function routes() { register_rest_route( self::NAMESPACE, '/settings', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); register_rest_route( self::NAMESPACE, '/settings/reset', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'reset_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/onboarding', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'complete_onboarding' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/changelog', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'changelog' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/whatsnew-seen', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'whatsnew_seen' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/review-ack', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'review_ack' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( // No enum here: schema validation runs BEFORE the permission // callback, so an enum would answer 400 to an anonymous probe // (and to the permission matrix's dummy value) before the gate // gets to say 403. The value set is enforced in the callback. 'answer' => array( 'type' => 'string', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE, '/digest/test', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'digest_test' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/verifier/probe-ranges', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'probe_ranges' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/readiness', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_readiness' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/score', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_score' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); // JSON-LD preview: the exact @graph the front end would emit for the site // or a chosen post, so an owner can inspect and validate it without viewing // page source. `post` = 0/absent → the site-wide identity graph. register_rest_route( self::NAMESPACE, '/preview/schema', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_schema_preview' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'post' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); // The Markdown twin of a page/post — what an agent receives when it requests // the page as text/markdown. The site target (post = 0) previews the index // Markdown served at the home URL and /llms.txt. register_rest_route( self::NAMESPACE, '/preview/markdown', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_markdown_preview' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'post' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); // The pickable targets for the preview: the in-scope pages & posts, most // recently edited first, optionally filtered by a title search. Grouped by // post type and paginated per group — `type` + `offset` fetch a group's next // batch for the "Load more" button (see get_schema_targets()). register_rest_route( self::NAMESPACE, '/preview/targets', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_schema_targets' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'type' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ), 'offset' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); // POST /optimize/ignore — set aside (or restore) a page from citability grading. register_rest_route( self::NAMESPACE, '/optimize/ignore', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'optimize_ignore' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'post' => array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'ignored' => array( 'type' => 'boolean', 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean', ), ), ) ); // POST /optimize/restore-all — bring every set-aside page back into grading. register_rest_route( self::NAMESPACE, '/optimize/restore-all', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'optimize_restore_all' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); // POST /optimize/ignore-issue — set aside EVERY page a content check flags, // in one action. The check id's validity is judged in the callback, after the // permission gate (an args enum would answer 400 before the 401/403). register_rest_route( self::NAMESPACE, '/optimize/ignore-issue', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'optimize_ignore_issue' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'issue' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ), ) ); } /** * Permission gate. * * @return bool */ public function can_manage() { return current_user_can( 'manage_options' ); } /** * GET /settings. * * @return \WP_REST_Response */ public function get_settings() { return rest_ensure_response( array( 'settings' => $this->settings->all(), 'readiness' => ( new Readiness( $this->settings ) )->report(), // The freshly-expanded exposed-files probe list, so the admin's // browser-side scan reflects a just-edited custom path without a reload. 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), ) ); } /** * POST /settings. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function save_settings( \WP_REST_Request $request ) { $input = $request->get_json_params(); if ( ! is_array( $input ) ) { $input = (array) $request->get_param( 'settings' ); } if ( isset( $input['settings'] ) && is_array( $input['settings'] ) ) { $input = $input['settings']; } $saved = $this->settings->update( (array) $input ); $readiness = ( new Readiness( $this->settings ) )->report(); // The weekly-digest cron must agree with the (possibly just-flipped) // digest_enabled switch without waiting for the next boot's self-heal. ( new Digest\Module( $this->settings ) )->sync_schedule(); return rest_ensure_response( array( 'settings' => $saved, 'readiness' => $readiness, // The recomputed score, so the rail card (e.g. the Cited rung when // citation tracking is toggled) updates live without a page reload. 'score' => $this->score_report( $readiness ), 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), 'saved' => true, ) ); } /** * The AEO/GEO score, computed fail-open (a throw degrades to null rather than failing * the settings save). * * @param array> $readiness Precomputed readiness rows. * @return array|null */ private function score_report( array $readiness ) { try { return ( new Score( $this->settings ) )->report( $readiness ); } catch ( \Throwable $e ) { return null; } } /** * POST /digest/test — send one weekly-digest email right now, to whoever the * digest is configured to reach. Synchronous: a single wp_mail is fast, and * the button's whole point is an immediate yes-or-no. Uses the SAVED * settings — an unsaved recipient in the form does not exist yet. * * @return \WP_REST_Response|\WP_Error */ public function digest_test() { $result = ( new Digest\Module( $this->settings ) )->send_test(); if ( empty( $result['sent'] ) ) { return new \WP_Error( 'agentimus_mail_failed', __( 'The email could not be sent. Your site’s mail setup may need attention — a plugin like WP Mail SMTP can help.', 'agentimus' ), array( 'status' => 500 ) ); } return rest_ensure_response( $result ); } /** * POST /optimize/ignore — set aside (ignored=true) or restore (false) a page from * content-citability grading. Returns the recomputed score so the worklist, the * set-aside list, and the counts update without a reload. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function optimize_ignore( \WP_REST_Request $request ) { $id = absint( $request->get_param( 'post' ) ); if ( $id < 1 ) { return new \WP_Error( 'agentimus_bad_post', __( 'Invalid post.', 'agentimus' ), array( 'status' => 400 ) ); } $ignore = (bool) $request->get_param( 'ignored' ); $all = $this->settings->all(); $list = ( isset( $all['optimize_ignored'] ) && is_array( $all['optimize_ignored'] ) ) ? array_map( 'intval', $all['optimize_ignored'] ) : array(); $list = array_values( array_diff( $list, array( $id ) ) ); if ( $ignore ) { $list[] = $id; } $all['optimize_ignored'] = $list; $this->settings->update( $all ); // Sanitises + busts the OPTIMIZE cache. return rest_ensure_response( array( 'score' => ( new Score( new Settings() ) )->report(), 'saved' => true, ) ); } /** * POST /optimize/restore-all — empty the set-aside list, returning every parked * page to grading. The mirror of ignore-issue; same recomputed-score response. * * @return \WP_REST_Response */ public function optimize_restore_all() { $all = $this->settings->all(); $all['optimize_ignored'] = array(); $this->settings->update( $all ); // Sanitises + busts the OPTIMIZE cache. return rest_ensure_response( array( 'score' => ( new Score( new Settings() ) )->report(), 'saved' => true, ) ); } /** * POST /optimize/ignore-issue — set aside every sampled page a content check * currently flags (the full set, not the worklist's capped preview). Returns the * recomputed score, like the per-page route, so the screen updates in place. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function optimize_ignore_issue( \WP_REST_Request $request ) { $issue = sanitize_key( (string) $request->get_param( 'issue' ) ); $ids = ( new Score( $this->settings ) )->issue_post_ids( $issue ); if ( empty( $ids ) ) { // Unknown check id, or nothing left to set aside — either way the // worklist has moved on under this click. return new \WP_Error( 'agentimus_no_pages', __( 'Nothing to set aside for that check.', 'agentimus' ), array( 'status' => 400 ) ); } $all = $this->settings->all(); $list = ( isset( $all['optimize_ignored'] ) && is_array( $all['optimize_ignored'] ) ) ? array_map( 'intval', $all['optimize_ignored'] ) : array(); $list = array_values( array_unique( array_merge( $list, $ids ) ) ); $all['optimize_ignored'] = $list; $this->settings->update( $all ); // Sanitises + busts the OPTIMIZE cache. return rest_ensure_response( array( 'score' => ( new Score( new Settings() ) )->report(), 'saved' => true, 'count' => count( $ids ), ) ); } /** * POST /settings/reset — restore factory defaults. * * @return \WP_REST_Response */ public function reset_settings() { $defaults = $this->settings->reset(); return rest_ensure_response( array( 'settings' => $defaults, 'readiness' => ( new Readiness( $this->settings ) )->report(), 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), 'reset' => true, ) ); } /** * POST /onboarding — mark the first-run setup wizard complete (or skipped) so * it never shows again. Stored as its own option, not inside settings, so a * factory reset doesn't re-trigger the wizard. * * @return \WP_REST_Response */ public function complete_onboarding() { update_option( 'agentimus_onboarded', AGENTIMUS_VERSION ); return rest_ensure_response( array( 'onboarded' => true ) ); } /** * GET /changelog — the full changelog for the in-admin dialog, parsed from the * BUNDLED readme.txt. Deliberately not fetched from WordPress.org: the changelog * wp.org displays IS this file, so a remote fetch would add an outbound call (and * a failure mode, and a disclosure) to receive what's already on disk — and the * local copy always matches the installed version exactly. Notes are escaped * first, then given minimal formatting (bold/code/links), so the payload is safe * to render as HTML. * * @return \WP_REST_Response */ public function changelog() { $readme = (string) file_get_contents( AGENTIMUS_DIR . 'readme.txt' ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- our own bundled file. $entries = array(); if ( preg_match( '/^== Changelog ==\s*$(.*?)(?=^== |\z)/ms', $readme, $m ) ) { // Split into "= 1.24.0 =" blocks; [0] is pre-block junk (blank), then // alternating heading / body pairs. $parts = preg_split( '/^= (.+?) =\s*$/m', trim( $m[1] ), -1, PREG_SPLIT_DELIM_CAPTURE ); $count = count( $parts ); for ( $i = 1; $i < $count; $i += 2 ) { $notes = array(); foreach ( preg_split( '/\R/u', trim( isset( $parts[ $i + 1 ] ) ? $parts[ $i + 1 ] : '' ) ) as $line ) { $line = trim( $line ); if ( '' === $line ) { continue; } $notes[] = $this->format_note( preg_replace( '/^\*\s*/', '', $line ) ); } if ( $notes ) { $entries[] = array( 'version' => sanitize_text_field( $parts[ $i ] ), 'notes' => $notes, ); } } } return rest_ensure_response( array( 'entries' => $entries ) ); } /** * Escape one changelog line, then apply the readme's minimal formatting: * **bold**, `code`, and bare https URLs become links. Escapes FIRST, so the * result is safe for v-html. * * @param string $line Raw readme line. * @return string Safe HTML. */ private function format_note( $line ) { $out = esc_html( $line ); $out = preg_replace( '/\*\*(.+?)\*\*/s', '$1', $out ); // Single-asterisk emphasis (readme *word*) — after ** so bold never half-matches. $out = preg_replace( '/\*([^*\s][^*]*)\*/', '$1', $out ); $out = preg_replace( '/`([^`]+)`/', '$1', $out ); $out = preg_replace( '#(https://[^\s<]+[^\s<.,)])#', '$1', $out ); return $out; } /** * POST /whatsnew-seen — dismiss the current release's "What's new" card. Stores * the version, so the card returns exactly once per release, never per session. * * @return \WP_REST_Response */ public function whatsnew_seen() { update_option( 'agentimus_whatsnew_seen', AGENTIMUS_VERSION ); return rest_ensure_response( array( 'seen' => AGENTIMUS_VERSION ) ); } /** * POST /review-ack — record the owner's answer to the Dashboard's review * ask. 'later' snoozes it for a month; 'review' and 'done' close it for * good (and the admin footer's rating line goes quiet with it). * * @param \WP_REST_Request $request Request carrying the 'answer' param. * @return \WP_REST_Response */ public function review_ack( $request ) { $answer = (string) $request['answer']; if ( ! in_array( $answer, array( 'review', 'done', 'later' ), true ) ) { return new \WP_Error( 'rest_invalid_param', __( 'answer must be one of: review, done, later.', 'agentimus' ), array( 'status' => 400 ) ); } return rest_ensure_response( array( 'state' => Review::ack( $answer ) ) ); } /** * POST /verifier/probe-ranges — the settings form's "Add bot" check: fetch the * candidate IP-ranges URL once (same bounded rules as the daily refresh) and * confirm it actually parses as a range file, so an owner can't save a URL that * could never verify anyone. Admin-clicked and capability-gated; the fetch is * bounded (5s, size-capped, no private hosts) so there's nothing to amplify. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function probe_ranges( \WP_REST_Request $request ) { $result = BotRanges::probe( (string) $request->get_param( 'url' ) ); if ( ! empty( $result['ok'] ) ) { return rest_ensure_response( array( 'ok' => true, 'prefixes' => (int) $result['prefixes'], ) ); } $messages = array( 'not-https' => __( 'The ranges URL must start with https://.', 'agentimus' ), 'unreachable' => __( 'That URL didn’t answer — check it’s correct and publicly reachable.', 'agentimus' ), 'not-a-range-file' => __( 'That URL doesn’t serve a range file — expected the operator’s prefixes JSON (like googlebot.json) or a plain JSON list of ranges.', 'agentimus' ), ); $reason = isset( $result['reason'] ) ? (string) $result['reason'] : 'not-a-range-file'; return rest_ensure_response( array( 'ok' => false, 'message' => isset( $messages[ $reason ] ) ? $messages[ $reason ] : $messages['not-a-range-file'], ) ); } /** * GET /readiness. * * @return \WP_REST_Response */ public function get_readiness() { return rest_ensure_response( ( new Readiness( $this->settings ) )->report() ); } /** * GET /score — the composite AEO/GEO score, recomputed. * * The score card used to arrive only two ways: with the page's initial bootstrap, or in * the reply to a settings save. So an edit made anywhere else — publishing a post in * another tab, which is the normal case — left an open dashboard showing a stale score * and a "next step" pointing at a page the owner had already fixed, with no way back but * a full page reload. This route gives the admin a way to ask for the current score. * * @return \WP_REST_Response */ public function get_score() { $readiness = ( new Readiness( $this->settings ) )->report(); return rest_ensure_response( array( 'score' => $this->score_report( $readiness ) ) ); } /** * GET /preview/schema — the JSON-LD document for the site or a chosen post. * * Returns the graph regardless of whether the front end is currently emitting * it (schema disabled, or an SEO plugin owns it): the point of a preview is to * show what WOULD ship. The `active`/`reason` fields let the UI explain when the * live `` is empty. An unpublished post (draft/pending/scheduled/private) * is previewed with its would-be per-post node — flagged by `postNote` as not yet * live — so the owner sees what publishing will emit. A password-protected post * is the one exception: its body never ships as schema, so it still yields only * the site-level nodes. `livePublic` marks whether the target is reachable at a * public URL right now, gating the URL-based validators. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function get_schema_preview( \WP_REST_Request $request ) { $schema = new Schema( $this->settings ); $seo = $schema->seo_plugin_active(); $enabled = $this->settings->enabled( 'enable_schema' ); $post_id = absint( $request->get_param( 'post' ) ); $post = null; $post_included = true; $post_note = ''; $live_public = true; // The site view is always reachable at a public URL. $target = array( 'type' => 'site', 'id' => 0, 'label' => __( 'Site-wide identity', 'agentimus' ), 'url' => home_url( '/' ), ); if ( $post_id > 0 ) { $post = get_post( $post_id ); if ( ! $post || ! in_array( $post->post_type, Content::post_types(), true ) ) { return new \WP_Error( 'agentimus_preview_not_found', __( 'That content is not available to preview.', 'agentimus' ), array( 'status' => 404 ) ); } $target = array( 'type' => 'post', 'id' => (int) $post->ID, 'label' => $this->preview_label( $post ), 'url' => (string) get_permalink( $post ), ); // Only a published, non-gated post is reachable at a public URL — the // precondition for the URL-based validators (a draft URL would 404). $live_public = ( 'publish' === $post->post_status && '' === (string) $post->post_password ); if ( '' !== (string) $post->post_password ) { // Never previewed: a gated body stays private even once published. $post_included = false; $post_note = __( 'This is password-protected, so its content is never exposed as schema — only the site-wide identity below.', 'agentimus' ); } elseif ( 'publish' !== $post->post_status ) { // Preview the node this post WILL emit once it's published — clearly // flagged as not-yet-live so the owner doesn't mistake it for live output. $post_note = __( 'Not published yet — this is a preview of the per-post schema that will ship once you publish. It isn’t in your live page’s yet.', 'agentimus' ); } } $doc = ( null === $post ) ? $schema->build_document( null, true ) : $schema->build_document( $post, false, true ); return rest_ensure_response( array( 'active' => (bool) ( $enabled && ! $seo ), 'reason' => ! $enabled ? 'disabled' : ( $seo ? 'seo_plugin' : 'ok' ), 'seoPlugin' => (bool) $seo, 'target' => $target, 'postIncluded' => (bool) $post_included, 'postNote' => $post_note, 'livePublic' => (bool) $live_public, 'graph' => $doc, // Pretty, slash-unescaped JSON for reading/validating. The live // escapes slashes to stay safe inside