Plugin Directory

Changeset 3777340


Ignore:
Timestamp:
10/31/2026 07:50:24 PM (4 hours ago)
Author:
aaronkaltman
Message:

Release 0.38.0 (git 13cc095c): see readme changelog

Location:
auditae-ai-search-toolkit
Files:
2 added
22 edited
1 copied

Legend:

Unmodified
Added
Removed
  • auditae-ai-search-toolkit/tags/0.38.0/auditae.php

    r3775803 r3777340  
    44 * Plugin URI:        https://auditae.app/wordpress-ai-plugin
    55 * Description:       WordPress MCP server for Claude & ChatGPT: let AI agents draft, edit and publish. Free AI search readiness: llms.txt, AI crawler log, score, schema.
    6  * Version:           0.37.0
     6 * Version:           0.38.0
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    1818defined( 'ABSPATH' ) || exit;
    1919
    20 define( 'AUDITAE_VERSION', '0.37.0' );
     20define( 'AUDITAE_VERSION', '0.38.0' );
    2121// REST contract version the app can check before relying on newer fields.
    2222// Bump when a route gains or changes a field the app depends on.
    … …  
    3333// seo_provider; POST /posts/{id}/untrash and /elementor-restore; GET /posts
    3434// status=trash; builder gutenberg|classic|aeo_template|empty; /status
    35 // features).
    36 define( 'AUDITAE_API_VERSION', 6 );
     35// features); 7 = 0.38.0 (body edits keep their undo on the site:
     36// content_restore_id + POST /posts/{id}/restore, no old body in answers;
     37// internal-link target check + undo; redirect live-source refusal; menu
     38// links in the graph; GET /broken-links + POST /posts/{id}/fix-link; POST
     39// /media/alt-text; /posts/{id}/seo robots + rendered_*; schema if_output;
     40// see auditae_rest_features()).
     41define( 'AUDITAE_API_VERSION', 7 );
    3742define( 'AUDITAE_PLUGIN_FILE', __FILE__ );
    3843define( 'AUDITAE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    … …  
    110115// dead end), so it loads after redirects.php.
    111116require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links.php';
     117require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links-rest.php';
    112118// Fan-out reads the link index (anchor phrases, stopwords) and the readiness
    113119// helpers (builder detection), so it loads after both.
  • auditae-ai-search-toolkit/tags/0.38.0/includes/elementor.php

    r3775803 r3777340  
    11051105}
    11061106
     1107// The post body an AuditAE edit replaced on a non-Elementor post (a full
     1108// `content` write, `replacements`, an inserted internal link, a broken-link
     1109// fix), kept on the post so the edit can be undone by id
     1110// (POST /posts/{id}/restore) instead of echoing the whole old body back in
     1111// every answer. Same limits and same changed-since check as the Elementor
     1112// store above.
     1113define( 'AUDITAE_CONTENT_UNDO_KEY', '_auditae_content_undo' );
     1114
     1115/**
     1116 * Keep the body an edit just replaced. Call after the new body is saved.
     1117 * Returns the restore id ('cu_…'), or '' when the body is too big to keep
     1118 * or nothing changed.
     1119 *
     1120 * @param int    $post_id Post ID.
     1121 * @param string $before  post_content before the edit.
     1122 * @return string
     1123 */
     1124function auditae_content_undo_push( $post_id, $before ) {
     1125        $post_id = (int) $post_id;
     1126        $before  = (string) $before;
     1127        $after   = (string) get_post_field( 'post_content', $post_id, 'raw' );
     1128        if ( strlen( $before ) > AUDITAE_ELEMENTOR_UNDO_MAX_BYTES || $before === $after ) {
     1129                return '';
     1130        }
     1131        $list   = get_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, true );
     1132        $list   = is_array( $list ) ? array_values( $list ) : array();
     1133        $id     = 'cu_' . strtolower( wp_generate_password( 16, false, false ) );
     1134        $list[] = array(
     1135                'id'        => $id,
     1136                'at'        => time(),
     1137                'before'    => $before,
     1138                'after_sha' => sha1( $after ),
     1139        );
     1140        $list = array_slice( $list, -AUDITAE_ELEMENTOR_UNDO_MAX );
     1141        update_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, wp_slash( $list ) );
     1142        return $id;
     1143}
     1144
     1145/**
     1146 * Put back the body a kept edit replaced. The body was already stored on
     1147 * this site, so it is saved with the post kses filter lifted (as user 0 it
     1148 * would strip the embeds the edit never touched).
     1149 *
     1150 * @param int    $post_id    Post ID.
     1151 * @param string $restore_id Id from auditae_content_undo_push().
     1152 * @param bool   $force      Restore even if the post changed since.
     1153 * @return array{previous:string,restore_id:string}|WP_Error
     1154 */
     1155function auditae_content_undo_restore( $post_id, $restore_id, $force = false ) {
     1156        $post_id = (int) $post_id;
     1157        $list    = get_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, true );
     1158        $list    = is_array( $list ) ? array_values( $list ) : array();
     1159        $index   = null;
     1160        foreach ( $list as $i => $entry ) {
     1161                if ( is_array( $entry ) && isset( $entry['id'] ) && hash_equals( (string) $entry['id'], (string) $restore_id ) ) {
     1162                        $index = $i;
     1163                }
     1164        }
     1165        if ( null === $index ) {
     1166                return new WP_Error( 'auditae_restore_not_found', 'No kept AuditAE edit with that restore id on this post (only the last ' . AUDITAE_ELEMENTOR_UNDO_MAX . ' are kept). Older versions are in the post\'s Revisions in wp-admin.', array( 'status' => 404 ) );
     1167        }
     1168        $entry   = $list[ $index ];
     1169        $current = (string) get_post_field( 'post_content', $post_id, 'raw' );
     1170        if ( ! $force && sha1( $current ) !== (string) $entry['after_sha'] ) {
     1171                return new WP_Error( 'auditae_changed_since', 'This post was changed after that edit (in wp-admin or by another edit), so restoring it would undo those changes too. Nothing was changed. Pass force: true to restore it anyway.', array( 'status' => 409 ) );
     1172        }
     1173        $prio = has_filter( 'content_save_pre', 'wp_filter_post_kses' );
     1174        if ( false !== $prio ) {
     1175                remove_filter( 'content_save_pre', 'wp_filter_post_kses', $prio );
     1176        }
     1177        try {
     1178                $res = wp_update_post(
     1179                        array(
     1180                                'ID'           => $post_id,
     1181                                'post_content' => wp_slash( (string) $entry['before'] ),
     1182                        ),
     1183                        true
     1184                );
     1185        } finally {
     1186                if ( false !== $prio ) {
     1187                        add_filter( 'content_save_pre', 'wp_filter_post_kses', $prio );
     1188                }
     1189        }
     1190        if ( is_wp_error( $res ) ) {
     1191                return new WP_Error( 'auditae_update_failed', $res->get_error_message(), array( 'status' => 500 ) );
     1192        }
     1193        $list = array_slice( $list, 0, $index );
     1194        if ( empty( $list ) ) {
     1195                delete_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY );
     1196        } else {
     1197                update_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, wp_slash( $list ) );
     1198        }
     1199        if ( function_exists( 'auditae_links_invalidate' ) ) {
     1200                auditae_links_invalidate();
     1201        }
     1202        return array(
     1203                'previous'   => $current,
     1204                'restore_id' => auditae_content_undo_push( $post_id, $current ),
     1205        );
     1206}
     1207
     1208/**
     1209 * Undo pairs for a plain-post `replacements` edit: each replacement turned
     1210 * back into its find, in reverse order. Only returned when replaying them
     1211 * on the new body gives back the old body exactly (a replacement whose new
     1212 * text already appeared elsewhere, or one that also changed block
     1213 * attributes, would not); otherwise the restore id is the undo.
     1214 *
     1215 * @param string $before   Body before the edit.
     1216 * @param string $after    Body after the edit.
     1217 * @param array  $replaced auditae_apply_replacements() result.
     1218 * @param array  $sent     The request's replacements.
     1219 * @return array|null
     1220 */
     1221function auditae_replacements_undo_pairs( $before, $after, array $replaced, array $sent ) {
     1222        if ( empty( $replaced['matches'] ) || ! is_array( $replaced['matches'] ) ) {
     1223                return null;
     1224        }
     1225        $undo = array();
     1226        $text = (string) $after;
     1227        foreach ( array_reverse( array_keys( $sent ) ) as $i ) {
     1228                $r = $sent[ $i ];
     1229                if ( ! is_array( $r ) || ! isset( $r['find'], $r['replace'] ) || '' === (string) $r['replace'] ) {
     1230                        return null;
     1231                }
     1232                $stored = wp_kses_post( (string) $r['replace'] );
     1233                $n      = isset( $replaced['counts'][ $i ] ) ? (int) $replaced['counts'][ $i ] : 0;
     1234                if ( $n < 1 || substr_count( $text, $stored ) !== $n ) {
     1235                        return null;
     1236                }
     1237                $text   = str_replace( $stored, (string) $r['find'], $text );
     1238                $pair   = array(
     1239                        'find'    => $stored,
     1240                        'replace' => (string) $r['find'],
     1241                );
     1242                if ( $n > 1 ) {
     1243                        $pair['expected_count'] = $n;
     1244                }
     1245                $undo[] = $pair;
     1246        }
     1247        return $text === (string) $before ? $undo : null;
     1248}
     1249
     1250/**
     1251 * Restore by id: 'eu_…' (Elementor tree) or 'cu_…' (post body).
     1252 *
     1253 * @param int    $post_id    Post ID.
     1254 * @param string $restore_id Restore id.
     1255 * @param bool   $force      Restore even if the post changed since.
     1256 * @return array{previous:string,restore_id:string,kind:string}|WP_Error
     1257 */
     1258function auditae_undo_restore( $post_id, $restore_id, $force = false ) {
     1259        $restore_id = (string) $restore_id;
     1260        if ( 0 === strpos( $restore_id, 'eu_' ) ) {
     1261                if ( ! auditae_elementor_is_built( $post_id ) ) {
     1262                        return new WP_Error( 'auditae_bad_request', 'That restore id is for an Elementor edit, and this post is no longer built with Elementor.', array( 'status' => 400 ) );
     1263                }
     1264                $res = auditae_elementor_undo_restore( $post_id, $restore_id, $force );
     1265                $kind = 'elementor';
     1266        } else {
     1267                if ( auditae_elementor_is_built( $post_id ) ) {
     1268                        return new WP_Error( 'auditae_bad_request', 'That restore id is for a post-body edit, and this post is now built with Elementor (its body is a mirror). Nothing was changed.', array( 'status' => 400 ) );
     1269                }
     1270                $res  = auditae_content_undo_restore( $post_id, $restore_id, $force );
     1271                $kind = 'content';
     1272        }
     1273        if ( is_wp_error( $res ) ) {
     1274                return $res;
     1275        }
     1276        $res['kind'] = $kind;
     1277        return $res;
     1278}
     1279
    11071280/**
    11081281 * The post_content mirror Elementor's own save would write for this tree
  • auditae-ai-search-toolkit/tags/0.38.0/includes/internal-links.php

    r3733266 r3777340  
    9292        // the graph is missing or out of date a background rebuild is queued
    9393        // and the response says so ('building').
    94         $status = auditae_links_index_status();
     94        $status = auditae_links_index_status_catch_up();
    9595        $index  = $status['index'];
    9696
    … …  
    117117                $items[] = array(
    118118                        'id'             => (int) $p->ID,
    119                         'title'          => get_the_title( $p ),
     119                        'title'          => auditae_rest_plain_title( $p ),
    120120                        'slug'           => $p->post_name,
    121121                        'view_url'       => get_permalink( $p ),
    122122                        'outgoing_count' => (int) $stats['out'],
    123123                        'incoming_count' => (int) $stats['in'],
    124                         'orphan'         => ( 0 === (int) $stats['in'] && $stats['known'] && ! in_array( (int) $p->ID, $exempt, true ) ),
     124                        // Links from the site's navigation menus, counted apart from
     125                        // links in other posts' content (0.38.0).
     126                        'menu_links'     => (int) $stats['menu'],
     127                        'orphan'         => ( 0 === (int) $stats['in'] && 0 === (int) $stats['menu'] && $stats['known'] && ! in_array( (int) $p->ID, $exempt, true ) ),
    125128                        // False when the post sits outside the indexed window, so a
    126129                        // consumer can tell "no incoming links" from "not measured".
    … …  
    147150                                // Non-null while a sliced rebuild runs: pages scanned so far.
    148151                                'rebuilding' => $status['progress'],
     152                                // True when WP-Cron isn't running the rebuild here (each read
     153                                // then advances it a little itself).
     154                                'stalled'     => (bool) $status['stalled'],
     155                                'age_seconds' => isset( $index['built'] ) ? max( 0, time() - (int) $index['built'] ) : null,
    149156                        ),
    150157                )
    … …  
    241248                                if ( in_array( auditae_links_normalize_path( $link['href'] ), $target_keys, true ) ) {
    242249                                        $incoming[] = array(
     250                                                'source'       => 'content',
    243251                                                'source_id'    => (int) $cand->ID,
    244                                                 'source_title' => get_the_title( $cand->ID ),
     252                                                'source_title' => auditae_rest_plain_title( $cand->ID ),
    245253                                                'anchor_text'  => $link['anchor'],
    246254                                                'href'         => $link['href'],
    … …  
    251259        }
    252260
     261        // Navigation menu links count as incoming too (source "menu"), so a
     262        // page reached only from the menu isn't presented as unlinked.
     263        $menu      = auditae_links_menu_inlinks();
     264        $menu_hits = isset( $menu[ (int) $post->ID ] ) ? (int) $menu[ (int) $post->ID ] : 0;
     265        if ( $menu_hits > 0 ) {
     266                $incoming[] = array(
     267                        'source'       => 'menu',
     268                        'source_id'    => 0,
     269                        'source_title' => 'Navigation menu',
     270                        'anchor_text'  => '',
     271                        'href'         => (string) $permalink,
     272                        'count'        => $menu_hits,
     273                );
     274        }
     275
    253276        return rest_ensure_response(
    254277                array(
    255278                        'post' => array(
    256279                                'id'    => (int) $post->ID,
    257                                 'title' => get_the_title( $post ),
     280                                'title' => auditae_rest_plain_title( $post ),
    258281                                'slug'  => $post->post_name,
    259282                        ),
    260                         'outgoing' => $outgoing,
    261                         'incoming' => $incoming,
     283                        'outgoing'   => $outgoing,
     284                        'incoming'   => $incoming,
     285                        'menu_links' => $menu_hits,
    262286                        // Only computed when there's nothing incoming, since that's the
    263287                        // only case where a suggestion is the next action. Each one is
    264288                        // pre-verified to be insertable, so AEBOT can act on it without
    265289                        // a round trip that might 422.
    266                         'suggestions' => empty( $incoming ) ? auditae_links_suggest_sources( (int) $post->ID, 5 ) : array(),
     290                        'suggestions' => count( $incoming ) === ( $menu_hits > 0 ? 1 : 0 ) ? auditae_links_suggest_sources( (int) $post->ID, 5 ) : array(),
    267291                )
    268292        );
    … …  
    279303                return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) );
    280304        }
     305        if ( 'trash' === $post->post_status ) {
     306                return new WP_Error( 'auditae_post_trashed', 'This post is in the trash. Restore it first (POST /posts/' . $id . '/untrash).', array( 'status' => 409 ) );
     307        }
    281308
    282309        $json = $request->get_json_params();
    … …  
    285312        $anchor = isset( $body['anchor_text'] ) ? trim( (string) $body['anchor_text'] ) : '';
    286313        $href   = isset( $body['target_url'] ) ? (string) $body['target_url'] : '';
    287 
    288         $original = auditae_elementor_is_built( $id ) ? get_post_meta( $id, '_elementor_data', true ) : $post->post_content;
     314        $force  = isset( $body['force'] ) && rest_sanitize_boolean( $body['force'] );
     315
     316        // The target must be a page that exists and is public: a host check
     317        // alone let /does-not-exist/ through, writing a broken link (and the
     318        // app billed for it). Resolved the way the broken-link checker does
     319        // (posts, pages, terms, archives, files, redirects) before anything is
     320        // written. force: true skips it, for a path another app on the same
     321        // host serves.
     322        $is_elementor = auditae_elementor_is_built( $id );
     323        $target       = null;
     324        $site_host    = wp_parse_url( home_url(), PHP_URL_HOST );
     325        $target_host  = wp_parse_url( $href, PHP_URL_HOST );
     326        if ( '' !== $anchor && '' !== $href && ( ! $target_host || auditae_is_site_host( $target_host, $site_host ) ) ) {
     327                $target = auditae_links_check_target( $href );
     328                if ( ! $force && 'broken' === $target['status'] ) {
     329                        return new WP_Error(
     330                                'auditae_link_target_not_found',
     331                                sprintf( 'Nothing was changed: %s is not a page on this site (no published post, page, category, archive, file or redirect answers it), so the link would be broken. Link to a published page (wp_list_posts gives each one\'s view_url), or pass force: true if another app on this host serves that path.', $href ),
     332                                array(
     333                                        'status'        => 422,
     334                                        'target_status' => 'not_found',
     335                                )
     336                        );
     337                }
     338                if ( ! $force && 'unpublished' === $target['status'] ) {
     339                        // Same code as not_found (the app maps one refusal);
     340                        // data.target_status says which.
     341                        return new WP_Error(
     342                                'auditae_link_target_not_found',
     343                                sprintf( 'Nothing was changed: %1$s is post %2$d, which is %3$s, so visitors following the link would get a 404. Publish it first, or link to a published page.', $href, (int) $target['post_id'], $target['post_status'] ),
     344                                array(
     345                                        'status'        => 422,
     346                                        'target_status' => $target['post_status'],
     347                                        'target_id'     => (int) $target['post_id'],
     348                                )
     349                        );
     350                }
     351        }
     352
     353        $content_before   = (string) $post->post_content;
     354        $elementor_before = $is_elementor ? (string) get_post_meta( $id, '_elementor_data', true ) : '';
    289355
    290356        // Validation, the kses/slashing dance and the save all live in
    291357        // auditae_links_insert() so this endpoint and the one-click button on
    292358        // the Links tab cannot drift apart.
    293         $result = auditae_links_insert( $id, $anchor, $href );
     359        $info   = array();
     360        $result = auditae_links_insert( $id, $anchor, $href, $info );
    294361        if ( is_wp_error( $result ) ) {
    295362                $status = array(
    … …  
    307374        }
    308375
    309         auditae_log_action( 'internal_link_inserted', 'id=' . $id );
    310 
    311         return rest_ensure_response(
    312                 array(
    313                         'post_id'        => (int) $id,
    314                         'anchor_text'    => $anchor,
    315                         'target_url'     => esc_url_raw( $href ),
    316                         'previous_state' => auditae_elementor_is_built( $id )
    317                                 ? array( '_elementor_data' => $original )
    318                                 : array( 'post_content' => $original ),
    319                 )
    320         );
     376        // Undone by id (POST /posts/{id}/restore), like wp_update_post's text
     377        // edits: an Elementor page's tree can't be written back by the app, and
     378        // echoing the whole old body made every link answer as big as the page.
     379        $answer = array(
     380                'post_id'     => (int) $id,
     381                'anchor_text' => $anchor,
     382                'target_url'  => esc_url_raw( $href ),
     383                'builder'     => auditae_rest_builder( get_post( $id ) ),
     384        );
     385        if ( null !== $target && $target['post_id'] > 0 ) {
     386                $answer['target_id'] = (int) $target['post_id'];
     387        }
     388        if ( $is_elementor ) {
     389                $rid = auditae_elementor_undo_push( $id, $elementor_before );
     390                if ( '' !== $rid ) {
     391                        $answer['elementor_restore_id'] = $rid;
     392                }
     393        } else {
     394                $rid = auditae_content_undo_push( $id, $content_before );
     395                if ( '' !== $rid ) {
     396                        $answer['content_restore_id'] = $rid;
     397                }
     398        }
     399        if ( ! empty( $info['inserted'] ) ) {
     400                // One-pair undo through wp_update_post `replacements` (Elementor
     401                // pages too): the anchor written back to the phrase it wrapped.
     402                $answer['inserted_html'] = $info['inserted'];
     403                $answer['undo']          = array(
     404                        'replacements' => array(
     405                                array(
     406                                        'find'    => $info['inserted'],
     407                                        'replace' => (string) $info['phrase'],
     408                                ),
     409                        ),
     410                );
     411        }
     412        if ( null !== $target && 'ok' !== $target['status'] ) {
     413                $answer['warning'] = 'force: true, so the target was not verified as a page on this site.';
     414        }
     415        if ( isset( $body['include_previous_content'] ) && rest_sanitize_boolean( $body['include_previous_content'] ) ) {
     416                $answer['previous_state'] = $is_elementor ? array( '_elementor_data' => $elementor_before ) : array( 'post_content' => $content_before );
     417        }
     418
     419        auditae_log_action( 'internal_link_inserted', 'id=' . $id . ( '' !== $rid ? ' restore_id=' . $rid : '' ) );
     420
     421        return rest_ensure_response( $answer );
    321422}
    322423
    … …  
    410511 * @return string Content with at most one new anchor.
    411512 */
    412 function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href ) {
     513function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href, &$inserted = null ) {
     514        $inserted = '';
    413515        if ( ! is_string( $content ) || '' === $content || '' === trim( (string) $anchor ) ) {
    414516                return $content;
    … …  
    443545        // A callback, not a replacement string: esc_url() allows "$", so a
    444546        // target URL containing "$1" was expanded as a backreference.
    445         $replace  = static function ( $m ) use ( $href_att ) {
    446                 return '<a href="' . $href_att . '">' . $m[1] . '</a>';
     547        $made     = '';
     548        $replace  = static function ( $m ) use ( $href_att, &$made ) {
     549                $made = '<a href="' . $href_att . '">' . $m[1] . '</a>';
     550                return $made;
    447551        };
    448552
    … …  
    482586        }
    483587
     588        if ( $replaced ) {
     589                // The exact anchor written, so a caller can offer it back as a
     590                // one-pair undo (find: this, replace: the phrase).
     591                $inserted = $made;
     592        }
    484593        return $replaced ? $out : $content;
    485594}
  • auditae-ai-search-toolkit/tags/0.38.0/includes/link-index.php

    r3733266 r3777340  
    175175
    176176/**
     177 * auditae_links_index_status() for the REST graph, which an agent reads
     178 * with nobody to press "Build link graph now": when nothing has advanced a
     179 * build lately (no slice in the last couple of minutes) and WP-Cron is
     180 * evidently not running it (an overdue event), or the graph is long past
     181 * its TTL, run one short slice in this request. Without it, `building`
     182 * stayed true next to an 18-hour-old built_at on a host where WP-Cron never
     183 * fired, and the graph never refreshed. The wp-admin tab keeps the
     184 * never-build-in-the-request behaviour (it has the button).
     185 *
     186 * @return array Same shape as auditae_links_index_status().
     187 */
     188function auditae_links_index_status_catch_up() {
     189        $status = auditae_links_index_status();
     190        if ( ! $status['building'] ) {
     191                return $status;
     192        }
     193        $stored = $status['index'];
     194        $state  = get_option( AUDITAE_LINK_INDEX_STATE );
     195        $last   = is_array( $state ) ? (int) ( isset( $state['updated'] ) ? $state['updated'] : ( isset( $state['started'] ) ? $state['started'] : 0 ) ) : 0;
     196        $idle   = ( time() - $last ) > AUDITAE_LINK_INDEX_RESUME_DELAY + 60;
     197        $old    = null !== $stored && ( time() - (int) $stored['built'] ) > AUDITAE_LINK_INDEX_TTL + HOUR_IN_SECONDS;
     198        if ( ! $idle || ! ( $status['stalled'] || $old ) ) {
     199                return $status;
     200        }
     201        $built = auditae_links_index_build( (float) apply_filters( 'auditae_link_index_inline_budget', 3 ) );
     202        if ( is_array( $built ) ) {
     203                return array(
     204                        'index'    => $built,
     205                        'building' => ! auditae_links_index_is_usable( get_option( AUDITAE_LINK_INDEX_OPTION ) ),
     206                        'progress' => null,
     207                        'stalled'  => false,
     208                );
     209        }
     210        $status['progress'] = auditae_links_index_progress();
     211        return $status;
     212}
     213
     214/**
    177215 * Queue the next build slice to run as soon as cron fires. Safe to call on
    178216 * every read: WordPress refuses a single event when one for the same hook
    … …  
    219257 * @return array|null The freshly built index, or null when a continuation was scheduled.
    220258 */
    221 function auditae_links_index_build() {
     259function auditae_links_index_build( $budget = null ) {
    222260        global $wpdb;
    223261
    … …  
    271309        }
    272310
    273         $budget   = (float) apply_filters( 'auditae_link_index_time_budget', AUDITAE_LINK_INDEX_TIME_BUDGET );
    274         $deadline = microtime( true ) + max( 0.0, $budget );
     311        if ( null === $budget ) {
     312                $budget = (float) apply_filters( 'auditae_link_index_time_budget', AUDITAE_LINK_INDEX_TIME_BUDGET );
     313        }
     314        $deadline = microtime( true ) + max( 0.0, (float) $budget );
    275315        $host     = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
    276316        $total    = count( $state['ids'] );
    … …  
    475515                        'in'    => 0,
    476516                        'out'   => 0,
     517                        'menu'  => 0,
    477518                        'known' => false,
    478519                );
    479520        }
     521        $menu = auditae_links_menu_inlinks();
    480522        return array(
    481523                'in'    => isset( $index['in'][ $post_id ] ) ? (int) $index['in'][ $post_id ] : 0,
    482524                'out'   => isset( $index['out'][ $post_id ] ) ? (int) $index['out'][ $post_id ] : 0,
     525                // Links from the site's navigation menus (not in `in`).
     526                'menu'  => isset( $menu[ $post_id ] ) ? (int) $menu[ $post_id ] : 0,
    483527                'known' => in_array( $post_id, $index['ids'], true ),
    484528        );
     529}
     530
     531/**
     532 * Posts and pages the site's navigation links to: items of the classic
     533 * menus assigned to a theme location (every menu when none is assigned, as
     534 * with an Elementor header's Nav Menu widget), and the links of published
     535 * block-theme Navigation menus (wp_navigation). A page reached only from
     536 * the menu was reported as an orphan: on a typical brochure site that was
     537 * every page but the home page.
     538 *
     539 * Read live (menus are small and cached), so it is never stale and needs
     540 * no index rebuild.
     541 *
     542 * @return array<int,int> post ID => number of menu links to it.
     543 */
     544function auditae_links_menu_inlinks( $reset = false ) {
     545        static $cache = null;
     546        if ( null !== $cache && ! $reset ) {
     547                return $cache;
     548        }
     549        $cache = array();
     550        $add   = static function ( $id ) use ( &$cache ) {
     551                $id = (int) $id;
     552                if ( $id > 0 ) {
     553                        $cache[ $id ] = isset( $cache[ $id ] ) ? $cache[ $id ] + 1 : 1;
     554                }
     555        };
     556        $host  = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
     557        $by_url = static function ( $url ) use ( $add, $host ) {
     558                $url      = (string) $url;
     559                $url_host = wp_parse_url( $url, PHP_URL_HOST );
     560                if ( '' === $url || ( $url_host && ! auditae_is_site_host( $url_host, $host ) ) ) {
     561                        return;
     562                }
     563                $abs = $url_host ? $url : home_url( '/' . ltrim( $url, '/' ) );
     564                $add( url_to_postid( $abs ) );
     565        };
     566
     567        $menu_ids = array_filter( array_map( 'intval', array_values( (array) get_nav_menu_locations() ) ) );
     568        if ( empty( $menu_ids ) ) {
     569                $menus    = wp_get_nav_menus( array( 'fields' => 'ids' ) );
     570                $menu_ids = is_array( $menus ) ? array_map( 'intval', $menus ) : array();
     571        }
     572        $seen_items = 0;
     573        foreach ( array_unique( $menu_ids ) as $menu_id ) {
     574                $items = wp_get_nav_menu_items( $menu_id, array( 'update_post_term_cache' => false ) );
     575                foreach ( (array) $items as $item ) {
     576                        if ( ++$seen_items > 500 ) {
     577                                break 2;
     578                        }
     579                        if ( 'post_type' === $item->type ) {
     580                                $add( $item->object_id );
     581                        } elseif ( 'custom' === $item->type ) {
     582                                $by_url( $item->url );
     583                        }
     584                }
     585        }
     586
     587        // Block themes: navigation-link / navigation-submenu blocks carry the
     588        // linked post's id (kind post-type) or a URL.
     589        $navs = get_posts(
     590                array(
     591                        'post_type'      => 'wp_navigation',
     592                        'post_status'    => 'publish',
     593                        'posts_per_page' => 20,
     594                        'no_found_rows'  => true,
     595                )
     596        );
     597        foreach ( $navs as $nav ) {
     598                $walk = static function ( $blocks ) use ( &$walk, $add, $by_url ) {
     599                        foreach ( (array) $blocks as $b ) {
     600                                if ( in_array( $b['blockName'], array( 'core/navigation-link', 'core/navigation-submenu', 'core/home-link' ), true ) ) {
     601                                        $attrs = (array) $b['attrs'];
     602                                        if ( isset( $attrs['kind'], $attrs['id'] ) && 'post-type' === $attrs['kind'] ) {
     603                                                $add( $attrs['id'] );
     604                                        } elseif ( ! empty( $attrs['url'] ) ) {
     605                                                $by_url( $attrs['url'] );
     606                                        }
     607                                }
     608                                if ( ! empty( $b['innerBlocks'] ) ) {
     609                                        $walk( $b['innerBlocks'] );
     610                                }
     611                        }
     612                };
     613                $walk( parse_blocks( $nav->post_content ) );
     614        }
     615        return $cache;
    485616}
    486617
    … …  
    530661        $limit   = max( 1, (int) $limit );
    531662        $exempt  = auditae_links_orphan_exempt_ids();
     663        $menu    = auditae_links_menu_inlinks();
    532664        $orphans = array();
    533665        foreach ( $index['ids'] as $id ) {
    534                 if ( isset( $index['in'][ $id ] ) || in_array( (int) $id, $exempt, true ) ) {
     666                if ( isset( $index['in'][ $id ] ) || isset( $menu[ (int) $id ] ) || in_array( (int) $id, $exempt, true ) ) {
    535667                        continue;
    536668                }
    … …  
    556688        }
    557689        $exempt = auditae_links_orphan_exempt_ids();
     690        $menu   = auditae_links_menu_inlinks();
    558691        $n      = 0;
    559692        foreach ( $index['ids'] as $id ) {
    560                 if ( ! isset( $index['in'][ $id ] ) && ! in_array( (int) $id, $exempt, true ) ) {
     693                if ( ! isset( $index['in'][ $id ] ) && ! isset( $menu[ (int) $id ] ) && ! in_array( (int) $id, $exempt, true ) ) {
    561694                        $n++;
    562695                }
    … …  
    816949 * @return bool Whether a link was placed.
    817950 */
    818 function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href ) {
     951function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href, &$inserted = null ) {
     952        $inserted = '';
    819953        return auditae_elementor_map_slots(
    820954                $data,
    821                 function ( $value, $kind, &$stop ) use ( $anchor, $href ) {
     955                function ( $value, $kind, &$stop ) use ( $anchor, $href, &$inserted ) {
    822956                        if ( 'rich' !== $kind ) {
    823957                                return $value;
    824958                        }
    825                         $new = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href );
     959                        $made = '';
     960                        $new  = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href, $made );
    826961                        if ( $new !== $value ) {
    827                                 $stop = true;
     962                                $stop     = true;
     963                                $inserted = $made;
    828964                        }
    829965                        return $new;
    … …  
    842978 * @param string $anchor    Text to wrap.
    843979 * @param string $href      Destination URL (must be same-host).
     980 * @param array  $info      Filled on success: inserted (the <a> written), phrase (the text it wraps).
    844981 * @return true|WP_Error
    845982 */
    846 function auditae_links_insert( $source_id, $anchor, $href ) {
     983function auditae_links_insert( $source_id, $anchor, $href, &$info = null ) {
     984        $info      = array();
    847985        $source_id = (int) $source_id;
    848986        $post      = get_post( $source_id );
    … …  
    8721010        if ( auditae_elementor_is_built( $source_id ) ) {
    8731011                $data = auditae_elementor_data( $source_id );
    874                 if ( ! auditae_elementor_wrap_first_anchor( $data, $anchor, $href ) ) {
     1012                $made = '';
     1013                if ( ! auditae_elementor_wrap_first_anchor( $data, $anchor, $href, $made ) ) {
    8751014                        return $not_found;
    8761015                }
     1016                $info = auditae_links_insert_info( $made );
    8771017                $result = auditae_elementor_save( $source_id, $data );
    8781018                if ( is_wp_error( $result ) ) {
    … …  
    8841024
    8851025        $original = $post->post_content;
    886         $updated  = auditae_wrap_first_anchor_occurrence( $original, $anchor, $href );
     1026        $made     = '';
     1027        $updated  = auditae_wrap_first_anchor_occurrence( $original, $anchor, $href, $made );
    8871028        if ( $updated === $original ) {
    8881029                return $not_found;
    8891030        }
     1031        $info = auditae_links_insert_info( $made );
    8901032
    8911033        // Requests authenticated by bearer token run as user 0, where kses is
    … …  
    9181060}
    9191061
     1062/**
     1063 * What auditae_links_insert() wrote, for an undo pair.
     1064 *
     1065 * @param string $made The <a> written.
     1066 * @return array{inserted:string,phrase:string}
     1067 */
     1068function auditae_links_insert_info( $made ) {
     1069        $phrase = preg_replace( '#^<a\b[^>]*>(.*)</a>$#s', '$1', (string) $made );
     1070        return array(
     1071                'inserted' => (string) $made,
     1072                'phrase'   => is_string( $phrase ) ? $phrase : '',
     1073        );
     1074}
     1075
     1076/**
     1077 * Whether a link target resolves to something public on this site, the way
     1078 * the broken-link checker decides it (auditae_broken_classify(): posts,
     1079 * pages, terms, archives, files, redirects; conservative: anything it
     1080 * can't rule out is 'ok').
     1081 *
     1082 * @param string $href Target URL (absolute same-host or host-relative).
     1083 * @return array{status:string,post_id:int,post_status:string} status ok | broken | unpublished.
     1084 */
     1085function auditae_links_check_target( $href ) {
     1086        $href = trim( (string) $href );
     1087        $out  = array(
     1088                'status'      => 'ok',
     1089                'post_id'     => 0,
     1090                'post_status' => '',
     1091        );
     1092        if ( ! function_exists( 'auditae_broken_classify' ) ) {
     1093                return $out;
     1094        }
     1095        $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
     1096        // A relative path without a leading slash resolves against home.
     1097        if ( '' !== $href && ! preg_match( '#^([a-z][a-z0-9+.\-]*:|/|\#|\?)#i', $href ) ) {
     1098                $href = '/' . $href;
     1099        }
     1100        if ( ! auditae_broken_is_checkable_internal( $href, $site_host ) ) {
     1101                return $out; // fragment, query-only, mailto:, another app's path
     1102        }
     1103        return auditae_broken_classify( $href, auditae_broken_normalize_path( $href ), auditae_broken_known_paths() );
     1104}
     1105
    9201106// ────────────────────────────────────────────────────────────────────
    9211107// Scheduled rebuild
  • auditae-ai-search-toolkit/tags/0.38.0/includes/media.php

    r3775803 r3777340  
    7070        // references inside published post content, so it carries more risk
    7171        // and a distinct audit-log entry. write_seo tier, same as alt/caption.
     72        // Alt text for up to 50 images in one call, with one undo token
     73        // (0.38.0). write_seo, like the single-image PATCH.
     74        register_rest_route(
     75                $namespace,
     76                '/media/bulk-alt',
     77                array(
     78                        'methods'             => 'POST',
     79                        'callback'            => 'auditae_rest_bulk_alt_text',
     80                        'permission_callback' => 'auditae_rest_authorize_seo_write',
     81                )
     82        );
     83        register_rest_route(
     84                $namespace,
     85                '/media/bulk-alt/undo',
     86                array(
     87                        'methods'             => 'POST',
     88                        'callback'            => 'auditae_rest_bulk_alt_text_undo',
     89                        'permission_callback' => 'auditae_rest_authorize_seo_write',
     90                )
     91        );
     92
    7293        register_rest_route(
    7394                $namespace,
    … …  
    241262                        'media'          => auditae_format_media( get_post( $id ), true ),
    242263                        'previous_state' => $previous_state,
     264                )
     265        );
     266}
     267
     268// ────────────────────────────────────────────────────────────────────
     269// POST /media/bulk-alt — bulk alt text, with undo (0.38.0)
     270// ────────────────────────────────────────────────────────────────────
     271
     272// Undo tokens for bulk alt writes: token => {at, items: {id: {before, after}}}.
     273// Last 10 kept for 30 days, one non-autoloaded option.
     274define( 'AUDITAE_OPT_ALT_UNDO', 'auditae_alt_undo' );
     275define( 'AUDITAE_ALT_BULK_MAX', 50 );
     276
     277/**
     278 * POST /media/bulk-alt {items: [{id, alt_text}, …]} (≤ 50). Per item: updated,
     279 * unchanged (already that alt: a no-op), not_found (not an attachment) or
     280 * invalid. One undo_token covers every updated item.
     281 */
     282function auditae_rest_bulk_alt_text( WP_REST_Request $request ) {
     283        $items = $request->get_param( 'items' );
     284        if ( ! is_array( $items ) || empty( $items ) ) {
     285                return new WP_Error( 'auditae_bad_request', 'items is required: [{id, alt_text}, …] (up to ' . AUDITAE_ALT_BULK_MAX . ').', array( 'status' => 400 ) );
     286        }
     287        if ( count( $items ) > AUDITAE_ALT_BULK_MAX ) {
     288                return new WP_Error( 'auditae_bad_request', 'At most ' . AUDITAE_ALT_BULK_MAX . ' items per call; nothing was changed. Split the list.', array( 'status' => 400 ) );
     289        }
     290        $results = array();
     291        $undo    = array();
     292        $seen    = array();
     293        foreach ( array_values( $items ) as $item ) {
     294                $id  = is_array( $item ) && isset( $item['id'] ) ? absint( $item['id'] ) : 0;
     295                $alt = is_array( $item ) && array_key_exists( 'alt_text', $item ) ? $item['alt_text'] : ( is_array( $item ) && array_key_exists( 'alt', $item ) ? $item['alt'] : null );
     296                if ( $id <= 0 || ! ( is_string( $alt ) || null === $alt ) || isset( $seen[ $id ] ) ) {
     297                        $results[] = array(
     298                                'id'     => $id,
     299                                'status' => 'invalid',
     300                                'error'  => isset( $seen[ $id ] ) ? 'duplicate id in this call' : 'each item needs a numeric id and a string alt_text',
     301                        );
     302                        continue;
     303                }
     304                $seen[ $id ] = true;
     305                $att         = get_post( $id );
     306                if ( ! $att || 'attachment' !== $att->post_type ) {
     307                        $results[] = array(
     308                                'id'     => $id,
     309                                'status' => 'not_found',
     310                        );
     311                        continue;
     312                }
     313                $new    = trim( wp_strip_all_tags( (string) $alt ) );
     314                $before = (string) get_post_meta( $id, '_wp_attachment_image_alt', true );
     315                if ( $new === $before ) {
     316                        $results[] = array(
     317                                'id'       => $id,
     318                                'status'   => 'unchanged',
     319                                'alt_text' => $new,
     320                        );
     321                        continue;
     322                }
     323                update_post_meta( $id, '_wp_attachment_image_alt', wp_slash( $new ) );
     324                clean_post_cache( $id );
     325                $undo[ $id ] = array(
     326                        'before' => $before,
     327                        'after'  => $new,
     328                );
     329                $results[]   = array(
     330                        'id'                => $id,
     331                        'status'            => 'updated',
     332                        'alt_text'          => $new,
     333                        'previous_alt_text' => $before,
     334                );
     335        }
     336
     337        $updated = count( $undo );
     338        $answer  = array(
     339                'results'   => $results,
     340                'updated'   => $updated,
     341                'unchanged' => count( wp_list_filter( $results, array( 'status' => 'unchanged' ) ) ),
     342                'failed'    => count( $results ) - $updated - count( wp_list_filter( $results, array( 'status' => 'unchanged' ) ) ),
     343        );
     344        if ( $updated > 0 ) {
     345                $token = 'au_' . strtolower( wp_generate_password( 16, false, false ) );
     346                $store = get_option( AUDITAE_OPT_ALT_UNDO, array() );
     347                $store = is_array( $store ) ? $store : array();
     348                foreach ( $store as $k => $entry ) {
     349                        if ( ! is_array( $entry ) || (int) ( $entry['at'] ?? 0 ) < time() - 30 * DAY_IN_SECONDS ) {
     350                                unset( $store[ $k ] );
     351                        }
     352                }
     353                $store[ $token ] = array(
     354                        'at'    => time(),
     355                        'items' => $undo,
     356                );
     357                $store = array_slice( $store, -10, null, true );
     358                update_option( AUDITAE_OPT_ALT_UNDO, $store, false );
     359                // POST /media/bulk-alt/undo {undo_token} puts every updated alt back.
     360                $answer['undo_token'] = $token;
     361                auditae_log_action( 'media_alt_bulk', 'updated=' . $updated . ' undo_token=' . $token );
     362        }
     363        return rest_ensure_response( $answer );
     364}
     365
     366/**
     367 * POST /media/bulk-alt/undo {undo_token, force?}: put back the alts a bulk
     368 * write replaced. An image whose alt changed again since is skipped
     369 * (changed_since) unless force.
     370 */
     371function auditae_rest_bulk_alt_text_undo( WP_REST_Request $request ) {
     372        $token = (string) $request->get_param( 'undo_token' );
     373        $store = get_option( AUDITAE_OPT_ALT_UNDO, array() );
     374        $store = is_array( $store ) ? $store : array();
     375        if ( ! preg_match( '/^au_[a-z0-9]{16}$/', $token ) || ! isset( $store[ $token ] ) ) {
     376                return new WP_Error( 'auditae_restore_not_found', 'No bulk alt-text write with that undo_token (the last 10, for 30 days, are kept). Nothing was changed.', array( 'status' => 404 ) );
     377        }
     378        $force    = rest_sanitize_boolean( $request->get_param( 'force' ) );
     379        $results  = array();
     380        $restored = 0;
     381        foreach ( (array) $store[ $token ]['items'] as $id => $pair ) {
     382                $id  = (int) $id;
     383                $cur = (string) get_post_meta( $id, '_wp_attachment_image_alt', true );
     384                if ( 'attachment' !== get_post_type( $id ) ) {
     385                        $results[] = array(
     386                                'id'     => $id,
     387                                'status' => 'not_found',
     388                        );
     389                        continue;
     390                }
     391                if ( ! $force && $cur !== (string) $pair['after'] ) {
     392                        $results[] = array(
     393                                'id'       => $id,
     394                                'status'   => 'changed_since',
     395                                'alt_text' => $cur,
     396                        );
     397                        continue;
     398                }
     399                update_post_meta( $id, '_wp_attachment_image_alt', wp_slash( (string) $pair['before'] ) );
     400                clean_post_cache( $id );
     401                $restored++;
     402                $results[] = array(
     403                        'id'       => $id,
     404                        'status'   => 'restored',
     405                        'alt_text' => (string) $pair['before'],
     406                );
     407        }
     408        if ( 0 === $restored ) {
     409                // Nothing put back: keep the token so a retry with force works.
     410                return new WP_Error(
     411                        'auditae_changed_since',
     412                        'Nothing was changed: every image\'s alt text was changed again (or the image deleted) since that write. Pass force: true to put the old alts back anyway.',
     413                        array(
     414                                'status'  => 409,
     415                                'results' => $results,
     416                        )
     417                );
     418        }
     419        unset( $store[ $token ] );
     420        update_option( AUDITAE_OPT_ALT_UNDO, $store, false );
     421        auditae_log_action( 'media_alt_bulk_undo', 'restored=' . $restored . ' undo_token=' . $token );
     422        return rest_ensure_response(
     423                array(
     424                        'results'  => $results,
     425                        'restored' => $restored,
    243426                )
    244427        );
    … …  
    614797
    615798        $rewrote_posts = 0;
     799        $rewrote_revs  = 0;
    616800        $rewrote_meta  = 0;
    617801        $leftovers     = array();
    … …  
    619803                // Pre-filter on the size stem (a prefix of the -scaled stem), so
    620804                // posts embedding only intermediate-size URLs still match.
    621                 $rewrote_posts = auditae_rewrite_media_references( $old_size_stem, $url_map );
     805                $rewrote_posts = auditae_rewrite_media_references( $old_size_stem, $url_map, $rewrote_revs );
    622806                $rewrote_meta  = auditae_rewrite_media_meta_references( $old_size_stem, $url_map );
    623807                auditae_rewrite_media_option_references( $url_map );
    … …  
    632816        return array(
    633817                'media'          => auditae_format_media( get_post( $id ), true ),
    634                 'rewrote_posts'  => $rewrote_posts,
     818                // Posts, pages and other content (any status but trash) whose body
     819                // or Elementor data named the file. Revisions are rewritten too and
     820                // counted apart (0.38.0; before, they were in rewrote_posts).
     821                'rewrote_posts'     => $rewrote_posts,
     822                'rewrote_revisions' => $rewrote_revs,
    635823                // Posts that still mention an old file name after the rewrite (a
    636824                // reference in a form we couldn't safely rewrite). Empty is good.
    … …  
    651839 * @param string $old_stem Filename stem (no extension) to pre-filter on.
    652840 * @param array  $url_map  old URL => new URL pairs (full size + each size).
    653  * @return int Number of posts updated.
     841 * @param int    $revisions Set to the number of revisions updated (not in the return value).
     842 * @return int Number of posts updated, revisions not counted.
    654843 */
    655 function auditae_rewrite_media_references( $old_stem, array $url_map ) {
     844function auditae_rewrite_media_references( $old_stem, array $url_map, &$revisions = null ) {
     845        $revisions = 0;
    656846        global $wpdb;
    657847
    … …  
    694884                                $el_done[ $pid ] = true;
    695885                        }
    696                         $updated++;
     886                        // Revisions are rewritten too (restoring one shouldn't bring a
     887                        // broken image back) but reported apart: counting them as posts
     888                        // said a rename touched 6 posts when no live post used the image.
     889                        if ( 'revision' === get_post_type( $pid ) ) {
     890                                $revisions++;
     891                        } else {
     892                                $updated++;
     893                        }
    697894                }
    698895        }
    … …  
    718915                        clean_post_cache( $pid );
    719916                        if ( ! isset( $el_done[ $pid ] ) ) {
    720                                 $updated++; // An Elementor page was already counted with its tree.
     917                                // An Elementor page was already counted with its tree.
     918                                if ( 'revision' === get_post_type( $pid ) ) {
     919                                        $revisions++;
     920                                } else {
     921                                        $updated++;
     922                                }
    721923                        }
    722924                }
  • auditae-ai-search-toolkit/tags/0.38.0/includes/redirects.php

    r3773612 r3777340  
    401401        }
    402402        return true;
     403}
     404
     405/**
     406 * What a redirect source still serves, when it isn't a 404. Rules only run
     407 * on a 404 (auditae_redirects_maybe_redirect()), so a rule for a live page
     408 * never fires. Null when nothing answers the path (the rule would work).
     409 *
     410 * Checks what WordPress itself answers the path with: a published post or
     411 * page of a viewable type, the front page, a term / post type / author
     412 * archive (auditae_broken_known_paths()), or a file on disk.
     413 *
     414 * @param string $source_n Normalized source (auditae_redirects_normalize_source()).
     415 * @return array{kind:string,post_id:int,title:string,view_url:string}|null
     416 */
     417function auditae_redirects_source_live( $source_n ) {
     418        $source_n = (string) $source_n;
     419        $parts    = wp_parse_url( home_url( '/' ) );
     420        $origin   = ( isset( $parts['scheme'] ) ? $parts['scheme'] : 'https' ) . '://' . ( isset( $parts['host'] ) ? $parts['host'] : '' ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' );
     421        $abs      = $origin . $source_n;
     422        $post_id  = url_to_postid( $abs );
     423        if ( $post_id <= 0 ) {
     424                $post_id = url_to_postid( $abs . '/' );
     425        }
     426        if ( $post_id > 0 ) {
     427                $post = get_post( $post_id );
     428                if ( $post && 'publish' === $post->post_status && 'attachment' !== $post->post_type && is_post_type_viewable( $post->post_type ) ) {
     429                        return array(
     430                                'kind'     => 'page' === $post->post_type ? 'page' : 'post',
     431                                'post_id'  => (int) $post_id,
     432                                'title'    => html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
     433                                'view_url' => (string) get_permalink( $post ),
     434                        );
     435                }
     436        }
     437        if ( ! function_exists( 'auditae_broken_known_paths' ) ) {
     438                return null;
     439        }
     440        $path = auditae_broken_normalize_path( $abs );
     441        if ( '' === $path ) {
     442                return array(
     443                        'kind'     => 'front_page',
     444                        'post_id'  => (int) get_option( 'page_on_front' ),
     445                        'title'    => '',
     446                        'view_url' => home_url( '/' ),
     447                );
     448        }
     449        $known = auditae_broken_known_paths();
     450        if ( isset( $known[ $path ] ) ) {
     451                return array(
     452                        'kind'     => 'archive',
     453                        'post_id'  => 0,
     454                        'title'    => '',
     455                        'view_url' => home_url( '/' . $path . '/' ),
     456                );
     457        }
     458        $raw = ltrim( rawurldecode( (string) wp_parse_url( $abs, PHP_URL_PATH ) ), '/' );
     459        if ( '' !== $raw && false === strpos( $raw, '..' ) && is_file( ABSPATH . $raw ) ) {
     460                return array(
     461                        'kind'     => 'file',
     462                        'post_id'  => 0,
     463                        'title'    => '',
     464                        'view_url' => $abs,
     465                );
     466        }
     467        return null;
    403468}
    404469
  • auditae-ai-search-toolkit/tags/0.38.0/includes/rest-routes.php

    r3775803 r3777340  
    113113        register_rest_route(
    114114                $namespace,
     115                '/posts/(?P<id>\d+)/restore',
     116                array(
     117                        // Undo of a body edit (content_restore_id) or an Elementor text
     118                        // edit (elementor_restore_id), by id. 0.38.0.
     119                        'methods'             => 'POST',
     120                        'callback'            => 'auditae_rest_elementor_restore',
     121                        'permission_callback' => 'auditae_rest_authorize_post_update',
     122                )
     123        );
     124
     125        register_rest_route(
     126                $namespace,
    115127                '/seo/yoast/settings',
    116128                array(
    … …  
    706718        }
    707719        return true;
     720}
     721
     722/**
     723 * A post title as plain text for REST answers. get_the_title() is
     724 * texturized and HTML-encoded for templates ("Tips &amp; Tricks"), which
     725 * agents then quoted back literally.
     726 *
     727 * @param int|WP_Post $post Post.
     728 * @return string
     729 */
     730function auditae_rest_plain_title( $post ) {
     731        return html_entity_decode( wp_strip_all_tags( (string) get_the_title( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
    708732}
    709733
    … …  
    887911                        // Behaviours the app may rely on (api_version 6+).
    888912                        'features'       => auditae_rest_features(),
     913                        // The IndexNow key file this plugin serves (always served, even
     914                        // with auto-ping off), so the app can submit this site's URLs
     915                        // under the site's own key. 0.38.0.
     916                        'indexnow'       => array(
     917                                'key_location' => auditae_indexnow_key_location(),
     918                                'enabled'      => (bool) auditae_indexnow_enabled(),
     919                                'paused'       => (string) auditae_indexnow_paused_reason(),
     920                        ),
    889921                )
    890922        );
    … …  
    903935                'untrash',                  // POST /posts/{id}/untrash, GET /posts?status=trash
    904936                'elementor_restore',        // POST /posts/{id}/elementor-restore
     937                // 0.38.0 (api_version 7):
     938                'content_restore',          // content_restore_id on body edits; POST /posts/{id}/restore takes cu_ and eu_ ids
     939                'compact_previous_state',   // PATCH /posts/{id} and internal-link answer no old body (include_previous_content: true for it)
     940                'link_target_check',        // internal-link refuses a target that isn't a public page (auditae_link_target_not_found / _unpublished)
     941                'internal_link_undo',       // internal-link answers undo.replacements + a restore id
     942                'redirect_source_check',    // redirects write refuses a live source (auditae_redirect_source_live) unless force
     943                'menu_links',               // link graph counts navigation menu links (menu_links), orphans exclude them
     944                'media_rename_revisions',   // rename reports rewrote_revisions apart from rewrote_posts
     945                'list_any_viewable',        // GET /posts?post_type=any lists only viewable content types
     946                'llms_txt_compact',         // llms.txt write/reset answer rendered_bytes, not rendered (include_rendered: true)
     947                'probe_handled_by',         // /probe-schema handled_by; found_foreign only when it differs
     948                'schema_if_output',         // /posts/{id}/schema write if_output: true refuses types another plugin prints
     949                'robots_meta',              // /posts/{id}/seo yoast / rankmath blocks read + write noindex / nofollow
     950                'rendered_seo',             // /posts/{id}/seo read: each engine block's rendered_title / rendered_description
     951                'broken_links_fix',         // GET /broken-links, POST /posts/{id}/broken-link
     952                'bulk_media_alt',           // POST /media/bulk-alt (+ /media/bulk-alt/undo)
     953                'indexnow_key',             // /status indexnow { key_location, enabled, paused }
    905954        );
    906955}
    … …  
    10261075                $query_type = array();
    10271076                foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $obj ) {
    1028                         if ( auditae_rest_post_type_allowed( $obj ) ) {
     1077                        if ( auditae_rest_post_type_allowed( $obj ) && auditae_rest_post_type_in_any( $obj ) ) {
    10291078                                $query_type[] = $obj->name;
    10301079                        }
    … …  
    10621111                        'post_type'     => $p->post_type,
    10631112                        'is_front_page' => auditae_rest_is_front_page( $p ),
    1064                         'title'     => get_the_title( $p ),
     1113                        'title'     => auditae_rest_plain_title( $p ),
    10651114                        'status'    => $p->post_status,
    10661115                        'date'      => auditae_rest_post_date( $p ),
    … …  
    13261375
    13271376/**
    1328  * POST /posts/{id}/elementor-restore {restore_id, force?} — put back the
    1329  * Elementor tree an AuditAE `replacements` edit replaced. Only trees this
    1330  * plugin kept itself (auditae_elementor_undo_push()) can be restored, by
    1331  * id; refused when the page changed since, unless force.
     1377 * POST /posts/{id}/restore (and its 0.37.0 name /elementor-restore)
     1378 * {restore_id, force?} — put back what an AuditAE edit replaced, by the id
     1379 * the edit answered with: an Elementor tree (elementor_restore_id, 'eu_…')
     1380 * or a post body (content_restore_id, 'cu_…': a `content` or
     1381 * `replacements` edit, an inserted internal link, a broken-link fix). Only
     1382 * what this plugin kept itself can be restored; refused when the post
     1383 * changed since, unless force. The restore answers its own restore id, so
     1384 * it can be undone too.
    13321385 */
    13331386function auditae_rest_elementor_restore( WP_REST_Request $request ) {
    … …  
    13371390                return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) );
    13381391        }
    1339         if ( ! auditae_elementor_is_built( $post_id ) ) {
    1340                 return new WP_Error( 'auditae_bad_request', 'This post is not built with Elementor. Undo other edits with previous_state (wp_update_post with the old values).', array( 'status' => 400 ) );
    1341         }
    13421392        $restore_id = $request->get_param( 'restore_id' );
    1343         if ( ! is_string( $restore_id ) || ! preg_match( '/^eu_[a-z0-9]{16}$/', $restore_id ) ) {
    1344                 return new WP_Error( 'auditae_bad_request', 'restore_id is required: the elementor_restore_id an Elementor text edit answered with.', array( 'status' => 400 ) );
    1345         }
    1346         $res = auditae_elementor_undo_restore( $post_id, $restore_id, rest_sanitize_boolean( $request->get_param( 'force' ) ) );
     1393        if ( ! is_string( $restore_id ) || ! preg_match( '/^(eu|cu)_[a-z0-9]{16}$/', $restore_id ) ) {
     1394                return new WP_Error( 'auditae_bad_request', 'restore_id is required: the elementor_restore_id or content_restore_id an edit answered with.', array( 'status' => 400 ) );
     1395        }
     1396        if ( 'trash' === $post->post_status ) {
     1397                return new WP_Error( 'auditae_post_trashed', 'This post is in the trash. Restore it first (POST /posts/' . $post_id . '/untrash).', array( 'status' => 409 ) );
     1398        }
     1399        $res = auditae_undo_restore( $post_id, $restore_id, rest_sanitize_boolean( $request->get_param( 'force' ) ) );
    13471400        if ( is_wp_error( $res ) ) {
    13481401                return $res;
    13491402        }
    1350         auditae_log_action( 'elementor_restore', 'post_id=' . $post_id . ' restore_id=' . $restore_id, array( '_elementor_data' => $res['previous'] ) );
     1403        $elementor = 'elementor' === $res['kind'];
     1404        auditae_log_action(
     1405                $elementor ? 'elementor_restore' : 'content_restore',
     1406                'post_id=' . $post_id . ' restore_id=' . $restore_id . ( '' !== $res['restore_id'] ? ' new_restore_id=' . $res['restore_id'] : '' ),
     1407                $elementor ? array( '_elementor_data' => $res['previous'] ) : array()
     1408        );
    13511409
    13521410        $answer = array(
    1353                 'post_id'        => $post_id,
    1354                 'status'         => get_post_status( $post_id ),
    1355                 'restored'       => $restore_id,
    1356                 'builder'        => 'elementor',
    1357                 'edit_url'       => auditae_rest_edit_url( $post_id ),
    1358                 'previous_state' => array( '_elementor_data' => $res['previous'] ),
    1359         );
     1411                'post_id'  => $post_id,
     1412                'status'   => get_post_status( $post_id ),
     1413                'restored' => $restore_id,
     1414                'builder'  => auditae_rest_builder( get_post( $post_id ) ),
     1415                'edit_url' => auditae_rest_edit_url( $post_id ),
     1416        );
     1417        if ( rest_sanitize_boolean( $request->get_param( 'include_previous_content' ) ) ) {
     1418                $answer['previous_state'] = $elementor ? array( '_elementor_data' => $res['previous'] ) : array( 'content' => $res['previous'] );
     1419        }
    13601420        if ( '' !== $res['restore_id'] ) {
    1361                 $answer['elementor_restore_id'] = $res['restore_id'];
     1421                $answer[ $elementor ? 'elementor_restore_id' : 'content_restore_id' ] = $res['restore_id'];
    13621422        }
    13631423        return rest_ensure_response( $answer );
    … …  
    16651725}
    16661726
     1727/**
     1728 * Whether GET /posts?post_type=any lists this type: content visitors can
     1729 * open, not page-builder template stores. Elementor's elementor_library
     1730 * ("Default Kit", headers, popups) is registered public but isn't viewable
     1731 * by visitors, and its URLs 404 for them. Those types stay readable by
     1732 * name (post_type=elementor_library).
     1733 *
     1734 * @param WP_Post_Type $pt_obj Post type.
     1735 * @return bool
     1736 */
     1737function auditae_rest_post_type_in_any( $pt_obj ) {
     1738        if ( ! $pt_obj || ! $pt_obj->public ) {
     1739                return false;
     1740        }
     1741        $templates = array(
     1742                'elementor_library',
     1743                'e-floating-buttons',
     1744                'elementor_snippet',
     1745                'elementor_font',
     1746                'elementor_icons',
     1747                'jet-theme-core',
     1748                'jet-engine',
     1749                'ae_global_templates',
     1750                'oceanwp_library',
     1751                'astra-advanced-hook',
     1752                'fl-builder-template',
     1753                'et_pb_layout',
     1754                'brizy_template',
     1755                'ct_template',
     1756                'kadence_element',
     1757                'gp_elements',
     1758        );
     1759        /**
     1760         * Filter the post types left out of GET /posts?post_type=any.
     1761         *
     1762         * @param string[] $templates Post type names.
     1763         */
     1764        $templates = (array) apply_filters( 'auditae_rest_any_excluded_types', $templates );
     1765        if ( in_array( $pt_obj->name, $templates, true ) ) {
     1766                return false;
     1767        }
     1768        // A type visitors can't open (not publicly queryable) has no public URL.
     1769        return 'page' === $pt_obj->name || is_post_type_viewable( $pt_obj );
     1770}
     1771
    16671772// Idempotency transient value while the create it guards is still running.
    16681773define( 'AUDITAE_IDEM_PENDING', 'pending' );
    … …  
    18881993                $remember( 'title', $post->post_title );
    18891994        }
     1995        // The old body is no longer echoed back (it made every edit's answer as
     1996        // big as the page): it is kept on the post and restored by id
     1997        // (content_restore_id / elementor_restore_id → POST /posts/{id}/restore).
     1998        // include_previous_content: true brings the 0.37.0 shape back.
     1999        $full_previous = rest_sanitize_boolean( $request->get_param( 'include_previous_content' ) );
     2000        $content_before = (string) $post->post_content;
     2001        // Fields sent whose value is already stored (title, content, excerpt,
     2002        // slug, status), so the app can tell a resend from a change.
     2003        $unchanged = array();
     2004        foreach ( array( 'title' => $post->post_title, 'content' => $post->post_content, 'excerpt' => $post->post_excerpt, 'slug' => $post->post_name, 'status' => $post->post_status ) as $field => $stored ) {
     2005                $sent = $request->get_param( $field );
     2006                if ( is_string( $sent ) && $sent === (string) $stored ) {
     2007                        $unchanged[] = $field;
     2008                }
     2009        }
    18902010        if ( is_string( $content ) ) {
    18912011                $update['post_content'] = $content;
    1892                 $remember( 'content', $post->post_content );
     2012                if ( $full_previous ) {
     2013                        $remember( 'content', $post->post_content );
     2014                }
    18932015        }
    18942016        $replaced        = null;
    … …  
    18962018        if ( is_array( $replacements ) ) {
    18972019                $elementor_before = $is_elementor ? (string) get_post_meta( $post_id, '_elementor_data', true ) : '';
    1898                 if ( $is_elementor ) {
    1899                         $previous['_elementor_data'] = $elementor_before;
    1900                 } else {
    1901                         $remember( 'content', $post->post_content );
     2020                if ( $full_previous ) {
     2021                        if ( $is_elementor ) {
     2022                                $previous['_elementor_data'] = $elementor_before;
     2023                        } else {
     2024                                $remember( 'content', $post->post_content );
     2025                        }
    19022026                }
    19032027                // Elementor pages are saved inside; plain posts hand back the new
    … …  
    19922116        }
    19932117
    1994         auditae_log_action( 'update_post', 'post_id=' . $post_id . ( '' !== $elementor_undo ? ' elementor_restore_id=' . $elementor_undo : '' ), $previous );
     2118        // A changed plain-post body is kept for undo by id.
     2119        $content_undo = '';
     2120        $undo_pairs   = null;
     2121        if ( ! $is_elementor && ( is_string( $content ) || ( is_array( $replacements ) && null !== $replaced && ! $replaced['elementor'] ) ) ) {
     2122                $content_undo = auditae_content_undo_push( $post_id, $content_before );
     2123                if ( is_array( $replacements ) && '' !== $content_undo ) {
     2124                        $undo_pairs = auditae_replacements_undo_pairs( $content_before, (string) get_post_field( 'post_content', $post_id, 'raw' ), $replaced, array_values( $replacements ) );
     2125                }
     2126        }
     2127
     2128        $log_detail = 'post_id=' . $post_id . ( '' !== $elementor_undo ? ' elementor_restore_id=' . $elementor_undo : '' ) . ( '' !== $content_undo ? ' content_restore_id=' . $content_undo : '' );
     2129        auditae_log_action( 'update_post', $log_detail, $previous );
    19952130
    19962131        $answer = array(
    19972132                'post_id'              => $post_id,
    19982133                'status'               => get_post_status( $post_id ),
    1999                 'previous_state'       => $previous,
     2134                // Old values of the small fields changed ({} when only the body
     2135                // changed: its undo is content_restore_id / elementor_restore_id).
     2136                'previous_state'       => empty( $previous ) ? new stdClass() : $previous,
    20002137                'edit_url'             => auditae_rest_edit_url( $post_id ),
    20012138                'builder'              => auditae_rest_builder( get_post( $post_id ) ),
    … …  
    20112148        if ( '' !== $elementor_undo ) {
    20122149                $answer['elementor_restore_id'] = $elementor_undo;
     2150        }
     2151        if ( '' !== $content_undo ) {
     2152                // Undo of the body change: POST /posts/{id}/restore {restore_id}.
     2153                $answer['content_restore_id'] = $content_undo;
     2154        }
     2155        if ( null !== $undo_pairs ) {
     2156                // Or send these as `replacements` (verified to give back the old body).
     2157                $answer['undo_replacements'] = $undo_pairs;
     2158        }
     2159        if ( $unchanged ) {
     2160                $answer['unchanged_fields'] = $unchanged;
    20132161        }
    20142162        return rest_ensure_response( $answer );
    … …  
    22852433        }
    22862434
     2435        // Only the engines that run: an inactive one's block was always empty
     2436        // (or stale leftovers) and read as if it applied.
     2437        $engines  = array_keys( array_filter( array( 'yoast' => $yoast_active, 'rankmath' => $rm_active ) ) );
    22872438        $map      = auditae_post_seo_meta_map();
    22882439        $snapshot = array();
    2289         foreach ( array( 'yoast', 'rankmath' ) as $engine ) {
     2440        foreach ( $engines as $engine ) {
    22902441                $snapshot[ $engine ] = array();
    22912442                foreach ( $map[ $engine ] as $logical => $meta_key ) {
    22922443                        $snapshot[ $engine ][ $logical ] = get_post_meta( $post_id, $meta_key, true );
    22932444                }
     2445                // noindex / nofollow as this engine renders them for the post
     2446                // (its own per-post setting, else the site / post type default).
     2447                $robots                          = auditae_seo_robots_get( $post_id, $engine );
     2448                $snapshot[ $engine ]['noindex']  = $robots['noindex'];
     2449                $snapshot[ $engine ]['nofollow'] = $robots['nofollow'];
    22942450        }
    22952451
    22962452        if ( 'read' === $action ) {
     2453                foreach ( $engines as $engine ) {
     2454                        // The title and description that actually print, templates
     2455                        // resolved (an empty field shows the template's output); null
     2456                        // when the engine's API can't say outside a page view.
     2457                        $snapshot[ $engine ] += auditae_seo_rendered( $post_id, $engine );
     2458                        $snapshot[ $engine ]['robots_source'] = auditae_seo_robots_get( $post_id, $engine )['source'];
     2459                }
    22972460                return rest_ensure_response(
    22982461                        array(
    2299                                 'action'           => 'read',
    2300                                 'post_id'          => $post_id,
    2301                                 'provider'         => auditae_seo_meta_provider(),
    2302                                 'yoast_active'     => $yoast_active,
    2303                                 'rankmath_active'  => $rm_active,
    2304                                 'seo'              => $snapshot,
     2462                                'action'          => 'read',
     2463                                'post_id'         => $post_id,
     2464                                'provider'        => auditae_seo_meta_provider(),
     2465                                'yoast_active'    => $yoast_active,
     2466                                'rankmath_active' => $rm_active,
     2467                                'seo'             => $snapshot,
    23052468                        )
    23062469                );
    … …  
    23092472        // SEO fields are single strings; an array or object would be stored
    23102473        // as-is and break the SEO plugin's output. Checked before any write so
    2311         // a bad field doesn't leave the other engine half-updated.
     2474        // a bad field doesn't leave the other engine half-updated. noindex /
     2475        // nofollow are booleans (null: back to the default).
     2476        $robots_by_engine = array();
    23122477        foreach ( array( 'yoast', 'rankmath' ) as $engine ) {
    23132478                $requested = $request->get_param( $engine );
    … …  
    23202485                        }
    23212486                }
     2487                $robot_keys = array_intersect_key( $requested, array( 'noindex' => 1, 'nofollow' => 1 ) );
     2488                if ( $robot_keys ) {
     2489                        $parsed = auditae_seo_robots_parse( $robot_keys );
     2490                        if ( is_wp_error( $parsed ) ) {
     2491                                return $parsed;
     2492                        }
     2493                        $robots_by_engine[ $engine ] = $parsed;
     2494                }
     2495        }
     2496        // A top-level robots {noindex, nofollow} applies to every active engine.
     2497        if ( null !== $request->get_param( 'robots' ) ) {
     2498                $parsed = auditae_seo_robots_parse( $request->get_param( 'robots' ) );
     2499                if ( is_wp_error( $parsed ) ) {
     2500                        return $parsed;
     2501                }
     2502                foreach ( $engines as $engine ) {
     2503                        $robots_by_engine[ $engine ] = isset( $robots_by_engine[ $engine ] ) ? $robots_by_engine[ $engine ] + $parsed : $parsed;
     2504                }
    23222505        }
    23232506
    23242507        $applied  = array();
    2325         $previous = $snapshot;
    2326 
    2327         foreach ( array( 'yoast', 'rankmath' ) as $engine ) {
    2328                 $engine_active = ( 'yoast' === $engine ) ? $yoast_active : $rm_active;
    2329                 if ( ! $engine_active ) {
    2330                         continue;
    2331                 }
     2508        $previous = array();
     2509
     2510        foreach ( $engines as $engine ) {
    23322511                $requested = $request->get_param( $engine );
    2333                 if ( ! is_array( $requested ) ) {
    2334                         continue;
    2335                 }
    2336                 $applied[ $engine ] = array();
    2337                 foreach ( $map[ $engine ] as $logical => $meta_key ) {
    2338                         if ( ! array_key_exists( $logical, $requested ) ) {
    2339                                 continue;
     2512                $done      = array();
     2513                if ( is_array( $requested ) ) {
     2514                        foreach ( $map[ $engine ] as $logical => $meta_key ) {
     2515                                if ( ! array_key_exists( $logical, $requested ) ) {
     2516                                        continue;
     2517                                }
     2518                                $value = wp_strip_all_tags( (string) $requested[ $logical ] );
     2519                                update_post_meta( $post_id, $meta_key, wp_slash( $value ) );
     2520                                $done[ $logical ] = $value;
    23402521                        }
    2341                         $value = wp_strip_all_tags( (string) $requested[ $logical ] );
    2342                         update_post_meta( $post_id, $meta_key, wp_slash( $value ) );
    2343                         $applied[ $engine ][ $logical ] = $value;
    2344                 }
    2345                 if ( empty( $applied[ $engine ] ) ) {
    2346                         unset( $applied[ $engine ] );
     2522                }
     2523                if ( isset( $robots_by_engine[ $engine ] ) ) {
     2524                        auditae_seo_robots_set( $post_id, $engine, $robots_by_engine[ $engine ] );
     2525                        $after = auditae_seo_robots_get( $post_id, $engine );
     2526                        foreach ( array_keys( $robots_by_engine[ $engine ] ) as $k ) {
     2527                                $done[ $k ] = $after[ $k ];
     2528                        }
     2529                }
     2530                if ( $done ) {
     2531                        $applied[ $engine ] = $done;
     2532                        // Old values of the fields written only (the full snapshot of
     2533                        // both engines was echoed before).
     2534                        $previous[ $engine ] = array_intersect_key( $snapshot[ $engine ], $done );
    23472535                }
    23482536        }
    … …  
    23512539                return new WP_Error(
    23522540                        'auditae_bad_request',
    2353                         'No applicable SEO fields supplied. Send {yoast: {...}} and/or {rankmath: {...}}.',
     2541                        'No applicable SEO fields supplied. Send {' . implode( ': {...}} and/or {', $engines ) . ': {...}} (the active SEO plugin) with title, description, canonical, focus_keyword, noindex and/or nofollow.',
    23542542                        array( 'status' => 400 )
    23552543                );
    … …  
    24212609                                'yoast_active'    => false,
    24222610                                'rankmath_active' => false,
    2423                                 'seo'             => array( 'auditae' => $current ),
     2611                                // noindex / nofollow are among the stored fields; rendered_*
     2612                                // are the title and description that print (0.38.0).
     2613                                'seo'             => array( 'auditae' => $current + auditae_seo_rendered( $post_id, 'auditae' ) ),
    24242614                                'output'          => $output,
    24252615                        )
    … …  
    24352625        }
    24362626        $fields = is_array( $requested ) ? array_intersect_key( $requested, auditae_seo_meta_empty() ) : array();
     2627        // robots: {noindex, nofollow} works on every provider; here it is the
     2628        // same two stored fields (null = off, the default).
     2629        if ( null !== $request->get_param( 'robots' ) ) {
     2630                $robots_in = auditae_seo_robots_parse( $request->get_param( 'robots' ) );
     2631                if ( is_wp_error( $robots_in ) ) {
     2632                        return $robots_in;
     2633                }
     2634                foreach ( $robots_in as $k => $v ) {
     2635                        $fields[ $k ] = (bool) $v;
     2636                }
     2637        }
    24372638        if ( empty( $fields ) ) {
    24382639                return new WP_Error(
    … …  
    25372738                );
    25382739        }
     2740        // if_output: true — refuse before saving when another source already
     2741        // prints FAQPage for this post (AuditAE's pairs would never be output).
     2742        $faq_owner = auditae_faq_foreign_source( $post_id );
     2743        if ( '' !== $faq_owner && rest_sanitize_boolean( $request->get_param( 'if_output' ) ) ) {
     2744                return new WP_Error(
     2745                        'auditae_schema_not_output',
     2746                        sprintf( 'Nothing was saved: %s already prints FAQPage on this post, so AuditAE would store these pairs but never print them. Edit the FAQ there instead.', $faq_owner ),
     2747                        array(
     2748                                'status'     => 409,
     2749                                'handled_by' => array( array( 'type' => 'FAQPage', 'handled_by' => $faq_owner ) ),
     2750                        )
     2751                );
     2752        }
    25392753        $res = auditae_faq_save( $post_id, $qas );
    25402754        if ( 0 === $res['applied'] ) {
    … …  
    26912905                );
    26922906        }
     2907        // if_output: true — refuse before saving when a node's type would be
     2908        // stored but not printed because another plugin prints that type here
     2909        // (an SEO plugin's Article, WooCommerce's Product): a paid write that
     2910        // changes nothing on the page. The answer names who handles each.
     2911        if ( rest_sanitize_boolean( $request->get_param( 'if_output' ) ) ) {
     2912                $list    = isset( $schemas['@type'] ) ? array( $schemas ) : $schemas;
     2913                $handled = array();
     2914                foreach ( (array) $list as $node ) {
     2915                        $type = auditae_schema_primary_type( $node );
     2916                        if ( '' !== $type && ! auditae_should_emit_schema_type( $type, $post_id ) ) {
     2917                                $owner     = auditae_schema_type_owner( $type, $post_id );
     2918                                $handled[] = array(
     2919                                        'type'       => $type,
     2920                                        'handled_by' => '' !== $owner ? $owner : 'another plugin',
     2921                                );
     2922                        }
     2923                }
     2924                if ( $handled ) {
     2925                        return new WP_Error(
     2926                                'auditae_schema_not_output',
     2927                                sprintf(
     2928                                        'Nothing was saved: %s, so AuditAE would store this but never print it. Edit the schema in that plugin instead, or send only the other types.',
     2929                                        implode(
     2930                                                '; ',
     2931                                                array_map(
     2932                                                        static function ( $h ) {
     2933                                                                return $h['handled_by'] . ' prints ' . $h['type'] . ' on this post';
     2934                                                        },
     2935                                                        $handled
     2936                                                )
     2937                                        )
     2938                                ),
     2939                                array(
     2940                                        'status'     => 409,
     2941                                        'handled_by' => $handled,
     2942                                )
     2943                        );
     2944                }
     2945        }
    26932946        $res = auditae_post_schema_save( $post_id, $schemas );
    26942947        if ( ! empty( $res['error'] ) ) {
    … …  
    27963049                );
    27973050        }
     3051        // if_output: true — a write of Organization nodes only is refused before
     3052        // saving when AuditAE would hold them back (an SEO plugin prints the
     3053        // site's publisher, or the theme / another plugin already does).
     3054        if ( rest_sanitize_boolean( $request->get_param( 'if_output' ) ) ) {
     3055                $list  = isset( $schemas['@type'] ) ? array( $schemas ) : $schemas;
     3056                $types = array_unique( array_filter( array_map( 'auditae_schema_primary_type', (array) $list ) ) );
     3057                if ( array( 'Organization' ) === array_values( $types ) ) {
     3058                        $signal = auditae_org_seo_signal();
     3059                        if ( ! in_array( $signal, array( 'none', 'seo_silent' ), true ) ) {
     3060                                $owner = 'other_verified' === $signal ? 'the theme or another plugin' : auditae_seo_plugin_label();
     3061                                return new WP_Error(
     3062                                        'auditae_schema_not_output',
     3063                                        sprintf( 'Nothing was saved: %s already prints the site\'s Organization, so AuditAE would store this one but hold it back. Edit it there, or reuse its @id (wp_probe_schema).', $owner ),
     3064                                        array(
     3065                                                'status'     => 409,
     3066                                                'handled_by' => array( array( 'type' => 'Organization', 'handled_by' => $owner ) ),
     3067                                        )
     3068                                );
     3069                        }
     3070                }
     3071        }
    27983072        $res = auditae_org_schema_save( $schemas );
    27993073        if ( ! empty( $res['error'] ) ) {
    … …  
    28423116        // { @type: [@id, …] } maps: an empty PHP array would encode as [] and
    28433117        // read as a list, not an object.
    2844         foreach ( array( 'found', 'found_foreign' ) as $key ) {
     3118        if ( is_array( $probe ) ) {
     3119                // found_foreign repeated found on every site where AuditAE prints
     3120                // nothing on the homepage. Sent only when it differs; when it's
     3121                // left out it equals found (foreign_same_as_found: true).
     3122                $found   = isset( $probe['found'] ) && is_array( $probe['found'] ) ? $probe['found'] : array();
     3123                $foreign = isset( $probe['found_foreign'] ) && is_array( $probe['found_foreign'] ) ? $probe['found_foreign'] : null;
     3124                if ( null !== $foreign && wp_json_encode( $foreign ) === wp_json_encode( $found ) ) {
     3125                        unset( $probe['found_foreign'] );
     3126                        $probe['foreign_same_as_found'] = true;
     3127                } elseif ( null !== $foreign ) {
     3128                        $probe['foreign_same_as_found'] = false;
     3129                }
     3130                // Which schema types another plugin prints instead of AuditAE (so
     3131                // an AuditAE node of that type is stored but not output), by type:
     3132                // { "Article": "Yoast SEO", … }; {} when AuditAE prints them all.
     3133                // Per-post owners (WooCommerce Product, an SEO plugin's HowTo
     3134                // block) are answered by /posts/{id}/schema's not_output.
     3135                $probe['handled_by'] = auditae_rest_schema_handled_by();
     3136        }
     3137        // { @type: [@id, …] } maps: an empty PHP array would encode as [] and
     3138        // read as a list, not an object.
     3139        foreach ( array( 'found', 'found_foreign', 'handled_by' ) as $key ) {
    28453140                if ( is_array( $probe ) && array_key_exists( $key, $probe ) && is_array( $probe[ $key ] ) && empty( $probe[ $key ] ) ) {
    28463141                        $probe[ $key ] = new stdClass();
    … …  
    28483143        }
    28493144        return rest_ensure_response( $probe );
     3145}
     3146
     3147/**
     3148 * Site-wide: schema type => the plugin that prints it instead of AuditAE.
     3149 *
     3150 * @return array<string,string>
     3151 */
     3152function auditae_rest_schema_handled_by() {
     3153        $out = array();
     3154        foreach ( array_unique( array_merge( auditae_schema_seo_owned_types(), auditae_schema_supported_types() ) ) as $type ) {
     3155                if ( ! is_string( $type ) || auditae_should_emit_schema_type( $type, 0 ) ) {
     3156                        continue;
     3157                }
     3158                $owner        = auditae_schema_type_owner( $type, 0 );
     3159                $out[ $type ] = '' !== $owner ? $owner : 'another plugin (auditae_should_emit_schema_type filter)';
     3160        }
     3161        return $out;
    28503162}
    28513163
    … …  
    28843196                );
    28853197                return rest_ensure_response(
    2886                         array(
    2887                                 'action'         => 'reset',
    2888                                 'previous_state' => array( 'config' => $res['previous'] ),
    2889                                 'config'         => $res['applied'],
    2890                                 'rendered'       => auditae_llms_txt_render(),
     3198                        auditae_rest_llms_txt_write_answer(
     3199                                $request,
     3200                                array(
     3201                                        'action'         => 'reset',
     3202                                        'previous_state' => array( 'config' => $res['previous'] ),
     3203                                        'config'         => $res['applied'],
     3204                                )
    28913205                        )
    28923206                );
    … …  
    29203234        );
    29213235        return rest_ensure_response(
    2922                 array(
    2923                         'action'         => 'write',
    2924                         'mode'           => 'replace' === $mode ? 'replace' : 'merge',
    2925                         'previous_state' => array( 'config' => $res['previous'] ),
    2926                         'config'         => $res['applied'],
    2927                         'rendered'       => auditae_llms_txt_render(),
    2928                 )
    2929         );
     3236                auditae_rest_llms_txt_write_answer(
     3237                        $request,
     3238                        array(
     3239                                'action'         => 'write',
     3240                                'mode'           => 'replace' === $mode ? 'replace' : 'merge',
     3241                                'previous_state' => array( 'config' => $res['previous'] ),
     3242                                'config'         => $res['applied'],
     3243                        )
     3244                )
     3245        );
     3246}
     3247
     3248/**
     3249 * A write/reset answer without the whole rendered file (it is served at
     3250 * `url`, and GET /llms-txt returns it): its size and line count instead,
     3251 * so the answer stays small. include_rendered: true adds it back.
     3252 *
     3253 * @param WP_REST_Request $request Request.
     3254 * @param array           $answer  Answer so far.
     3255 * @return array
     3256 */
     3257function auditae_rest_llms_txt_write_answer( WP_REST_Request $request, array $answer ) {
     3258        $rendered          = (string) auditae_llms_txt_render();
     3259        $answer['url']     = home_url( '/llms.txt' );
     3260        $answer['rendered_bytes'] = strlen( $rendered );
     3261        $answer['rendered_lines'] = '' === $rendered ? 0 : substr_count( $rendered, "\n" ) + 1;
     3262        if ( rest_sanitize_boolean( $request->get_param( 'include_rendered' ) ) ) {
     3263                $answer['rendered'] = $rendered;
     3264        }
     3265        return $answer;
    29303266}
    29313267
    … …  
    30103346                                break;
    30113347                        }
     3348                }
     3349
     3350                // A rule only runs when WordPress would answer 404, so one for a page
     3351                // that is live never fires: the write succeeded (and was billed)
     3352                // while the page kept answering 200. Refuse before saving, unless
     3353                // force (then it's saved, inactive until the page is gone).
     3354                $force = rest_sanitize_boolean( $request->get_param( 'force' ) );
     3355                $live  = auditae_redirects_source_live( $source_norm );
     3356                if ( null !== $live && ! $force ) {
     3357                        $what = 'front_page' === $live['kind'] ? 'the front page'
     3358                                : ( 'archive' === $live['kind'] ? 'a live archive (category, tag, author or post type)'
     3359                                : ( 'file' === $live['kind'] ? 'a file on disk'
     3360                                : sprintf( 'a published %1$s ("%2$s", id %3$d)', $live['kind'], $live['title'], $live['post_id'] ) ) );
     3361                        return new WP_Error(
     3362                                'auditae_redirect_source_live',
     3363                                sprintf(
     3364                                        'Nothing was saved: %1$s is %2$s. AuditAE redirects only fire when WordPress would answer 404 for the path, so this one would never run and the page would keep loading. To move a post or page, change its slug (wp_update_post slug: WordPress then sends the old address to the new one itself), or trash it first (wp_trash_post) and then add the redirect. force: true saves the rule anyway, inactive until the page is gone.',
     3365                                        $source_norm,
     3366                                        $what
     3367                                ),
     3368                                array(
     3369                                        'status'      => 409,
     3370                                        'source_kind' => $live['kind'],
     3371                                        'post_id'     => $live['post_id'],
     3372                                        'view_url'    => $live['view_url'],
     3373                                )
     3374                        );
    30123375                }
    30133376
    … …  
    30373400                );
    30383401
    3039                 return rest_ensure_response(
    3040                         array(
    3041                                 'action'         => 'write',
    3042                                 'rule'           => $applied,
    3043                                 'previous_state' => $previous, // null if this was a new rule
    3044                         )
    3045                 );
     3402                $answer = array(
     3403                        'action'         => 'write',
     3404                        'rule'           => $applied,
     3405                        'previous_state' => $previous, // null if this was a new rule
     3406                        // False when the source still serves a live page (saved with
     3407                        // force): the rule runs only once that page answers 404.
     3408                        'active'         => null === $live,
     3409                );
     3410                if ( null !== $live ) {
     3411                        $answer['warning'] = 'Saved inactive: ' . $source_norm . ' still serves a live ' . str_replace( '_', ' ', $live['kind'] ) . ', and redirects only fire on a 404. It starts working once that page is trashed or its slug changes.';
     3412                }
     3413                return rest_ensure_response( $answer );
    30463414        }
    30473415
  • auditae-ai-search-toolkit/tags/0.38.0/includes/seo-meta.php

    r3775803 r3777340  
    317317        return 1 === preg_match( $pattern, (string) $html );
    318318}
     319
     320// ────────────────────────────────────────────────────────────────────
     321// Per-post robots (noindex / nofollow) and the title / description that
     322// actually render, for every provider (0.38.0)
     323// ────────────────────────────────────────────────────────────────────
     324
     325/**
     326 * Rank Math's titles options (robots defaults, title templates).
     327 *
     328 * @return array
     329 */
     330function auditae_seo_rankmath_titles() {
     331        $opt = get_option( 'rank-math-options-titles', array() );
     332        return is_array( $opt ) ? $opt : array();
     333}
     334
     335/**
     336 * A post's robots as the provider stores them: {noindex, nofollow, source}.
     337 * source is 'post' when the post sets them itself, 'default' when it
     338 * follows the site / post type default (whose effective value is given).
     339 *
     340 * @param int    $post_id  Post ID.
     341 * @param string $provider yoast | rankmath | auditae.
     342 * @return array{noindex:bool,nofollow:bool,source:string}
     343 */
     344function auditae_seo_robots_get( $post_id, $provider ) {
     345        $post_id = (int) $post_id;
     346        $type    = (string) get_post_type( $post_id );
     347        if ( 'yoast' === $provider ) {
     348                $ni  = (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true );
     349                $nf  = (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', true );
     350                $def = false;
     351                if ( class_exists( 'WPSEO_Options' ) && method_exists( 'WPSEO_Options', 'get' ) ) {
     352                        $def = (bool) WPSEO_Options::get( 'noindex-' . $type, false );
     353                }
     354                return array(
     355                        'noindex'  => '1' === $ni || ( '2' !== $ni && $def ),
     356                        'nofollow' => '1' === $nf,
     357                        'source'   => ( in_array( $ni, array( '1', '2' ), true ) || '1' === $nf ) ? 'post' : 'default',
     358                );
     359        }
     360        if ( 'rankmath' === $provider ) {
     361                $own = get_post_meta( $post_id, 'rank_math_robots', true );
     362                $own = is_array( $own ) ? array_values( array_filter( array_map( 'strval', $own ) ) ) : array();
     363                if ( ! empty( $own ) ) {
     364                        $robots = $own;
     365                        $source = 'post';
     366                } else {
     367                        $titles = auditae_seo_rankmath_titles();
     368                        $custom = isset( $titles[ 'pt_' . $type . '_custom_robots' ] ) && 'on' === $titles[ 'pt_' . $type . '_custom_robots' ] && isset( $titles[ 'pt_' . $type . '_robots' ] );
     369                        $robots = $custom ? (array) $titles[ 'pt_' . $type . '_robots' ] : ( isset( $titles['robots_global'] ) ? (array) $titles['robots_global'] : array() );
     370                        $source = 'default';
     371                }
     372                return array(
     373                        'noindex'  => in_array( 'noindex', $robots, true ),
     374                        'nofollow' => in_array( 'nofollow', $robots, true ),
     375                        'source'   => $source,
     376                );
     377        }
     378        $f = auditae_seo_meta_get( $post_id );
     379        return array(
     380                'noindex'  => (bool) $f['noindex'],
     381                'nofollow' => (bool) $f['nofollow'],
     382                'source'   => ( $f['noindex'] || $f['nofollow'] ) ? 'post' : 'default',
     383        );
     384}
     385
     386/**
     387 * Validate a robots write: {noindex?, nofollow?} booleans (null = back to
     388 * the default).
     389 *
     390 * @param mixed $in Request value.
     391 * @return array|WP_Error Keys present only for what was sent.
     392 */
     393function auditae_seo_robots_parse( $in ) {
     394        if ( ! is_array( $in ) ) {
     395                return new WP_Error( 'auditae_bad_request', '`robots` must be an object {noindex?: boolean, nofollow?: boolean}.', array( 'status' => 400 ) );
     396        }
     397        $out = array();
     398        foreach ( array( 'noindex', 'nofollow' ) as $k ) {
     399                if ( ! array_key_exists( $k, $in ) ) {
     400                        continue;
     401                }
     402                $v = $in[ $k ];
     403                if ( null !== $v && ! is_bool( $v ) && ! is_int( $v ) && ! is_string( $v ) ) {
     404                        return new WP_Error( 'auditae_bad_request', sprintf( 'robots.%s must be true, false or null.', $k ), array( 'status' => 400 ) );
     405                }
     406                $out[ $k ] = null === $v ? null : rest_sanitize_boolean( $v );
     407        }
     408        if ( empty( $out ) ) {
     409                return new WP_Error( 'auditae_bad_request', '`robots` needs noindex and/or nofollow.', array( 'status' => 400 ) );
     410        }
     411        return $out;
     412}
     413
     414/**
     415 * Write robots into Yoast's or Rank Math's own per-post fields (the ones
     416 * their Advanced tab edits). false writes an explicit "index" / removes
     417 * "nofollow"; null puts the post back on the site default.
     418 *
     419 * @param int    $post_id  Post ID.
     420 * @param string $provider yoast | rankmath.
     421 * @param array  $robots   auditae_seo_robots_parse() result.
     422 */
     423function auditae_seo_robots_set( $post_id, $provider, array $robots ) {
     424        $post_id = (int) $post_id;
     425        if ( 'yoast' === $provider ) {
     426                if ( array_key_exists( 'noindex', $robots ) ) {
     427                        if ( null === $robots['noindex'] ) {
     428                                delete_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex' );
     429                        } else {
     430                                update_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', $robots['noindex'] ? '1' : '2' );
     431                        }
     432                }
     433                if ( array_key_exists( 'nofollow', $robots ) ) {
     434                        if ( $robots['nofollow'] ) {
     435                                update_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', '1' );
     436                        } else {
     437                                delete_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow' );
     438                        }
     439                }
     440                return;
     441        }
     442        if ( 'rankmath' === $provider ) {
     443                $cur = auditae_seo_robots_get( $post_id, 'rankmath' );
     444                $own = get_post_meta( $post_id, 'rank_math_robots', true );
     445                $own = is_array( $own ) ? array_values( array_filter( array_map( 'strval', $own ) ) ) : array();
     446                // Rank Math reads the per-post list whole once it's set, so start
     447                // from what the post renders now and change only what was sent.
     448                $noindex  = array_key_exists( 'noindex', $robots ) ? $robots['noindex'] : $cur['noindex'];
     449                $nofollow = array_key_exists( 'nofollow', $robots ) ? $robots['nofollow'] : $cur['nofollow'];
     450                if ( null === $noindex && null === $nofollow ) {
     451                        delete_post_meta( $post_id, 'rank_math_robots' );
     452                        return;
     453                }
     454                // Other directives the post set (noarchive, nosnippet…) are kept.
     455                $list   = array_values( array_diff( $own, array( 'index', 'noindex', 'nofollow' ) ) );
     456                $list[] = $noindex ? 'noindex' : 'index';
     457                if ( $nofollow ) {
     458                        $list[] = 'nofollow';
     459                }
     460                update_post_meta( $post_id, 'rank_math_robots', array_values( array_unique( $list ) ) );
     461        }
     462}
     463
     464/**
     465 * The title tag and meta description the provider renders for a post,
     466 * templates resolved (a post with empty fields shows its template's
     467 * output). '' for a description nothing prints. Null when it can't be
     468 * worked out outside a page view (the provider's API is unavailable).
     469 *
     470 * @param int    $post_id  Post ID.
     471 * @param string $provider yoast | rankmath | auditae.
     472 * @return array{rendered_title:?string,rendered_description:?string}
     473 */
     474function auditae_seo_rendered( $post_id, $provider ) {
     475        $post_id = (int) $post_id;
     476        $post    = get_post( $post_id );
     477        $out     = array(
     478                'rendered_title'       => null,
     479                'rendered_description' => null,
     480        );
     481        if ( ! $post ) {
     482                return $out;
     483        }
     484        $clean = static function ( $s ) {
     485                return trim( html_entity_decode( wp_strip_all_tags( (string) $s ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
     486        };
     487        try {
     488                if ( 'yoast' === $provider ) {
     489                        if ( function_exists( 'YoastSEO' ) ) {
     490                                $meta = YoastSEO()->meta->for_post( $post_id );
     491                                if ( $meta ) {
     492                                        $out['rendered_title']       = $clean( $meta->title );
     493                                        $out['rendered_description'] = $clean( $meta->description );
     494                                }
     495                        }
     496                        return $out;
     497                }
     498                if ( 'rankmath' === $provider ) {
     499                        if ( class_exists( '\RankMath\Helper' ) && method_exists( '\RankMath\Helper', 'replace_vars' ) ) {
     500                                // Rank Math registers its %vars% on the front end's `wp`
     501                                // action, which a REST request never reaches; its setup()
     502                                // runs once and is public.
     503                                $rm = function_exists( 'rank_math' ) ? rank_math() : null;
     504                                if ( ! is_object( $rm ) || ! isset( $rm->variables ) || ! is_object( $rm->variables ) ) {
     505                                        // Rank Math before its account step (connect or skip)
     506                                        // loads none of its front end and prints no title or
     507                                        // description: WordPress's own title is what shows.
     508                                        return array(
     509                                                'rendered_title'       => auditae_seo_wp_document_title( $post ),
     510                                                'rendered_description' => '',
     511                                        );
     512                                }
     513                                if ( method_exists( $rm->variables, 'setup' ) ) {
     514                                        $rm->variables->setup();
     515                                }
     516                                $titles = auditae_seo_rankmath_titles();
     517                                $type   = $post->post_type;
     518                                $title  = (string) get_post_meta( $post_id, 'rank_math_title', true );
     519                                if ( '' === $title ) {
     520                                        $title = isset( $titles[ 'pt_' . $type . '_title' ] ) ? (string) $titles[ 'pt_' . $type . '_title' ] : '%title% %sep% %sitename%';
     521                                }
     522                                $desc = (string) get_post_meta( $post_id, 'rank_math_description', true );
     523                                if ( '' === $desc ) {
     524                                        $desc = isset( $titles[ 'pt_' . $type . '_description' ] ) ? (string) $titles[ 'pt_' . $type . '_description' ] : '%excerpt%';
     525                                }
     526                                $out['rendered_title']       = $clean( \RankMath\Helper::replace_vars( $title, $post ) );
     527                                $out['rendered_description'] = $clean( \RankMath\Helper::replace_vars( $desc, $post ) );
     528                        }
     529                        return $out;
     530                }
     531        } catch ( \Throwable $e ) {
     532                return $out;
     533        }
     534        if ( 'auditae' !== $provider ) {
     535                return $out;
     536        }
     537        // No SEO plugin: AuditAE's stored title, else WordPress's own document
     538        // title (title-tag themes); the description only when one is stored
     539        // (nothing else prints one).
     540        $f = auditae_seo_meta_get( $post_id );
     541        $out['rendered_title']       = '' !== $f['title'] ? $f['title'] : auditae_seo_wp_document_title( $post );
     542        $out['rendered_description'] = $f['description'];
     543        return $out;
     544}
     545
     546/**
     547 * The <title> WordPress itself prints for a post (themes with title-tag
     548 * support and block themes), or null when the theme prints its own.
     549 *
     550 * @param WP_Post $post Post.
     551 * @return string|null
     552 */
     553function auditae_seo_wp_document_title( $post ) {
     554        if ( ! current_theme_supports( 'title-tag' ) && ! ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) ) {
     555                return null;
     556        }
     557        $sep   = apply_filters( 'document_title_separator', '-' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's filter.
     558        $front = 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === (int) $post->ID;
     559        $parts = $front
     560                ? array( get_bloginfo( 'name', 'display' ), get_bloginfo( 'description', 'display' ) )
     561                : array( get_the_title( $post ), get_bloginfo( 'name', 'display' ) );
     562        return trim( html_entity_decode( wp_strip_all_tags( implode( ' ' . $sep . ' ', array_filter( $parts ) ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
     563}
  • auditae-ai-search-toolkit/tags/0.38.0/languages/auditae-ai-search-toolkit.pot

    r3775803 r3777340  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: AuditAE – MCP Server & AI SEO 0.37.0\n"
     5"Project-Id-Version: AuditAE – MCP Server & AI SEO 0.38.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/auditae-ai-search-toolkit\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-10-30T14:57:49+00:00\n"
     12"POT-Creation-Date: 2026-10-31T19:49:26+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    19581958msgstr[1] ""
    19591959
    1960 #: includes/link-index.php:850
     1960#: includes/link-index.php:988
    19611961msgid "Post not found."
    19621962msgstr ""
    19631963
    1964 #: includes/link-index.php:856
     1964#: includes/link-index.php:994
    19651965msgid "Anchor text and target URL are both required."
    19661966msgstr ""
    19671967
    1968 #: includes/link-index.php:862
     1968#: includes/link-index.php:1000
    19691969msgid "Internal links only — the target must be on this site."
    19701970msgstr ""
    19711971
    1972 #: includes/link-index.php:867
     1972#: includes/link-index.php:1005
    19731973msgid "That phrase is no longer available as plain text in the post — it may have been edited or already linked."
    19741974msgstr ""
    … …  
    22472247msgstr ""
    22482248
    2249 #: includes/media.php:1040
    2250 #: includes/media.php:1095
     2249#: includes/media.php:1242
     2250#: includes/media.php:1297
    22512251msgid "Rename file"
    22522252msgstr ""
    22532253
    2254 #: includes/media.php:1056
     2254#: includes/media.php:1258
    22552255msgid "You don't have permission to rename this file."
    22562256msgstr ""
    22572257
    2258 #: includes/media.php:1093
     2258#: includes/media.php:1295
    22592259msgid "New file name (without extension). Renames every size and updates references in your posts:"
    22602260msgstr ""
    22612261
    2262 #: includes/media.php:1094
     2262#: includes/media.php:1296
    22632263msgid "Renaming…"
    22642264msgstr ""
    22652265
    2266 #: includes/media.php:1096
     2266#: includes/media.php:1298
    22672267msgid "Rename failed."
    22682268msgstr ""
  • auditae-ai-search-toolkit/tags/0.38.0/readme.txt

    r3775803 r3777340  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.37.0
     7Stable tag: 0.38.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    129129characters, so older entries are kept in the plugin's development history
    130130rather than here, where they would push the current release out of view.
     131
     132= 0.38.0 =
     133* Internal links: a link to a page that doesn't exist, or to a draft, is refused before anything is written. Pages linked from your navigation menu no longer show up as orphans, and link-graph titles read as plain text.
     134* Redirects: a redirect whose source is a live page (which would never fire, since redirects run only on a 404) is refused with a clear explanation.
     135* Undo: every AI text edit, inserted link and link fix keeps a restore point on the site, so it can be undone in one step, Elementor pages included. Edit answers are much smaller.
     136* New for paired agents: list and fix broken internal links, set alt text on up to 50 images at once (with undo), and read or set a post's noindex / nofollow through Yoast, Rank Math or AuditAE. Reading a post's SEO also shows the title and description that actually print.
     137* Schema: an agent can ask that a schema type another plugin prints (Yoast's Article, WooCommerce's Product) be refused rather than saved unseen.
     138* Renaming an image reports revisions separately from posts, and "all content types" listings skip page-builder templates.
    131139
    132140= 0.37.0 =
    … …  
    154162* New, for paired sites: AuditAE can compress Media Library images in place. Each JPEG, PNG or WebP keeps its file name and URL, its thumbnail sizes are compressed too, and the originals are kept in uploads/auditae-originals/: Restore original in the Media Library puts them back. It won't replace files while another image optimizer, image CDN or WebP copy is in place (Smush, ShortPixel, EWWW, Imagify, Jetpack's image CDN…) unless you confirm, and it skips protected folders such as WooCommerce downloads.
    155163
    156 = 0.34.0 =
    157 * New: a free Schema box in the post editor adds Article, Blog post, How-to or Product schema to a post, with a JSON-LD preview. No account needed.
    158 * Elementor 4: AuditAE can read and edit the text of atomic Heading, Paragraph and Button widgets.
    159 * Renaming an image also updates Elementor page settings, block widgets, header images and the social images Yoast, Rank Math and SEOPress store.
    160 * Coexistence: no duplicate FAQ schema next to Elementor's FAQ Schema switch or Rank Math's Schema Builder, Product schema is left to WooCommerce on product pages, and an Organization your theme or another plugin already prints is respected.
    161 * Faster: IndexNow URLs are queued and sent in batches (a post unpublished or set to noindex before its turn is skipped), the crawler log prunes once a day, and page views and 404s skip database reads for Organization schema and redirects.
    162 * The Links tab and the Dashboard widget no longer make the page wait: the link graph rebuilds and the scorecard refreshes in the background.
    163 * Connect tab: a free sign-up link with your site address filled in, a warning to paste the pairing code only at auditae.app, and how to use the site from Claude or ChatGPT over MCP. A pairing AuditAE no longer accepts asks you to re-pair.
    164 * Accessibility: higher-contrast colours, visible keyboard focus and screen-reader text on badges, new-tab links and repeated links.
    165 * A one-time review request on the AI crawlers tab after the first AI crawler visit.
    166 * Redirects tab: the Suggested redirects table fits its card, so the destination field and Save button are always visible.
    167 
    168164== Upgrade Notice ==
     165
     166= 0.38.0 =
     167Safer AI links and redirects (broken targets and live sources refused), one-step undo for AI edits, menu-aware orphan report, and new bulk alt text, broken-link and robots tools for paired agents. No re-pair required.
    169168
    170169= 0.37.0 =
  • auditae-ai-search-toolkit/tags/0.38.0/uninstall.php

    r3775803 r3777340  
    7373        'auditae_redirect_sources',
    7474        'auditae_org_fill_gap',
     75        // 0.38.0: bulk alt-text undo tokens.
     76        'auditae_alt_undo',
    7577);
    7678
    … …  
    145147        $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_seo' ) );
    146148        $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_elementor_undo' ) );
     149        $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_content_undo' ) );
    147150        // Not deleted: _auditae_media_backup and uploads/auditae-originals/
    148151        // (0.35.0). They hold the only copy of the images as they were before
  • auditae-ai-search-toolkit/trunk/auditae.php

    r3775803 r3777340  
    44 * Plugin URI:        https://auditae.app/wordpress-ai-plugin
    55 * Description:       WordPress MCP server for Claude & ChatGPT: let AI agents draft, edit and publish. Free AI search readiness: llms.txt, AI crawler log, score, schema.
    6  * Version:           0.37.0
     6 * Version:           0.38.0
    77 * Requires at least: 6.0
    88 * Requires PHP:      7.4
    … …  
    1818defined( 'ABSPATH' ) || exit;
    1919
    20 define( 'AUDITAE_VERSION', '0.37.0' );
     20define( 'AUDITAE_VERSION', '0.38.0' );
    2121// REST contract version the app can check before relying on newer fields.
    2222// Bump when a route gains or changes a field the app depends on.
    … …  
    3333// seo_provider; POST /posts/{id}/untrash and /elementor-restore; GET /posts
    3434// status=trash; builder gutenberg|classic|aeo_template|empty; /status
    35 // features).
    36 define( 'AUDITAE_API_VERSION', 6 );
     35// features); 7 = 0.38.0 (body edits keep their undo on the site:
     36// content_restore_id + POST /posts/{id}/restore, no old body in answers;
     37// internal-link target check + undo; redirect live-source refusal; menu
     38// links in the graph; GET /broken-links + POST /posts/{id}/fix-link; POST
     39// /media/alt-text; /posts/{id}/seo robots + rendered_*; schema if_output;
     40// see auditae_rest_features()).
     41define( 'AUDITAE_API_VERSION', 7 );
    3742define( 'AUDITAE_PLUGIN_FILE', __FILE__ );
    3843define( 'AUDITAE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
    … …  
    110115// dead end), so it loads after redirects.php.
    111116require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links.php';
     117require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links-rest.php';
    112118// Fan-out reads the link index (anchor phrases, stopwords) and the readiness
    113119// helpers (builder detection), so it loads after both.
  • auditae-ai-search-toolkit/trunk/includes/elementor.php

    r3775803 r3777340  
    11051105}
    11061106
     1107// The post body an AuditAE edit replaced on a non-Elementor post (a full
     1108// `content` write, `replacements`, an inserted internal link, a broken-link
     1109// fix), kept on the post so the edit can be undone by id
     1110// (POST /posts/{id}/restore) instead of echoing the whole old body back in
     1111// every answer. Same limits and same changed-since check as the Elementor
     1112// store above.
     1113define( 'AUDITAE_CONTENT_UNDO_KEY', '_auditae_content_undo' );
     1114
     1115/**
     1116 * Keep the body an edit just replaced. Call after the new body is saved.
     1117 * Returns the restore id ('cu_…'), or '' when the body is too big to keep
     1118 * or nothing changed.
     1119 *
     1120 * @param int    $post_id Post ID.
     1121 * @param string $before  post_content before the edit.
     1122 * @return string
     1123 */
     1124function auditae_content_undo_push( $post_id, $before ) {
     1125        $post_id = (int) $post_id;
     1126        $before  = (string) $before;
     1127        $after   = (string) get_post_field( 'post_content', $post_id, 'raw' );
     1128        if ( strlen( $before ) > AUDITAE_ELEMENTOR_UNDO_MAX_BYTES || $before === $after ) {
     1129                return '';
     1130        }
     1131        $list   = get_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, true );
     1132        $list   = is_array( $list ) ? array_values( $list ) : array();
     1133        $id     = 'cu_' . strtolower( wp_generate_password( 16, false, false ) );
     1134        $list[] = array(
     1135                'id'        => $id,
     1136                'at'        => time(),
     1137                'before'    => $before,
     1138                'after_sha' => sha1( $after ),
     1139        );
     1140        $list = array_slice( $list, -AUDITAE_ELEMENTOR_UNDO_MAX );
     1141        update_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, wp_slash( $list ) );
     1142        return $id;
     1143}
     1144
     1145/**
     1146 * Put back the body a kept edit replaced. The body was already stored on
     1147 * this site, so it is saved with the post kses filter lifted (as user 0 it
     1148 * would strip the embeds the edit never touched).
     1149 *
     1150 * @param int    $post_id    Post ID.
     1151 * @param string $restore_id Id from auditae_content_undo_push().
     1152 * @param bool   $force      Restore even if the post changed since.
     1153 * @return array{previous:string,restore_id:string}|WP_Error
     1154 */
     1155function auditae_content_undo_restore( $post_id, $restore_id, $force = false ) {
     1156        $post_id = (int) $post_id;
     1157        $list    = get_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, true );
     1158        $list    = is_array( $list ) ? array_values( $list ) : array();
     1159        $index   = null;
     1160        foreach ( $list as $i => $entry ) {
     1161                if ( is_array( $entry ) && isset( $entry['id'] ) && hash_equals( (string) $entry['id'], (string) $restore_id ) ) {
     1162                        $index = $i;
     1163                }
     1164        }
     1165        if ( null === $index ) {
     1166                return new WP_Error( 'auditae_restore_not_found', 'No kept AuditAE edit with that restore id on this post (only the last ' . AUDITAE_ELEMENTOR_UNDO_MAX . ' are kept). Older versions are in the post\'s Revisions in wp-admin.', array( 'status' => 404 ) );
     1167        }
     1168        $entry   = $list[ $index ];
     1169        $current = (string) get_post_field( 'post_content', $post_id, 'raw' );
     1170        if ( ! $force && sha1( $current ) !== (string) $entry['after_sha'] ) {
     1171                return new WP_Error( 'auditae_changed_since', 'This post was changed after that edit (in wp-admin or by another edit), so restoring it would undo those changes too. Nothing was changed. Pass force: true to restore it anyway.', array( 'status' => 409 ) );
     1172        }
     1173        $prio = has_filter( 'content_save_pre', 'wp_filter_post_kses' );
     1174        if ( false !== $prio ) {
     1175                remove_filter( 'content_save_pre', 'wp_filter_post_kses', $prio );
     1176        }
     1177        try {
     1178                $res = wp_update_post(
     1179                        array(
     1180                                'ID'           => $post_id,
     1181                                'post_content' => wp_slash( (string) $entry['before'] ),
     1182                        ),
     1183                        true
     1184                );
     1185        } finally {
     1186                if ( false !== $prio ) {
     1187                        add_filter( 'content_save_pre', 'wp_filter_post_kses', $prio );
     1188                }
     1189        }
     1190        if ( is_wp_error( $res ) ) {
     1191                return new WP_Error( 'auditae_update_failed', $res->get_error_message(), array( 'status' => 500 ) );
     1192        }
     1193        $list = array_slice( $list, 0, $index );
     1194        if ( empty( $list ) ) {
     1195                delete_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY );
     1196        } else {
     1197                update_post_meta( $post_id, AUDITAE_CONTENT_UNDO_KEY, wp_slash( $list ) );
     1198        }
     1199        if ( function_exists( 'auditae_links_invalidate' ) ) {
     1200                auditae_links_invalidate();
     1201        }
     1202        return array(
     1203                'previous'   => $current,
     1204                'restore_id' => auditae_content_undo_push( $post_id, $current ),
     1205        );
     1206}
     1207
     1208/**
     1209 * Undo pairs for a plain-post `replacements` edit: each replacement turned
     1210 * back into its find, in reverse order. Only returned when replaying them
     1211 * on the new body gives back the old body exactly (a replacement whose new
     1212 * text already appeared elsewhere, or one that also changed block
     1213 * attributes, would not); otherwise the restore id is the undo.
     1214 *
     1215 * @param string $before   Body before the edit.
     1216 * @param string $after    Body after the edit.
     1217 * @param array  $replaced auditae_apply_replacements() result.
     1218 * @param array  $sent     The request's replacements.
     1219 * @return array|null
     1220 */
     1221function auditae_replacements_undo_pairs( $before, $after, array $replaced, array $sent ) {
     1222        if ( empty( $replaced['matches'] ) || ! is_array( $replaced['matches'] ) ) {
     1223                return null;
     1224        }
     1225        $undo = array();
     1226        $text = (string) $after;
     1227        foreach ( array_reverse( array_keys( $sent ) ) as $i ) {
     1228                $r = $sent[ $i ];
     1229                if ( ! is_array( $r ) || ! isset( $r['find'], $r['replace'] ) || '' === (string) $r['replace'] ) {
     1230                        return null;
     1231                }
     1232                $stored = wp_kses_post( (string) $r['replace'] );
     1233                $n      = isset( $replaced['counts'][ $i ] ) ? (int) $replaced['counts'][ $i ] : 0;
     1234                if ( $n < 1 || substr_count( $text, $stored ) !== $n ) {
     1235                        return null;
     1236                }
     1237                $text   = str_replace( $stored, (string) $r['find'], $text );
     1238                $pair   = array(
     1239                        'find'    => $stored,
     1240                        'replace' => (string) $r['find'],
     1241                );
     1242                if ( $n > 1 ) {
     1243                        $pair['expected_count'] = $n;
     1244                }
     1245                $undo[] = $pair;
     1246        }
     1247        return $text === (string) $before ? $undo : null;
     1248}
     1249
     1250/**
     1251 * Restore by id: 'eu_…' (Elementor tree) or 'cu_…' (post body).
     1252 *
     1253 * @param int    $post_id    Post ID.
     1254 * @param string $restore_id Restore id.
     1255 * @param bool   $force      Restore even if the post changed since.
     1256 * @return array{previous:string,restore_id:string,kind:string}|WP_Error
     1257 */
     1258function auditae_undo_restore( $post_id, $restore_id, $force = false ) {
     1259        $restore_id = (string) $restore_id;
     1260        if ( 0 === strpos( $restore_id, 'eu_' ) ) {
     1261                if ( ! auditae_elementor_is_built( $post_id ) ) {
     1262                        return new WP_Error( 'auditae_bad_request', 'That restore id is for an Elementor edit, and this post is no longer built with Elementor.', array( 'status' => 400 ) );
     1263                }
     1264                $res = auditae_elementor_undo_restore( $post_id, $restore_id, $force );
     1265                $kind = 'elementor';
     1266        } else {
     1267                if ( auditae_elementor_is_built( $post_id ) ) {
     1268                        return new WP_Error( 'auditae_bad_request', 'That restore id is for a post-body edit, and this post is now built with Elementor (its body is a mirror). Nothing was changed.', array( 'status' => 400 ) );
     1269                }
     1270                $res  = auditae_content_undo_restore( $post_id, $restore_id, $force );
     1271                $kind = 'content';
     1272        }
     1273        if ( is_wp_error( $res ) ) {
     1274                return $res;
     1275        }
     1276        $res['kind'] = $kind;
     1277        return $res;
     1278}
     1279
    11071280/**
    11081281 * The post_content mirror Elementor's own save would write for this tree
  • auditae-ai-search-toolkit/trunk/includes/internal-links.php

    r3733266 r3777340  
    9292        // the graph is missing or out of date a background rebuild is queued
    9393        // and the response says so ('building').
    94         $status = auditae_links_index_status();
     94        $status = auditae_links_index_status_catch_up();
    9595        $index  = $status['index'];
    9696
    … …  
    117117                $items[] = array(
    118118                        'id'             => (int) $p->ID,
    119                         'title'          => get_the_title( $p ),
     119                        'title'          => auditae_rest_plain_title( $p ),
    120120                        'slug'           => $p->post_name,
    121121                        'view_url'       => get_permalink( $p ),
    122122                        'outgoing_count' => (int) $stats['out'],
    123123                        'incoming_count' => (int) $stats['in'],
    124                         'orphan'         => ( 0 === (int) $stats['in'] && $stats['known'] && ! in_array( (int) $p->ID, $exempt, true ) ),
     124                        // Links from the site's navigation menus, counted apart from
     125                        // links in other posts' content (0.38.0).
     126                        'menu_links'     => (int) $stats['menu'],
     127                        'orphan'         => ( 0 === (int) $stats['in'] && 0 === (int) $stats['menu'] && $stats['known'] && ! in_array( (int) $p->ID, $exempt, true ) ),
    125128                        // False when the post sits outside the indexed window, so a
    126129                        // consumer can tell "no incoming links" from "not measured".
    … …  
    147150                                // Non-null while a sliced rebuild runs: pages scanned so far.
    148151                                'rebuilding' => $status['progress'],
     152                                // True when WP-Cron isn't running the rebuild here (each read
     153                                // then advances it a little itself).
     154                                'stalled'     => (bool) $status['stalled'],
     155                                'age_seconds' => isset( $index['built'] ) ? max( 0, time() - (int) $index['built'] ) : null,
    149156                        ),
    150157                )
    … …  
    241248                                if ( in_array( auditae_links_normalize_path( $link['href'] ), $target_keys, true ) ) {
    242249                                        $incoming[] = array(
     250                                                'source'       => 'content',
    243251                                                'source_id'    => (int) $cand->ID,
    244                                                 'source_title' => get_the_title( $cand->ID ),
     252                                                'source_title' => auditae_rest_plain_title( $cand->ID ),
    245253                                                'anchor_text'  => $link['anchor'],
    246254                                                'href'         => $link['href'],
    … …  
    251259        }
    252260
     261        // Navigation menu links count as incoming too (source "menu"), so a
     262        // page reached only from the menu isn't presented as unlinked.
     263        $menu      = auditae_links_menu_inlinks();
     264        $menu_hits = isset( $menu[ (int) $post->ID ] ) ? (int) $menu[ (int) $post->ID ] : 0;
     265        if ( $menu_hits > 0 ) {
     266                $incoming[] = array(
     267                        'source'       => 'menu',
     268                        'source_id'    => 0,
     269                        'source_title' => 'Navigation menu',
     270                        'anchor_text'  => '',
     271                        'href'         => (string) $permalink,
     272                        'count'        => $menu_hits,
     273                );
     274        }
     275
    253276        return rest_ensure_response(
    254277                array(
    255278                        'post' => array(
    256279                                'id'    => (int) $post->ID,
    257                                 'title' => get_the_title( $post ),
     280                                'title' => auditae_rest_plain_title( $post ),
    258281                                'slug'  => $post->post_name,
    259282                        ),
    260                         'outgoing' => $outgoing,
    261                         'incoming' => $incoming,
     283                        'outgoing'   => $outgoing,
     284                        'incoming'   => $incoming,
     285                        'menu_links' => $menu_hits,
    262286                        // Only computed when there's nothing incoming, since that's the
    263287                        // only case where a suggestion is the next action. Each one is
    264288                        // pre-verified to be insertable, so AEBOT can act on it without
    265289                        // a round trip that might 422.
    266                         'suggestions' => empty( $incoming ) ? auditae_links_suggest_sources( (int) $post->ID, 5 ) : array(),
     290                        'suggestions' => count( $incoming ) === ( $menu_hits > 0 ? 1 : 0 ) ? auditae_links_suggest_sources( (int) $post->ID, 5 ) : array(),
    267291                )
    268292        );
    … …  
    279303                return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) );
    280304        }
     305        if ( 'trash' === $post->post_status ) {
     306                return new WP_Error( 'auditae_post_trashed', 'This post is in the trash. Restore it first (POST /posts/' . $id . '/untrash).', array( 'status' => 409 ) );
     307        }
    281308
    282309        $json = $request->get_json_params();
    … …  
    285312        $anchor = isset( $body['anchor_text'] ) ? trim( (string) $body['anchor_text'] ) : '';
    286313        $href   = isset( $body['target_url'] ) ? (string) $body['target_url'] : '';
    287 
    288         $original = auditae_elementor_is_built( $id ) ? get_post_meta( $id, '_elementor_data', true ) : $post->post_content;
     314        $force  = isset( $body['force'] ) && rest_sanitize_boolean( $body['force'] );
     315
     316        // The target must be a page that exists and is public: a host check
     317        // alone let /does-not-exist/ through, writing a broken link (and the
     318        // app billed for it). Resolved the way the broken-link checker does
     319        // (posts, pages, terms, archives, files, redirects) before anything is
     320        // written. force: true skips it, for a path another app on the same
     321        // host serves.
     322        $is_elementor = auditae_elementor_is_built( $id );
     323        $target       = null;
     324        $site_host    = wp_parse_url( home_url(), PHP_URL_HOST );
     325        $target_host  = wp_parse_url( $href, PHP_URL_HOST );
     326        if ( '' !== $anchor && '' !== $href && ( ! $target_host || auditae_is_site_host( $target_host, $site_host ) ) ) {
     327                $target = auditae_links_check_target( $href );
     328                if ( ! $force && 'broken' === $target['status'] ) {
     329                        return new WP_Error(
     330                                'auditae_link_target_not_found',
     331                                sprintf( 'Nothing was changed: %s is not a page on this site (no published post, page, category, archive, file or redirect answers it), so the link would be broken. Link to a published page (wp_list_posts gives each one\'s view_url), or pass force: true if another app on this host serves that path.', $href ),
     332                                array(
     333                                        'status'        => 422,
     334                                        'target_status' => 'not_found',
     335                                )
     336                        );
     337                }
     338                if ( ! $force && 'unpublished' === $target['status'] ) {
     339                        // Same code as not_found (the app maps one refusal);
     340                        // data.target_status says which.
     341                        return new WP_Error(
     342                                'auditae_link_target_not_found',
     343                                sprintf( 'Nothing was changed: %1$s is post %2$d, which is %3$s, so visitors following the link would get a 404. Publish it first, or link to a published page.', $href, (int) $target['post_id'], $target['post_status'] ),
     344                                array(
     345                                        'status'        => 422,
     346                                        'target_status' => $target['post_status'],
     347                                        'target_id'     => (int) $target['post_id'],
     348                                )
     349                        );
     350                }
     351        }
     352
     353        $content_before   = (string) $post->post_content;
     354        $elementor_before = $is_elementor ? (string) get_post_meta( $id, '_elementor_data', true ) : '';
    289355
    290356        // Validation, the kses/slashing dance and the save all live in
    291357        // auditae_links_insert() so this endpoint and the one-click button on
    292358        // the Links tab cannot drift apart.
    293         $result = auditae_links_insert( $id, $anchor, $href );
     359        $info   = array();
     360        $result = auditae_links_insert( $id, $anchor, $href, $info );
    294361        if ( is_wp_error( $result ) ) {
    295362                $status = array(
    … …  
    307374        }
    308375
    309         auditae_log_action( 'internal_link_inserted', 'id=' . $id );
    310 
    311         return rest_ensure_response(
    312                 array(
    313                         'post_id'        => (int) $id,
    314                         'anchor_text'    => $anchor,
    315                         'target_url'     => esc_url_raw( $href ),
    316                         'previous_state' => auditae_elementor_is_built( $id )
    317                                 ? array( '_elementor_data' => $original )
    318                                 : array( 'post_content' => $original ),
    319                 )
    320         );
     376        // Undone by id (POST /posts/{id}/restore), like wp_update_post's text
     377        // edits: an Elementor page's tree can't be written back by the app, and
     378        // echoing the whole old body made every link answer as big as the page.
     379        $answer = array(
     380                'post_id'     => (int) $id,
     381                'anchor_text' => $anchor,
     382                'target_url'  => esc_url_raw( $href ),
     383                'builder'     => auditae_rest_builder( get_post( $id ) ),
     384        );
     385        if ( null !== $target && $target['post_id'] > 0 ) {
     386                $answer['target_id'] = (int) $target['post_id'];
     387        }
     388        if ( $is_elementor ) {
     389                $rid = auditae_elementor_undo_push( $id, $elementor_before );
     390                if ( '' !== $rid ) {
     391                        $answer['elementor_restore_id'] = $rid;
     392                }
     393        } else {
     394                $rid = auditae_content_undo_push( $id, $content_before );
     395                if ( '' !== $rid ) {
     396                        $answer['content_restore_id'] = $rid;
     397                }
     398        }
     399        if ( ! empty( $info['inserted'] ) ) {
     400                // One-pair undo through wp_update_post `replacements` (Elementor
     401                // pages too): the anchor written back to the phrase it wrapped.
     402                $answer['inserted_html'] = $info['inserted'];
     403                $answer['undo']          = array(
     404                        'replacements' => array(
     405                                array(
     406                                        'find'    => $info['inserted'],
     407                                        'replace' => (string) $info['phrase'],
     408                                ),
     409                        ),
     410                );
     411        }
     412        if ( null !== $target && 'ok' !== $target['status'] ) {
     413                $answer['warning'] = 'force: true, so the target was not verified as a page on this site.';
     414        }
     415        if ( isset( $body['include_previous_content'] ) && rest_sanitize_boolean( $body['include_previous_content'] ) ) {
     416                $answer['previous_state'] = $is_elementor ? array( '_elementor_data' => $elementor_before ) : array( 'post_content' => $content_before );
     417        }
     418
     419        auditae_log_action( 'internal_link_inserted', 'id=' . $id . ( '' !== $rid ? ' restore_id=' . $rid : '' ) );
     420
     421        return rest_ensure_response( $answer );
    321422}
    322423
    … …  
    410511 * @return string Content with at most one new anchor.
    411512 */
    412 function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href ) {
     513function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href, &$inserted = null ) {
     514        $inserted = '';
    413515        if ( ! is_string( $content ) || '' === $content || '' === trim( (string) $anchor ) ) {
    414516                return $content;
    … …  
    443545        // A callback, not a replacement string: esc_url() allows "$", so a
    444546        // target URL containing "$1" was expanded as a backreference.
    445         $replace  = static function ( $m ) use ( $href_att ) {
    446                 return '<a href="' . $href_att . '">' . $m[1] . '</a>';
     547        $made     = '';
     548        $replace  = static function ( $m ) use ( $href_att, &$made ) {
     549                $made = '<a href="' . $href_att . '">' . $m[1] . '</a>';
     550                return $made;
    447551        };
    448552
    … …  
    482586        }
    483587
     588        if ( $replaced ) {
     589                // The exact anchor written, so a caller can offer it back as a
     590                // one-pair undo (find: this, replace: the phrase).
     591                $inserted = $made;
     592        }
    484593        return $replaced ? $out : $content;
    485594}
  • auditae-ai-search-toolkit/trunk/includes/link-index.php

    r3733266 r3777340  
    175175
    176176/**
     177 * auditae_links_index_status() for the REST graph, which an agent reads
     178 * with nobody to press "Build link graph now": when nothing has advanced a
     179 * build lately (no slice in the last couple of minutes) and WP-Cron is
     180 * evidently not running it (an overdue event), or the graph is long past
     181 * its TTL, run one short slice in this request. Without it, `building`
     182 * stayed true next to an 18-hour-old built_at on a host where WP-Cron never
     183 * fired, and the graph never refreshed. The wp-admin tab keeps the
     184 * never-build-in-the-request behaviour (it has the button).
     185 *
     186 * @return array Same shape as auditae_links_index_status().
     187 */
     188function auditae_links_index_status_catch_up() {
     189        $status = auditae_links_index_status();
     190        if ( ! $status['building'] ) {
     191                return $status;
     192        }
     193        $stored = $status['index'];
     194        $state  = get_option( AUDITAE_LINK_INDEX_STATE );
     195        $last   = is_array( $state ) ? (int) ( isset( $state['updated'] ) ? $state['updated'] : ( isset( $state['started'] ) ? $state['started'] : 0 ) ) : 0;
     196        $idle   = ( time() - $last ) > AUDITAE_LINK_INDEX_RESUME_DELAY + 60;
     197        $old    = null !== $stored && ( time() - (int) $stored['built'] ) > AUDITAE_LINK_INDEX_TTL + HOUR_IN_SECONDS;
     198        if ( ! $idle || ! ( $status['stalled'] || $old ) ) {
     199                return $status;
     200        }
     201        $built = auditae_links_index_build( (float) apply_filters( 'auditae_link_index_inline_budget', 3 ) );
     202        if ( is_array( $built ) ) {
     203                return array(
     204                        'index'    => $built,
     205                        'building' => ! auditae_links_index_is_usable( get_option( AUDITAE_LINK_INDEX_OPTION ) ),
     206                        'progress' => null,
     207                        'stalled'  => false,
     208                );
     209        }
     210        $status['progress'] = auditae_links_index_progress();
     211        return $status;
     212}
     213
     214/**
    177215 * Queue the next build slice to run as soon as cron fires. Safe to call on
    178216 * every read: WordPress refuses a single event when one for the same hook
    … …  
    219257 * @return array|null The freshly built index, or null when a continuation was scheduled.
    220258 */
    221 function auditae_links_index_build() {
     259function auditae_links_index_build( $budget = null ) {
    222260        global $wpdb;
    223261
    … …  
    271309        }
    272310
    273         $budget   = (float) apply_filters( 'auditae_link_index_time_budget', AUDITAE_LINK_INDEX_TIME_BUDGET );
    274         $deadline = microtime( true ) + max( 0.0, $budget );
     311        if ( null === $budget ) {
     312                $budget = (float) apply_filters( 'auditae_link_index_time_budget', AUDITAE_LINK_INDEX_TIME_BUDGET );
     313        }
     314        $deadline = microtime( true ) + max( 0.0, (float) $budget );
    275315        $host     = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
    276316        $total    = count( $state['ids'] );
    … …  
    475515                        'in'    => 0,
    476516                        'out'   => 0,
     517                        'menu'  => 0,
    477518                        'known' => false,
    478519                );
    479520        }
     521        $menu = auditae_links_menu_inlinks();
    480522        return array(
    481523                'in'    => isset( $index['in'][ $post_id ] ) ? (int) $index['in'][ $post_id ] : 0,
    482524                'out'   => isset( $index['out'][ $post_id ] ) ? (int) $index['out'][ $post_id ] : 0,
     525                // Links from the site's navigation menus (not in `in`).
     526                'menu'  => isset( $menu[ $post_id ] ) ? (int) $menu[ $post_id ] : 0,
    483527                'known' => in_array( $post_id, $index['ids'], true ),
    484528        );
     529}
     530
     531/**
     532 * Posts and pages the site's navigation links to: items of the classic
     533 * menus assigned to a theme location (every menu when none is assigned, as
     534 * with an Elementor header's Nav Menu widget), and the links of published
     535 * block-theme Navigation menus (wp_navigation). A page reached only from
     536 * the menu was reported as an orphan: on a typical brochure site that was
     537 * every page but the home page.
     538 *
     539 * Read live (menus are small and cached), so it is never stale and needs
     540 * no index rebuild.
     541 *
     542 * @return array<int,int> post ID => number of menu links to it.
     543 */
     544function auditae_links_menu_inlinks( $reset = false ) {
     545        static $cache = null;
     546        if ( null !== $cache && ! $reset ) {
     547                return $cache;
     548        }
     549        $cache = array();
     550        $add   = static function ( $id ) use ( &$cache ) {
     551                $id = (int) $id;
     552                if ( $id > 0 ) {
     553                        $cache[ $id ] = isset( $cache[ $id ] ) ? $cache[ $id ] + 1 : 1;
     554                }
     555        };
     556        $host  = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
     557        $by_url = static function ( $url ) use ( $add, $host ) {
     558                $url      = (string) $url;
     559                $url_host = wp_parse_url( $url, PHP_URL_HOST );
     560                if ( '' === $url || ( $url_host && ! auditae_is_site_host( $url_host, $host ) ) ) {
     561                        return;
     562                }
     563                $abs = $url_host ? $url : home_url( '/' . ltrim( $url, '/' ) );
     564                $add( url_to_postid( $abs ) );
     565        };
     566
     567        $menu_ids = array_filter( array_map( 'intval', array_values( (array) get_nav_menu_locations() ) ) );
     568        if ( empty( $menu_ids ) ) {
     569                $menus    = wp_get_nav_menus( array( 'fields' => 'ids' ) );
     570                $menu_ids = is_array( $menus ) ? array_map( 'intval', $menus ) : array();
     571        }
     572        $seen_items = 0;
     573        foreach ( array_unique( $menu_ids ) as $menu_id ) {
     574                $items = wp_get_nav_menu_items( $menu_id, array( 'update_post_term_cache' => false ) );
     575                foreach ( (array) $items as $item ) {
     576                        if ( ++$seen_items > 500 ) {
     577                                break 2;
     578                        }
     579                        if ( 'post_type' === $item->type ) {
     580                                $add( $item->object_id );
     581                        } elseif ( 'custom' === $item->type ) {
     582                                $by_url( $item->url );
     583                        }
     584                }
     585        }
     586
     587        // Block themes: navigation-link / navigation-submenu blocks carry the
     588        // linked post's id (kind post-type) or a URL.
     589        $navs = get_posts(
     590                array(
     591                        'post_type'      => 'wp_navigation',
     592                        'post_status'    => 'publish',
     593                        'posts_per_page' => 20,
     594                        'no_found_rows'  => true,
     595                )
     596        );
     597        foreach ( $navs as $nav ) {
     598                $walk = static function ( $blocks ) use ( &$walk, $add, $by_url ) {
     599                        foreach ( (array) $blocks as $b ) {
     600                                if ( in_array( $b['blockName'], array( 'core/navigation-link', 'core/navigation-submenu', 'core/home-link' ), true ) ) {
     601                                        $attrs = (array) $b['attrs'];
     602                                        if ( isset( $attrs['kind'], $attrs['id'] ) && 'post-type' === $attrs['kind'] ) {
     603                                                $add( $attrs['id'] );
     604                                        } elseif ( ! empty( $attrs['url'] ) ) {
     605                                                $by_url( $attrs['url'] );
     606                                        }
     607                                }
     608                                if ( ! empty( $b['innerBlocks'] ) ) {
     609                                        $walk( $b['innerBlocks'] );
     610                                }
     611                        }
     612                };
     613                $walk( parse_blocks( $nav->post_content ) );
     614        }
     615        return $cache;
    485616}
    486617
    … …  
    530661        $limit   = max( 1, (int) $limit );
    531662        $exempt  = auditae_links_orphan_exempt_ids();
     663        $menu    = auditae_links_menu_inlinks();
    532664        $orphans = array();
    533665        foreach ( $index['ids'] as $id ) {
    534                 if ( isset( $index['in'][ $id ] ) || in_array( (int) $id, $exempt, true ) ) {
     666                if ( isset( $index['in'][ $id ] ) || isset( $menu[ (int) $id ] ) || in_array( (int) $id, $exempt, true ) ) {
    535667                        continue;
    536668                }
    … …  
    556688        }
    557689        $exempt = auditae_links_orphan_exempt_ids();
     690        $menu   = auditae_links_menu_inlinks();
    558691        $n      = 0;
    559692        foreach ( $index['ids'] as $id ) {
    560                 if ( ! isset( $index['in'][ $id ] ) && ! in_array( (int) $id, $exempt, true ) ) {
     693                if ( ! isset( $index['in'][ $id ] ) && ! isset( $menu[ (int) $id ] ) && ! in_array( (int) $id, $exempt, true ) ) {
    561694                        $n++;
    562695                }
    … …  
    816949 * @return bool Whether a link was placed.
    817950 */
    818 function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href ) {
     951function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href, &$inserted = null ) {
     952        $inserted = '';
    819953        return auditae_elementor_map_slots(
    820954                $data,
    821                 function ( $value, $kind, &$stop ) use ( $anchor, $href ) {
     955                function ( $value, $kind, &$stop ) use ( $anchor, $href, &$inserted ) {
    822956                        if ( 'rich' !== $kind ) {
    823957                                return $value;
    824958                        }
    825                         $new = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href );
     959                        $made = '';
     960                        $new  = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href, $made );
    826961                        if ( $new !== $value ) {
    827                                 $stop = true;
     962                                $stop     = true;
     963                                $inserted = $made;
    828964                        }
    829965                        return $new;
    … …  
    842978 * @param string $anchor    Text to wrap.
    843979 * @param string $href      Destination URL (must be same-host).
     980 * @param array  $info      Filled on success: inserted (the <a> written), phrase (the text it wraps).
    844981 * @return true|WP_Error
    845982 */
    846 function auditae_links_insert( $source_id, $anchor, $href ) {
     983function auditae_links_insert( $source_id, $anchor, $href, &$info = null ) {
     984        $info      = array();
    847985        $source_id = (int) $source_id;
    848986        $post      = get_post( $source_id );
    … …  
    8721010        if ( auditae_elementor_is_built( $source_id ) ) {
    8731011                $data = auditae_elementor_data( $source_id );
    874                 if ( ! auditae_elementor_wrap_first_anchor( $data, $anchor, $href ) ) {
     1012                $made = '';
     1013                if ( ! auditae_elementor_wrap_first_anchor( $data, $anchor, $href, $made ) ) {
    8751014                        return $not_found;
    8761015                }
     1016                $info = auditae_links_insert_info( $made );
    8771017                $result = auditae_elementor_save( $source_id, $data );
    8781018                if ( is_wp_error( $result ) ) {
    … …  
    8841024
    8851025        $original = $post->post_content;
    886         $updated  = auditae_wrap_first_anchor_occurrence( $original, $anchor, $href );
     1026        $made     = '';
     1027        $updated  = auditae_wrap_first_anchor_occurrence( $original, $anchor, $href, $made );
    8871028        if ( $updated === $original ) {
    8881029                return $not_found;
    8891030        }
     1031        $info = auditae_links_insert_info( $made );
    8901032
    8911033        // Requests authenticated by bearer token run as user 0, where kses is
    … …  
    9181060}
    9191061
     1062/**
     1063 * What auditae_links_insert() wrote, for an undo pair.
     1064 *
     1065 * @param string $made The <a> written.
     1066 * @return array{inserted:string,phrase:string}
     1067 */
     1068function auditae_links_insert_info( $made ) {
     1069        $phrase = preg_replace( '#^<a\b[^>]*>(.*)</a>$#s', '$1', (string) $made );
     1070        return array(
     1071                'inserted' => (string) $made,
     1072                'phrase'   => is_string( $phrase ) ? $phrase : '',
     1073        );
     1074}
     1075
     1076/**
     1077 * Whether a link target resolves to something public on this site, the way
     1078 * the broken-link checker decides it (auditae_broken_classify(): posts,
     1079 * pages, terms, archives, files, redirects; conservative: anything it
     1080 * can't rule out is 'ok').
     1081 *
     1082 * @param string $href Target URL (absolute same-host or host-relative).
     1083 * @return array{status:string,post_id:int,post_status:string} status ok | broken | unpublished.
     1084 */
     1085function auditae_links_check_target( $href ) {
     1086        $href = trim( (string) $href );
     1087        $out  = array(
     1088                'status'      => 'ok',
     1089                'post_id'     => 0,
     1090                'post_status' => '',
     1091        );
     1092        if ( ! function_exists( 'auditae_broken_classify' ) ) {
     1093                return $out;
     1094        }
     1095        $site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
     1096        // A relative path without a leading slash resolves against home.
     1097        if ( '' !== $href && ! preg_match( '#^([a-z][a-z0-9+.\-]*:|/|\#|\?)#i', $href ) ) {
     1098                $href = '/' . $href;
     1099        }
     1100        if ( ! auditae_broken_is_checkable_internal( $href, $site_host ) ) {
     1101                return $out; // fragment, query-only, mailto:, another app's path
     1102        }
     1103        return auditae_broken_classify( $href, auditae_broken_normalize_path( $href ), auditae_broken_known_paths() );
     1104}
     1105
    9201106// ────────────────────────────────────────────────────────────────────
    9211107// Scheduled rebuild
  • auditae-ai-search-toolkit/trunk/includes/media.php

    r3775803 r3777340  
    7070        // references inside published post content, so it carries more risk
    7171        // and a distinct audit-log entry. write_seo tier, same as alt/caption.
     72        // Alt text for up to 50 images in one call, with one undo token
     73        // (0.38.0). write_seo, like the single-image PATCH.
     74        register_rest_route(
     75                $namespace,
     76                '/media/bulk-alt',
     77                array(
     78                        'methods'             => 'POST',
     79                        'callback'            => 'auditae_rest_bulk_alt_text',
     80                        'permission_callback' => 'auditae_rest_authorize_seo_write',
     81                )
     82        );
     83        register_rest_route(
     84                $namespace,
     85                '/media/bulk-alt/undo',
     86                array(
     87                        'methods'             => 'POST',
     88                        'callback'            => 'auditae_rest_bulk_alt_text_undo',
     89                        'permission_callback' => 'auditae_rest_authorize_seo_write',
     90                )
     91        );
     92
    7293        register_rest_route(
    7394                $namespace,
    … …  
    241262                        'media'          => auditae_format_media( get_post( $id ), true ),
    242263                        'previous_state' => $previous_state,
     264                )
     265        );
     266}
     267
     268// ────────────────────────────────────────────────────────────────────
     269// POST /media/bulk-alt — bulk alt text, with undo (0.38.0)
     270// ────────────────────────────────────────────────────────────────────
     271
     272// Undo tokens for bulk alt writes: token => {at, items: {id: {before, after}}}.
     273// Last 10 kept for 30 days, one non-autoloaded option.
     274define( 'AUDITAE_OPT_ALT_UNDO', 'auditae_alt_undo' );
     275define( 'AUDITAE_ALT_BULK_MAX', 50 );
     276
     277/**
     278 * POST /media/bulk-alt {items: [{id, alt_text}, …]} (≤ 50). Per item: updated,
     279 * unchanged (already that alt: a no-op), not_found (not an attachment) or
     280 * invalid. One undo_token covers every updated item.
     281 */
     282function auditae_rest_bulk_alt_text( WP_REST_Request $request ) {
     283        $items = $request->get_param( 'items' );
     284        if ( ! is_array( $items ) || empty( $items ) ) {
     285                return new WP_Error( 'auditae_bad_request', 'items is required: [{id, alt_text}, …] (up to ' . AUDITAE_ALT_BULK_MAX . ').', array( 'status' => 400 ) );
     286        }
     287        if ( count( $items ) > AUDITAE_ALT_BULK_MAX ) {
     288                return new WP_Error( 'auditae_bad_request', 'At most ' . AUDITAE_ALT_BULK_MAX . ' items per call; nothing was changed. Split the list.', array( 'status' => 400 ) );
     289        }
     290        $results = array();
     291        $undo    = array();
     292        $seen    = array();
     293        foreach ( array_values( $items ) as $item ) {
     294                $id  = is_array( $item ) && isset( $item['id'] ) ? absint( $item['id'] ) : 0;
     295                $alt = is_array( $item ) && array_key_exists( 'alt_text', $item ) ? $item['alt_text'] : ( is_array( $item ) && array_key_exists( 'alt', $item ) ? $item['alt'] : null );
     296                if ( $id <= 0 || ! ( is_string( $alt ) || null === $alt ) || isset( $seen[ $id ] ) ) {
     297                        $results[] = array(
     298                                'id'     => $id,
     299                                'status' => 'invalid',
     300                                'error'  => isset( $seen[ $id ] ) ? 'duplicate id in this call' : 'each item needs a numeric id and a string alt_text',
     301                        );
     302                        continue;
     303                }
     304                $seen[ $id ] = true;
     305                $att         = get_post( $id );
     306                if ( ! $att || 'attachment' !== $att->post_type ) {
     307                        $results[] = array(
     308                                'id'     => $id,
     309                                'status' => 'not_found',
     310                        );
     311                        continue;
     312                }
     313                $new    = trim( wp_strip_all_tags( (string) $alt ) );
     314                $before = (string) get_post_meta( $id, '_wp_attachment_image_alt', true );
     315                if ( $new === $before ) {
     316                        $results[] = array(
     317                                'id'       => $id,
     318                                'status'   => 'unchanged',
     319                                'alt_text' => $new,
     320                        );
     321                        continue;
     322                }
     323                update_post_meta( $id, '_wp_attachment_image_alt', wp_slash( $new ) );
     324                clean_post_cache( $id );
     325                $undo[ $id ] = array(
     326                        'before' => $before,
     327                        'after'  => $new,
     328                );
     329                $results[]   = array(
     330                        'id'                => $id,
     331                        'status'            => 'updated',
     332                        'alt_text'          => $new,
     333                        'previous_alt_text' => $before,
     334                );
     335        }
     336
     337        $updated = count( $undo );
     338        $answer  = array(
     339                'results'   => $results,
     340                'updated'   => $updated,
     341                'unchanged' => count( wp_list_filter( $results, array( 'status' => 'unchanged' ) ) ),
     342                'failed'    => count( $results ) - $updated - count( wp_list_filter( $results, array( 'status' => 'unchanged' ) ) ),
     343        );
     344        if ( $updated > 0 ) {
     345                $token = 'au_' . strtolower( wp_generate_password( 16, false, false ) );
     346                $store = get_option( AUDITAE_OPT_ALT_UNDO, array() );
     347                $store = is_array( $store ) ? $store : array();
     348                foreach ( $store as $k => $entry ) {
     349                        if ( ! is_array( $entry ) || (int) ( $entry['at'] ?? 0 ) < time() - 30 * DAY_IN_SECONDS ) {
     350                                unset( $store[ $k ] );
     351                        }
     352                }
     353                $store[ $token ] = array(
     354                        'at'    => time(),
     355                        'items' => $undo,
     356                );
     357                $store = array_slice( $store, -10, null, true );
     358                update_option( AUDITAE_OPT_ALT_UNDO, $store, false );
     359                // POST /media/bulk-alt/undo {undo_token} puts every updated alt back.
     360                $answer['undo_token'] = $token;
     361                auditae_log_action( 'media_alt_bulk', 'updated=' . $updated . ' undo_token=' . $token );
     362        }
     363        return rest_ensure_response( $answer );
     364}
     365
     366/**
     367 * POST /media/bulk-alt/undo {undo_token, force?}: put back the alts a bulk
     368 * write replaced. An image whose alt changed again since is skipped
     369 * (changed_since) unless force.
     370 */
     371function auditae_rest_bulk_alt_text_undo( WP_REST_Request $request ) {
     372        $token = (string) $request->get_param( 'undo_token' );
     373        $store = get_option( AUDITAE_OPT_ALT_UNDO, array() );
     374        $store = is_array( $store ) ? $store : array();
     375        if ( ! preg_match( '/^au_[a-z0-9]{16}$/', $token ) || ! isset( $store[ $token ] ) ) {
     376                return new WP_Error( 'auditae_restore_not_found', 'No bulk alt-text write with that undo_token (the last 10, for 30 days, are kept). Nothing was changed.', array( 'status' => 404 ) );
     377        }
     378        $force    = rest_sanitize_boolean( $request->get_param( 'force' ) );
     379        $results  = array();
     380        $restored = 0;
     381        foreach ( (array) $store[ $token ]['items'] as $id => $pair ) {
     382                $id  = (int) $id;
     383                $cur = (string) get_post_meta( $id, '_wp_attachment_image_alt', true );
     384                if ( 'attachment' !== get_post_type( $id ) ) {
     385                        $results[] = array(
     386                                'id'     => $id,
     387                                'status' => 'not_found',
     388                        );
     389                        continue;
     390                }
     391                if ( ! $force && $cur !== (string) $pair['after'] ) {
     392                        $results[] = array(
     393                                'id'       => $id,
     394                                'status'   => 'changed_since',
     395                                'alt_text' => $cur,
     396                        );
     397                        continue;
     398                }
     399                update_post_meta( $id, '_wp_attachment_image_alt', wp_slash( (string) $pair['before'] ) );
     400                clean_post_cache( $id );
     401                $restored++;
     402                $results[] = array(
     403                        'id'       => $id,
     404                        'status'   => 'restored',
     405                        'alt_text' => (string) $pair['before'],
     406                );
     407        }
     408        if ( 0 === $restored ) {
     409                // Nothing put back: keep the token so a retry with force works.
     410                return new WP_Error(
     411                        'auditae_changed_since',
     412                        'Nothing was changed: every image\'s alt text was changed again (or the image deleted) since that write. Pass force: true to put the old alts back anyway.',
     413                        array(
     414                                'status'  => 409,
     415                                'results' => $results,
     416                        )
     417                );
     418        }
     419        unset( $store[ $token ] );
     420        update_option( AUDITAE_OPT_ALT_UNDO, $store, false );
     421        auditae_log_action( 'media_alt_bulk_undo', 'restored=' . $restored . ' undo_token=' . $token );
     422        return rest_ensure_response(
     423                array(
     424                        'results'  => $results,
     425                        'restored' => $restored,
    243426                )
    244427        );
    … …  
    614797
    615798        $rewrote_posts = 0;
     799        $rewrote_revs  = 0;
    616800        $rewrote_meta  = 0;
    617801        $leftovers     = array();
    … …  
    619803                // Pre-filter on the size stem (a prefix of the -scaled stem), so
    620804                // posts embedding only intermediate-size URLs still match.
    621                 $rewrote_posts = auditae_rewrite_media_references( $old_size_stem, $url_map );
     805                $rewrote_posts = auditae_rewrite_media_references( $old_size_stem, $url_map, $rewrote_revs );
    622806                $rewrote_meta  = auditae_rewrite_media_meta_references( $old_size_stem, $url_map );
    623807                auditae_rewrite_media_option_references( $url_map );
    … …  
    632816        return array(
    633817                'media'          => auditae_format_media( get_post( $id ), true ),
    634                 'rewrote_posts'  => $rewrote_posts,
     818                // Posts, pages and other content (any status but trash) whose body
     819                // or Elementor data named the file. Revisions are rewritten too and
     820                // counted apart (0.38.0; before, they were in rewrote_posts).
     821                'rewrote_posts'     => $rewrote_posts,
     822                'rewrote_revisions' => $rewrote_revs,
    635823                // Posts that still mention an old file name after the rewrite (a
    636824                // reference in a form we couldn't safely rewrite). Empty is good.
    … …  
    651839 * @param string $old_stem Filename stem (no extension) to pre-filter on.
    652840 * @param array  $url_map  old URL => new URL pairs (full size + each size).
    653  * @return int Number of posts updated.
     841 * @param int    $revisions Set to the number of revisions updated (not in the return value).
     842 * @return int Number of posts updated, revisions not counted.
    654843 */
    655 function auditae_rewrite_media_references( $old_stem, array $url_map ) {
     844function auditae_rewrite_media_references( $old_stem, array $url_map, &$revisions = null ) {
     845        $revisions = 0;
    656846        global $wpdb;
    657847
    … …  
    694884                                $el_done[ $pid ] = true;
    695885                        }
    696                         $updated++;
     886                        // Revisions are rewritten too (restoring one shouldn't bring a
     887                        // broken image back) but reported apart: counting them as posts
     888                        // said a rename touched 6 posts when no live post used the image.
     889                        if ( 'revision' === get_post_type( $pid ) ) {
     890                                $revisions++;
     891                        } else {
     892                                $updated++;
     893                        }
    697894                }
    698895        }
    … …  
    718915                        clean_post_cache( $pid );
    719916                        if ( ! isset( $el_done[ $pid ] ) ) {
    720                                 $updated++; // An Elementor page was already counted with its tree.
     917                                // An Elementor page was already counted with its tree.
     918                                if ( 'revision' === get_post_type( $pid ) ) {
     919                                        $revisions++;
     920                                } else {
     921                                        $updated++;
     922                                }
    721923                        }
    722924                }
  • auditae-ai-search-toolkit/trunk/includes/redirects.php

    r3773612 r3777340  
    401401        }
    402402        return true;
     403}
     404
     405/**
     406 * What a redirect source still serves, when it isn't a 404. Rules only run
     407 * on a 404 (auditae_redirects_maybe_redirect()), so a rule for a live page
     408 * never fires. Null when nothing answers the path (the rule would work).
     409 *
     410 * Checks what WordPress itself answers the path with: a published post or
     411 * page of a viewable type, the front page, a term / post type / author
     412 * archive (auditae_broken_known_paths()), or a file on disk.
     413 *
     414 * @param string $source_n Normalized source (auditae_redirects_normalize_source()).
     415 * @return array{kind:string,post_id:int,title:string,view_url:string}|null
     416 */
     417function auditae_redirects_source_live( $source_n ) {
     418        $source_n = (string) $source_n;
     419        $parts    = wp_parse_url( home_url( '/' ) );
     420        $origin   = ( isset( $parts['scheme'] ) ? $parts['scheme'] : 'https' ) . '://' . ( isset( $parts['host'] ) ? $parts['host'] : '' ) . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' );
     421        $abs      = $origin . $source_n;
     422        $post_id  = url_to_postid( $abs );
     423        if ( $post_id <= 0 ) {
     424                $post_id = url_to_postid( $abs . '/' );
     425        }
     426        if ( $post_id > 0 ) {
     427                $post = get_post( $post_id );
     428                if ( $post && 'publish' === $post->post_status && 'attachment' !== $post->post_type && is_post_type_viewable( $post->post_type ) ) {
     429                        return array(
     430                                'kind'     => 'page' === $post->post_type ? 'page' : 'post',
     431                                'post_id'  => (int) $post_id,
     432                                'title'    => html_entity_decode( wp_strip_all_tags( get_the_title( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
     433                                'view_url' => (string) get_permalink( $post ),
     434                        );
     435                }
     436        }
     437        if ( ! function_exists( 'auditae_broken_known_paths' ) ) {
     438                return null;
     439        }
     440        $path = auditae_broken_normalize_path( $abs );
     441        if ( '' === $path ) {
     442                return array(
     443                        'kind'     => 'front_page',
     444                        'post_id'  => (int) get_option( 'page_on_front' ),
     445                        'title'    => '',
     446                        'view_url' => home_url( '/' ),
     447                );
     448        }
     449        $known = auditae_broken_known_paths();
     450        if ( isset( $known[ $path ] ) ) {
     451                return array(
     452                        'kind'     => 'archive',
     453                        'post_id'  => 0,
     454                        'title'    => '',
     455                        'view_url' => home_url( '/' . $path . '/' ),
     456                );
     457        }
     458        $raw = ltrim( rawurldecode( (string) wp_parse_url( $abs, PHP_URL_PATH ) ), '/' );
     459        if ( '' !== $raw && false === strpos( $raw, '..' ) && is_file( ABSPATH . $raw ) ) {
     460                return array(
     461                        'kind'     => 'file',
     462                        'post_id'  => 0,
     463                        'title'    => '',
     464                        'view_url' => $abs,
     465                );
     466        }
     467        return null;
    403468}
    404469
  • auditae-ai-search-toolkit/trunk/includes/rest-routes.php

    r3775803 r3777340  
    113113        register_rest_route(
    114114                $namespace,
     115                '/posts/(?P<id>\d+)/restore',
     116                array(
     117                        // Undo of a body edit (content_restore_id) or an Elementor text
     118                        // edit (elementor_restore_id), by id. 0.38.0.
     119                        'methods'             => 'POST',
     120                        'callback'            => 'auditae_rest_elementor_restore',
     121                        'permission_callback' => 'auditae_rest_authorize_post_update',
     122                )
     123        );
     124
     125        register_rest_route(
     126                $namespace,
    115127                '/seo/yoast/settings',
    116128                array(
    … …  
    706718        }
    707719        return true;
     720}
     721
     722/**
     723 * A post title as plain text for REST answers. get_the_title() is
     724 * texturized and HTML-encoded for templates ("Tips &amp; Tricks"), which
     725 * agents then quoted back literally.
     726 *
     727 * @param int|WP_Post $post Post.
     728 * @return string
     729 */
     730function auditae_rest_plain_title( $post ) {
     731        return html_entity_decode( wp_strip_all_tags( (string) get_the_title( $post ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
    708732}
    709733
    … …  
    887911                        // Behaviours the app may rely on (api_version 6+).
    888912                        'features'       => auditae_rest_features(),
     913                        // The IndexNow key file this plugin serves (always served, even
     914                        // with auto-ping off), so the app can submit this site's URLs
     915                        // under the site's own key. 0.38.0.
     916                        'indexnow'       => array(
     917                                'key_location' => auditae_indexnow_key_location(),
     918                                'enabled'      => (bool) auditae_indexnow_enabled(),
     919                                'paused'       => (string) auditae_indexnow_paused_reason(),
     920                        ),
    889921                )
    890922        );
    … …  
    903935                'untrash',                  // POST /posts/{id}/untrash, GET /posts?status=trash
    904936                'elementor_restore',        // POST /posts/{id}/elementor-restore
     937                // 0.38.0 (api_version 7):
     938                'content_restore',          // content_restore_id on body edits; POST /posts/{id}/restore takes cu_ and eu_ ids
     939                'compact_previous_state',   // PATCH /posts/{id} and internal-link answer no old body (include_previous_content: true for it)
     940                'link_target_check',        // internal-link refuses a target that isn't a public page (auditae_link_target_not_found / _unpublished)
     941                'internal_link_undo',       // internal-link answers undo.replacements + a restore id
     942                'redirect_source_check',    // redirects write refuses a live source (auditae_redirect_source_live) unless force
     943                'menu_links',               // link graph counts navigation menu links (menu_links), orphans exclude them
     944                'media_rename_revisions',   // rename reports rewrote_revisions apart from rewrote_posts
     945                'list_any_viewable',        // GET /posts?post_type=any lists only viewable content types
     946                'llms_txt_compact',         // llms.txt write/reset answer rendered_bytes, not rendered (include_rendered: true)
     947                'probe_handled_by',         // /probe-schema handled_by; found_foreign only when it differs
     948                'schema_if_output',         // /posts/{id}/schema write if_output: true refuses types another plugin prints
     949                'robots_meta',              // /posts/{id}/seo yoast / rankmath blocks read + write noindex / nofollow
     950                'rendered_seo',             // /posts/{id}/seo read: each engine block's rendered_title / rendered_description
     951                'broken_links_fix',         // GET /broken-links, POST /posts/{id}/broken-link
     952                'bulk_media_alt',           // POST /media/bulk-alt (+ /media/bulk-alt/undo)
     953                'indexnow_key',             // /status indexnow { key_location, enabled, paused }
    905954        );
    906955}
    … …  
    10261075                $query_type = array();
    10271076                foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $obj ) {
    1028                         if ( auditae_rest_post_type_allowed( $obj ) ) {
     1077                        if ( auditae_rest_post_type_allowed( $obj ) && auditae_rest_post_type_in_any( $obj ) ) {
    10291078                                $query_type[] = $obj->name;
    10301079                        }
    … …  
    10621111                        'post_type'     => $p->post_type,
    10631112                        'is_front_page' => auditae_rest_is_front_page( $p ),
    1064                         'title'     => get_the_title( $p ),
     1113                        'title'     => auditae_rest_plain_title( $p ),
    10651114                        'status'    => $p->post_status,
    10661115                        'date'      => auditae_rest_post_date( $p ),
    … …  
    13261375
    13271376/**
    1328  * POST /posts/{id}/elementor-restore {restore_id, force?} — put back the
    1329  * Elementor tree an AuditAE `replacements` edit replaced. Only trees this
    1330  * plugin kept itself (auditae_elementor_undo_push()) can be restored, by
    1331  * id; refused when the page changed since, unless force.
     1377 * POST /posts/{id}/restore (and its 0.37.0 name /elementor-restore)
     1378 * {restore_id, force?} — put back what an AuditAE edit replaced, by the id
     1379 * the edit answered with: an Elementor tree (elementor_restore_id, 'eu_…')
     1380 * or a post body (content_restore_id, 'cu_…': a `content` or
     1381 * `replacements` edit, an inserted internal link, a broken-link fix). Only
     1382 * what this plugin kept itself can be restored; refused when the post
     1383 * changed since, unless force. The restore answers its own restore id, so
     1384 * it can be undone too.
    13321385 */
    13331386function auditae_rest_elementor_restore( WP_REST_Request $request ) {
    … …  
    13371390                return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) );
    13381391        }
    1339         if ( ! auditae_elementor_is_built( $post_id ) ) {
    1340                 return new WP_Error( 'auditae_bad_request', 'This post is not built with Elementor. Undo other edits with previous_state (wp_update_post with the old values).', array( 'status' => 400 ) );
    1341         }
    13421392        $restore_id = $request->get_param( 'restore_id' );
    1343         if ( ! is_string( $restore_id ) || ! preg_match( '/^eu_[a-z0-9]{16}$/', $restore_id ) ) {
    1344                 return new WP_Error( 'auditae_bad_request', 'restore_id is required: the elementor_restore_id an Elementor text edit answered with.', array( 'status' => 400 ) );
    1345         }
    1346         $res = auditae_elementor_undo_restore( $post_id, $restore_id, rest_sanitize_boolean( $request->get_param( 'force' ) ) );
     1393        if ( ! is_string( $restore_id ) || ! preg_match( '/^(eu|cu)_[a-z0-9]{16}$/', $restore_id ) ) {
     1394                return new WP_Error( 'auditae_bad_request', 'restore_id is required: the elementor_restore_id or content_restore_id an edit answered with.', array( 'status' => 400 ) );
     1395        }
     1396        if ( 'trash' === $post->post_status ) {
     1397                return new WP_Error( 'auditae_post_trashed', 'This post is in the trash. Restore it first (POST /posts/' . $post_id . '/untrash).', array( 'status' => 409 ) );
     1398        }
     1399        $res = auditae_undo_restore( $post_id, $restore_id, rest_sanitize_boolean( $request->get_param( 'force' ) ) );
    13471400        if ( is_wp_error( $res ) ) {
    13481401                return $res;
    13491402        }
    1350         auditae_log_action( 'elementor_restore', 'post_id=' . $post_id . ' restore_id=' . $restore_id, array( '_elementor_data' => $res['previous'] ) );
     1403        $elementor = 'elementor' === $res['kind'];
     1404        auditae_log_action(
     1405                $elementor ? 'elementor_restore' : 'content_restore',
     1406                'post_id=' . $post_id . ' restore_id=' . $restore_id . ( '' !== $res['restore_id'] ? ' new_restore_id=' . $res['restore_id'] : '' ),
     1407                $elementor ? array( '_elementor_data' => $res['previous'] ) : array()
     1408        );
    13511409
    13521410        $answer = array(
    1353                 'post_id'        => $post_id,
    1354                 'status'         => get_post_status( $post_id ),
    1355                 'restored'       => $restore_id,
    1356                 'builder'        => 'elementor',
    1357                 'edit_url'       => auditae_rest_edit_url( $post_id ),
    1358                 'previous_state' => array( '_elementor_data' => $res['previous'] ),
    1359         );
     1411                'post_id'  => $post_id,
     1412                'status'   => get_post_status( $post_id ),
     1413                'restored' => $restore_id,
     1414                'builder'  => auditae_rest_builder( get_post( $post_id ) ),
     1415                'edit_url' => auditae_rest_edit_url( $post_id ),
     1416        );
     1417        if ( rest_sanitize_boolean( $request->get_param( 'include_previous_content' ) ) ) {
     1418                $answer['previous_state'] = $elementor ? array( '_elementor_data' => $res['previous'] ) : array( 'content' => $res['previous'] );
     1419        }
    13601420        if ( '' !== $res['restore_id'] ) {
    1361                 $answer['elementor_restore_id'] = $res['restore_id'];
     1421                $answer[ $elementor ? 'elementor_restore_id' : 'content_restore_id' ] = $res['restore_id'];
    13621422        }
    13631423        return rest_ensure_response( $answer );
    … …  
    16651725}
    16661726
     1727/**
     1728 * Whether GET /posts?post_type=any lists this type: content visitors can
     1729 * open, not page-builder template stores. Elementor's elementor_library
     1730 * ("Default Kit", headers, popups) is registered public but isn't viewable
     1731 * by visitors, and its URLs 404 for them. Those types stay readable by
     1732 * name (post_type=elementor_library).
     1733 *
     1734 * @param WP_Post_Type $pt_obj Post type.
     1735 * @return bool
     1736 */
     1737function auditae_rest_post_type_in_any( $pt_obj ) {
     1738        if ( ! $pt_obj || ! $pt_obj->public ) {
     1739                return false;
     1740        }
     1741        $templates = array(
     1742                'elementor_library',
     1743                'e-floating-buttons',
     1744                'elementor_snippet',
     1745                'elementor_font',
     1746                'elementor_icons',
     1747                'jet-theme-core',
     1748                'jet-engine',
     1749                'ae_global_templates',
     1750                'oceanwp_library',
     1751                'astra-advanced-hook',
     1752                'fl-builder-template',
     1753                'et_pb_layout',
     1754                'brizy_template',
     1755                'ct_template',
     1756                'kadence_element',
     1757                'gp_elements',
     1758        );
     1759        /**
     1760         * Filter the post types left out of GET /posts?post_type=any.
     1761         *
     1762         * @param string[] $templates Post type names.
     1763         */
     1764        $templates = (array) apply_filters( 'auditae_rest_any_excluded_types', $templates );
     1765        if ( in_array( $pt_obj->name, $templates, true ) ) {
     1766                return false;
     1767        }
     1768        // A type visitors can't open (not publicly queryable) has no public URL.
     1769        return 'page' === $pt_obj->name || is_post_type_viewable( $pt_obj );
     1770}
     1771
    16671772// Idempotency transient value while the create it guards is still running.
    16681773define( 'AUDITAE_IDEM_PENDING', 'pending' );
    … …  
    18881993                $remember( 'title', $post->post_title );
    18891994        }
     1995        // The old body is no longer echoed back (it made every edit's answer as
     1996        // big as the page): it is kept on the post and restored by id
     1997        // (content_restore_id / elementor_restore_id → POST /posts/{id}/restore).
     1998        // include_previous_content: true brings the 0.37.0 shape back.
     1999        $full_previous = rest_sanitize_boolean( $request->get_param( 'include_previous_content' ) );
     2000        $content_before = (string) $post->post_content;
     2001        // Fields sent whose value is already stored (title, content, excerpt,
     2002        // slug, status), so the app can tell a resend from a change.
     2003        $unchanged = array();
     2004        foreach ( array( 'title' => $post->post_title, 'content' => $post->post_content, 'excerpt' => $post->post_excerpt, 'slug' => $post->post_name, 'status' => $post->post_status ) as $field => $stored ) {
     2005                $sent = $request->get_param( $field );
     2006                if ( is_string( $sent ) && $sent === (string) $stored ) {
     2007                        $unchanged[] = $field;
     2008                }
     2009        }
    18902010        if ( is_string( $content ) ) {
    18912011                $update['post_content'] = $content;
    1892                 $remember( 'content', $post->post_content );
     2012                if ( $full_previous ) {
     2013                        $remember( 'content', $post->post_content );
     2014                }
    18932015        }
    18942016        $replaced        = null;
    … …  
    18962018        if ( is_array( $replacements ) ) {
    18972019                $elementor_before = $is_elementor ? (string) get_post_meta( $post_id, '_elementor_data', true ) : '';
    1898                 if ( $is_elementor ) {
    1899                         $previous['_elementor_data'] = $elementor_before;
    1900                 } else {
    1901                         $remember( 'content', $post->post_content );
     2020                if ( $full_previous ) {
     2021                        if ( $is_elementor ) {
     2022                                $previous['_elementor_data'] = $elementor_before;
     2023                        } else {
     2024                                $remember( 'content', $post->post_content );
     2025                        }
    19022026                }
    19032027                // Elementor pages are saved inside; plain posts hand back the new
    … …  
    19922116        }
    19932117
    1994         auditae_log_action( 'update_post', 'post_id=' . $post_id . ( '' !== $elementor_undo ? ' elementor_restore_id=' . $elementor_undo : '' ), $previous );
     2118        // A changed plain-post body is kept for undo by id.
     2119        $content_undo = '';
     2120        $undo_pairs   = null;
     2121        if ( ! $is_elementor && ( is_string( $content ) || ( is_array( $replacements ) && null !== $replaced && ! $replaced['elementor'] ) ) ) {
     2122                $content_undo = auditae_content_undo_push( $post_id, $content_before );
     2123                if ( is_array( $replacements ) && '' !== $content_undo ) {
     2124                        $undo_pairs = auditae_replacements_undo_pairs( $content_before, (string) get_post_field( 'post_content', $post_id, 'raw' ), $replaced, array_values( $replacements ) );
     2125                }
     2126        }
     2127
     2128        $log_detail = 'post_id=' . $post_id . ( '' !== $elementor_undo ? ' elementor_restore_id=' . $elementor_undo : '' ) . ( '' !== $content_undo ? ' content_restore_id=' . $content_undo : '' );
     2129        auditae_log_action( 'update_post', $log_detail, $previous );
    19952130
    19962131        $answer = array(
    19972132                'post_id'              => $post_id,
    19982133                'status'               => get_post_status( $post_id ),
    1999                 'previous_state'       => $previous,
     2134                // Old values of the small fields changed ({} when only the body
     2135                // changed: its undo is content_restore_id / elementor_restore_id).
     2136                'previous_state'       => empty( $previous ) ? new stdClass() : $previous,
    20002137                'edit_url'             => auditae_rest_edit_url( $post_id ),
    20012138                'builder'              => auditae_rest_builder( get_post( $post_id ) ),
    … …  
    20112148        if ( '' !== $elementor_undo ) {
    20122149                $answer['elementor_restore_id'] = $elementor_undo;
     2150        }
     2151        if ( '' !== $content_undo ) {
     2152                // Undo of the body change: POST /posts/{id}/restore {restore_id}.
     2153                $answer['content_restore_id'] = $content_undo;
     2154        }
     2155        if ( null !== $undo_pairs ) {
     2156                // Or send these as `replacements` (verified to give back the old body).
     2157                $answer['undo_replacements'] = $undo_pairs;
     2158        }
     2159        if ( $unchanged ) {
     2160                $answer['unchanged_fields'] = $unchanged;
    20132161        }
    20142162        return rest_ensure_response( $answer );
    … …  
    22852433        }
    22862434
     2435        // Only the engines that run: an inactive one's block was always empty
     2436        // (or stale leftovers) and read as if it applied.
     2437        $engines  = array_keys( array_filter( array( 'yoast' => $yoast_active, 'rankmath' => $rm_active ) ) );
    22872438        $map      = auditae_post_seo_meta_map();
    22882439        $snapshot = array();
    2289         foreach ( array( 'yoast', 'rankmath' ) as $engine ) {
     2440        foreach ( $engines as $engine ) {
    22902441                $snapshot[ $engine ] = array();
    22912442                foreach ( $map[ $engine ] as $logical => $meta_key ) {
    22922443                        $snapshot[ $engine ][ $logical ] = get_post_meta( $post_id, $meta_key, true );
    22932444                }
     2445                // noindex / nofollow as this engine renders them for the post
     2446                // (its own per-post setting, else the site / post type default).
     2447                $robots                          = auditae_seo_robots_get( $post_id, $engine );
     2448                $snapshot[ $engine ]['noindex']  = $robots['noindex'];
     2449                $snapshot[ $engine ]['nofollow'] = $robots['nofollow'];
    22942450        }
    22952451
    22962452        if ( 'read' === $action ) {
     2453                foreach ( $engines as $engine ) {
     2454                        // The title and description that actually print, templates
     2455                        // resolved (an empty field shows the template's output); null
     2456                        // when the engine's API can't say outside a page view.
     2457                        $snapshot[ $engine ] += auditae_seo_rendered( $post_id, $engine );
     2458                        $snapshot[ $engine ]['robots_source'] = auditae_seo_robots_get( $post_id, $engine )['source'];
     2459                }
    22972460                return rest_ensure_response(
    22982461                        array(
    2299                                 'action'           => 'read',
    2300                                 'post_id'          => $post_id,
    2301                                 'provider'         => auditae_seo_meta_provider(),
    2302                                 'yoast_active'     => $yoast_active,
    2303                                 'rankmath_active'  => $rm_active,
    2304                                 'seo'              => $snapshot,
     2462                                'action'          => 'read',
     2463                                'post_id'         => $post_id,
     2464                                'provider'        => auditae_seo_meta_provider(),
     2465                                'yoast_active'    => $yoast_active,
     2466                                'rankmath_active' => $rm_active,
     2467                                'seo'             => $snapshot,
    23052468                        )
    23062469                );
    … …  
    23092472        // SEO fields are single strings; an array or object would be stored
    23102473        // as-is and break the SEO plugin's output. Checked before any write so
    2311         // a bad field doesn't leave the other engine half-updated.
     2474        // a bad field doesn't leave the other engine half-updated. noindex /
     2475        // nofollow are booleans (null: back to the default).
     2476        $robots_by_engine = array();
    23122477        foreach ( array( 'yoast', 'rankmath' ) as $engine ) {
    23132478                $requested = $request->get_param( $engine );
    … …  
    23202485                        }
    23212486                }
     2487                $robot_keys = array_intersect_key( $requested, array( 'noindex' => 1, 'nofollow' => 1 ) );
     2488                if ( $robot_keys ) {
     2489                        $parsed = auditae_seo_robots_parse( $robot_keys );
     2490                        if ( is_wp_error( $parsed ) ) {
     2491                                return $parsed;
     2492                        }
     2493                        $robots_by_engine[ $engine ] = $parsed;
     2494                }
     2495        }
     2496        // A top-level robots {noindex, nofollow} applies to every active engine.
     2497        if ( null !== $request->get_param( 'robots' ) ) {
     2498                $parsed = auditae_seo_robots_parse( $request->get_param( 'robots' ) );
     2499                if ( is_wp_error( $parsed ) ) {
     2500                        return $parsed;
     2501                }
     2502                foreach ( $engines as $engine ) {
     2503                        $robots_by_engine[ $engine ] = isset( $robots_by_engine[ $engine ] ) ? $robots_by_engine[ $engine ] + $parsed : $parsed;
     2504                }
    23222505        }
    23232506
    23242507        $applied  = array();
    2325         $previous = $snapshot;
    2326 
    2327         foreach ( array( 'yoast', 'rankmath' ) as $engine ) {
    2328                 $engine_active = ( 'yoast' === $engine ) ? $yoast_active : $rm_active;
    2329                 if ( ! $engine_active ) {
    2330                         continue;
    2331                 }
     2508        $previous = array();
     2509
     2510        foreach ( $engines as $engine ) {
    23322511                $requested = $request->get_param( $engine );
    2333                 if ( ! is_array( $requested ) ) {
    2334                         continue;
    2335                 }
    2336                 $applied[ $engine ] = array();
    2337                 foreach ( $map[ $engine ] as $logical => $meta_key ) {
    2338                         if ( ! array_key_exists( $logical, $requested ) ) {
    2339                                 continue;
     2512                $done      = array();
     2513                if ( is_array( $requested ) ) {
     2514                        foreach ( $map[ $engine ] as $logical => $meta_key ) {
     2515                                if ( ! array_key_exists( $logical, $requested ) ) {
     2516                                        continue;
     2517                                }
     2518                                $value = wp_strip_all_tags( (string) $requested[ $logical ] );
     2519                                update_post_meta( $post_id, $meta_key, wp_slash( $value ) );
     2520                                $done[ $logical ] = $value;
    23402521                        }
    2341                         $value = wp_strip_all_tags( (string) $requested[ $logical ] );
    2342                         update_post_meta( $post_id, $meta_key, wp_slash( $value ) );
    2343                         $applied[ $engine ][ $logical ] = $value;
    2344                 }
    2345                 if ( empty( $applied[ $engine ] ) ) {
    2346                         unset( $applied[ $engine ] );
     2522                }
     2523                if ( isset( $robots_by_engine[ $engine ] ) ) {
     2524                        auditae_seo_robots_set( $post_id, $engine, $robots_by_engine[ $engine ] );
     2525                        $after = auditae_seo_robots_get( $post_id, $engine );
     2526                        foreach ( array_keys( $robots_by_engine[ $engine ] ) as $k ) {
     2527                                $done[ $k ] = $after[ $k ];
     2528                        }
     2529                }
     2530                if ( $done ) {
     2531                        $applied[ $engine ] = $done;
     2532                        // Old values of the fields written only (the full snapshot of
     2533                        // both engines was echoed before).
     2534                        $previous[ $engine ] = array_intersect_key( $snapshot[ $engine ], $done );
    23472535                }
    23482536        }
    … …  
    23512539                return new WP_Error(
    23522540                        'auditae_bad_request',
    2353                         'No applicable SEO fields supplied. Send {yoast: {...}} and/or {rankmath: {...}}.',
     2541                        'No applicable SEO fields supplied. Send {' . implode( ': {...}} and/or {', $engines ) . ': {...}} (the active SEO plugin) with title, description, canonical, focus_keyword, noindex and/or nofollow.',
    23542542                        array( 'status' => 400 )
    23552543                );
    … …  
    24212609                                'yoast_active'    => false,
    24222610                                'rankmath_active' => false,
    2423                                 'seo'             => array( 'auditae' => $current ),
     2611                                // noindex / nofollow are among the stored fields; rendered_*
     2612                                // are the title and description that print (0.38.0).
     2613                                'seo'             => array( 'auditae' => $current + auditae_seo_rendered( $post_id, 'auditae' ) ),
    24242614                                'output'          => $output,
    24252615                        )
    … …  
    24352625        }
    24362626        $fields = is_array( $requested ) ? array_intersect_key( $requested, auditae_seo_meta_empty() ) : array();
     2627        // robots: {noindex, nofollow} works on every provider; here it is the
     2628        // same two stored fields (null = off, the default).
     2629        if ( null !== $request->get_param( 'robots' ) ) {
     2630                $robots_in = auditae_seo_robots_parse( $request->get_param( 'robots' ) );
     2631                if ( is_wp_error( $robots_in ) ) {
     2632                        return $robots_in;
     2633                }
     2634                foreach ( $robots_in as $k => $v ) {
     2635                        $fields[ $k ] = (bool) $v;
     2636                }
     2637        }
    24372638        if ( empty( $fields ) ) {
    24382639                return new WP_Error(
    … …  
    25372738                );
    25382739        }
     2740        // if_output: true — refuse before saving when another source already
     2741        // prints FAQPage for this post (AuditAE's pairs would never be output).
     2742        $faq_owner = auditae_faq_foreign_source( $post_id );
     2743        if ( '' !== $faq_owner && rest_sanitize_boolean( $request->get_param( 'if_output' ) ) ) {
     2744                return new WP_Error(
     2745                        'auditae_schema_not_output',
     2746                        sprintf( 'Nothing was saved: %s already prints FAQPage on this post, so AuditAE would store these pairs but never print them. Edit the FAQ there instead.', $faq_owner ),
     2747                        array(
     2748                                'status'     => 409,
     2749                                'handled_by' => array( array( 'type' => 'FAQPage', 'handled_by' => $faq_owner ) ),
     2750                        )
     2751                );
     2752        }
    25392753        $res = auditae_faq_save( $post_id, $qas );
    25402754        if ( 0 === $res['applied'] ) {
    … …  
    26912905                );
    26922906        }
     2907        // if_output: true — refuse before saving when a node's type would be
     2908        // stored but not printed because another plugin prints that type here
     2909        // (an SEO plugin's Article, WooCommerce's Product): a paid write that
     2910        // changes nothing on the page. The answer names who handles each.
     2911        if ( rest_sanitize_boolean( $request->get_param( 'if_output' ) ) ) {
     2912                $list    = isset( $schemas['@type'] ) ? array( $schemas ) : $schemas;
     2913                $handled = array();
     2914                foreach ( (array) $list as $node ) {
     2915                        $type = auditae_schema_primary_type( $node );
     2916                        if ( '' !== $type && ! auditae_should_emit_schema_type( $type, $post_id ) ) {
     2917                                $owner     = auditae_schema_type_owner( $type, $post_id );
     2918                                $handled[] = array(
     2919                                        'type'       => $type,
     2920                                        'handled_by' => '' !== $owner ? $owner : 'another plugin',
     2921                                );
     2922                        }
     2923                }
     2924                if ( $handled ) {
     2925                        return new WP_Error(
     2926                                'auditae_schema_not_output',
     2927                                sprintf(
     2928                                        'Nothing was saved: %s, so AuditAE would store this but never print it. Edit the schema in that plugin instead, or send only the other types.',
     2929                                        implode(
     2930                                                '; ',
     2931                                                array_map(
     2932                                                        static function ( $h ) {
     2933                                                                return $h['handled_by'] . ' prints ' . $h['type'] . ' on this post';
     2934                                                        },
     2935                                                        $handled
     2936                                                )
     2937                                        )
     2938                                ),
     2939                                array(
     2940                                        'status'     => 409,
     2941                                        'handled_by' => $handled,
     2942                                )
     2943                        );
     2944                }
     2945        }
    26932946        $res = auditae_post_schema_save( $post_id, $schemas );
    26942947        if ( ! empty( $res['error'] ) ) {
    … …  
    27963049                );
    27973050        }
     3051        // if_output: true — a write of Organization nodes only is refused before
     3052        // saving when AuditAE would hold them back (an SEO plugin prints the
     3053        // site's publisher, or the theme / another plugin already does).
     3054        if ( rest_sanitize_boolean( $request->get_param( 'if_output' ) ) ) {
     3055                $list  = isset( $schemas['@type'] ) ? array( $schemas ) : $schemas;
     3056                $types = array_unique( array_filter( array_map( 'auditae_schema_primary_type', (array) $list ) ) );
     3057                if ( array( 'Organization' ) === array_values( $types ) ) {
     3058                        $signal = auditae_org_seo_signal();
     3059                        if ( ! in_array( $signal, array( 'none', 'seo_silent' ), true ) ) {
     3060                                $owner = 'other_verified' === $signal ? 'the theme or another plugin' : auditae_seo_plugin_label();
     3061                                return new WP_Error(
     3062                                        'auditae_schema_not_output',
     3063                                        sprintf( 'Nothing was saved: %s already prints the site\'s Organization, so AuditAE would store this one but hold it back. Edit it there, or reuse its @id (wp_probe_schema).', $owner ),
     3064                                        array(
     3065                                                'status'     => 409,
     3066                                                'handled_by' => array( array( 'type' => 'Organization', 'handled_by' => $owner ) ),
     3067                                        )
     3068                                );
     3069                        }
     3070                }
     3071        }
    27983072        $res = auditae_org_schema_save( $schemas );
    27993073        if ( ! empty( $res['error'] ) ) {
    … …  
    28423116        // { @type: [@id, …] } maps: an empty PHP array would encode as [] and
    28433117        // read as a list, not an object.
    2844         foreach ( array( 'found', 'found_foreign' ) as $key ) {
     3118        if ( is_array( $probe ) ) {
     3119                // found_foreign repeated found on every site where AuditAE prints
     3120                // nothing on the homepage. Sent only when it differs; when it's
     3121                // left out it equals found (foreign_same_as_found: true).
     3122                $found   = isset( $probe['found'] ) && is_array( $probe['found'] ) ? $probe['found'] : array();
     3123                $foreign = isset( $probe['found_foreign'] ) && is_array( $probe['found_foreign'] ) ? $probe['found_foreign'] : null;
     3124                if ( null !== $foreign && wp_json_encode( $foreign ) === wp_json_encode( $found ) ) {
     3125                        unset( $probe['found_foreign'] );
     3126                        $probe['foreign_same_as_found'] = true;
     3127                } elseif ( null !== $foreign ) {
     3128                        $probe['foreign_same_as_found'] = false;
     3129                }
     3130                // Which schema types another plugin prints instead of AuditAE (so
     3131                // an AuditAE node of that type is stored but not output), by type:
     3132                // { "Article": "Yoast SEO", … }; {} when AuditAE prints them all.
     3133                // Per-post owners (WooCommerce Product, an SEO plugin's HowTo
     3134                // block) are answered by /posts/{id}/schema's not_output.
     3135                $probe['handled_by'] = auditae_rest_schema_handled_by();
     3136        }
     3137        // { @type: [@id, …] } maps: an empty PHP array would encode as [] and
     3138        // read as a list, not an object.
     3139        foreach ( array( 'found', 'found_foreign', 'handled_by' ) as $key ) {
    28453140                if ( is_array( $probe ) && array_key_exists( $key, $probe ) && is_array( $probe[ $key ] ) && empty( $probe[ $key ] ) ) {
    28463141                        $probe[ $key ] = new stdClass();
    … …  
    28483143        }
    28493144        return rest_ensure_response( $probe );
     3145}
     3146
     3147/**
     3148 * Site-wide: schema type => the plugin that prints it instead of AuditAE.
     3149 *
     3150 * @return array<string,string>
     3151 */
     3152function auditae_rest_schema_handled_by() {
     3153        $out = array();
     3154        foreach ( array_unique( array_merge( auditae_schema_seo_owned_types(), auditae_schema_supported_types() ) ) as $type ) {
     3155                if ( ! is_string( $type ) || auditae_should_emit_schema_type( $type, 0 ) ) {
     3156                        continue;
     3157                }
     3158                $owner        = auditae_schema_type_owner( $type, 0 );
     3159                $out[ $type ] = '' !== $owner ? $owner : 'another plugin (auditae_should_emit_schema_type filter)';
     3160        }
     3161        return $out;
    28503162}
    28513163
    … …  
    28843196                );
    28853197                return rest_ensure_response(
    2886                         array(
    2887                                 'action'         => 'reset',
    2888                                 'previous_state' => array( 'config' => $res['previous'] ),
    2889                                 'config'         => $res['applied'],
    2890                                 'rendered'       => auditae_llms_txt_render(),
     3198                        auditae_rest_llms_txt_write_answer(
     3199                                $request,
     3200                                array(
     3201                                        'action'         => 'reset',
     3202                                        'previous_state' => array( 'config' => $res['previous'] ),
     3203                                        'config'         => $res['applied'],
     3204                                )
    28913205                        )
    28923206                );
    … …  
    29203234        );
    29213235        return rest_ensure_response(
    2922                 array(
    2923                         'action'         => 'write',
    2924                         'mode'           => 'replace' === $mode ? 'replace' : 'merge',
    2925                         'previous_state' => array( 'config' => $res['previous'] ),
    2926                         'config'         => $res['applied'],
    2927                         'rendered'       => auditae_llms_txt_render(),
    2928                 )
    2929         );
     3236                auditae_rest_llms_txt_write_answer(
     3237                        $request,
     3238                        array(
     3239                                'action'         => 'write',
     3240                                'mode'           => 'replace' === $mode ? 'replace' : 'merge',
     3241                                'previous_state' => array( 'config' => $res['previous'] ),
     3242                                'config'         => $res['applied'],
     3243                        )
     3244                )
     3245        );
     3246}
     3247
     3248/**
     3249 * A write/reset answer without the whole rendered file (it is served at
     3250 * `url`, and GET /llms-txt returns it): its size and line count instead,
     3251 * so the answer stays small. include_rendered: true adds it back.
     3252 *
     3253 * @param WP_REST_Request $request Request.
     3254 * @param array           $answer  Answer so far.
     3255 * @return array
     3256 */
     3257function auditae_rest_llms_txt_write_answer( WP_REST_Request $request, array $answer ) {
     3258        $rendered          = (string) auditae_llms_txt_render();
     3259        $answer['url']     = home_url( '/llms.txt' );
     3260        $answer['rendered_bytes'] = strlen( $rendered );
     3261        $answer['rendered_lines'] = '' === $rendered ? 0 : substr_count( $rendered, "\n" ) + 1;
     3262        if ( rest_sanitize_boolean( $request->get_param( 'include_rendered' ) ) ) {
     3263                $answer['rendered'] = $rendered;
     3264        }
     3265        return $answer;
    29303266}
    29313267
    … …  
    30103346                                break;
    30113347                        }
     3348                }
     3349
     3350                // A rule only runs when WordPress would answer 404, so one for a page
     3351                // that is live never fires: the write succeeded (and was billed)
     3352                // while the page kept answering 200. Refuse before saving, unless
     3353                // force (then it's saved, inactive until the page is gone).
     3354                $force = rest_sanitize_boolean( $request->get_param( 'force' ) );
     3355                $live  = auditae_redirects_source_live( $source_norm );
     3356                if ( null !== $live && ! $force ) {
     3357                        $what = 'front_page' === $live['kind'] ? 'the front page'
     3358                                : ( 'archive' === $live['kind'] ? 'a live archive (category, tag, author or post type)'
     3359                                : ( 'file' === $live['kind'] ? 'a file on disk'
     3360                                : sprintf( 'a published %1$s ("%2$s", id %3$d)', $live['kind'], $live['title'], $live['post_id'] ) ) );
     3361                        return new WP_Error(
     3362                                'auditae_redirect_source_live',
     3363                                sprintf(
     3364                                        'Nothing was saved: %1$s is %2$s. AuditAE redirects only fire when WordPress would answer 404 for the path, so this one would never run and the page would keep loading. To move a post or page, change its slug (wp_update_post slug: WordPress then sends the old address to the new one itself), or trash it first (wp_trash_post) and then add the redirect. force: true saves the rule anyway, inactive until the page is gone.',
     3365                                        $source_norm,
     3366                                        $what
     3367                                ),
     3368                                array(
     3369                                        'status'      => 409,
     3370                                        'source_kind' => $live['kind'],
     3371                                        'post_id'     => $live['post_id'],
     3372                                        'view_url'    => $live['view_url'],
     3373                                )
     3374                        );
    30123375                }
    30133376
    … …  
    30373400                );
    30383401
    3039                 return rest_ensure_response(
    3040                         array(
    3041                                 'action'         => 'write',
    3042                                 'rule'           => $applied,
    3043                                 'previous_state' => $previous, // null if this was a new rule
    3044                         )
    3045                 );
     3402                $answer = array(
     3403                        'action'         => 'write',
     3404                        'rule'           => $applied,
     3405                        'previous_state' => $previous, // null if this was a new rule
     3406                        // False when the source still serves a live page (saved with
     3407                        // force): the rule runs only once that page answers 404.
     3408                        'active'         => null === $live,
     3409                );
     3410                if ( null !== $live ) {
     3411                        $answer['warning'] = 'Saved inactive: ' . $source_norm . ' still serves a live ' . str_replace( '_', ' ', $live['kind'] ) . ', and redirects only fire on a 404. It starts working once that page is trashed or its slug changes.';
     3412                }
     3413                return rest_ensure_response( $answer );
    30463414        }
    30473415
  • auditae-ai-search-toolkit/trunk/includes/seo-meta.php

    r3775803 r3777340  
    317317        return 1 === preg_match( $pattern, (string) $html );
    318318}
     319
     320// ────────────────────────────────────────────────────────────────────
     321// Per-post robots (noindex / nofollow) and the title / description that
     322// actually render, for every provider (0.38.0)
     323// ────────────────────────────────────────────────────────────────────
     324
     325/**
     326 * Rank Math's titles options (robots defaults, title templates).
     327 *
     328 * @return array
     329 */
     330function auditae_seo_rankmath_titles() {
     331        $opt = get_option( 'rank-math-options-titles', array() );
     332        return is_array( $opt ) ? $opt : array();
     333}
     334
     335/**
     336 * A post's robots as the provider stores them: {noindex, nofollow, source}.
     337 * source is 'post' when the post sets them itself, 'default' when it
     338 * follows the site / post type default (whose effective value is given).
     339 *
     340 * @param int    $post_id  Post ID.
     341 * @param string $provider yoast | rankmath | auditae.
     342 * @return array{noindex:bool,nofollow:bool,source:string}
     343 */
     344function auditae_seo_robots_get( $post_id, $provider ) {
     345        $post_id = (int) $post_id;
     346        $type    = (string) get_post_type( $post_id );
     347        if ( 'yoast' === $provider ) {
     348                $ni  = (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true );
     349                $nf  = (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', true );
     350                $def = false;
     351                if ( class_exists( 'WPSEO_Options' ) && method_exists( 'WPSEO_Options', 'get' ) ) {
     352                        $def = (bool) WPSEO_Options::get( 'noindex-' . $type, false );
     353                }
     354                return array(
     355                        'noindex'  => '1' === $ni || ( '2' !== $ni && $def ),
     356                        'nofollow' => '1' === $nf,
     357                        'source'   => ( in_array( $ni, array( '1', '2' ), true ) || '1' === $nf ) ? 'post' : 'default',
     358                );
     359        }
     360        if ( 'rankmath' === $provider ) {
     361                $own = get_post_meta( $post_id, 'rank_math_robots', true );
     362                $own = is_array( $own ) ? array_values( array_filter( array_map( 'strval', $own ) ) ) : array();
     363                if ( ! empty( $own ) ) {
     364                        $robots = $own;
     365                        $source = 'post';
     366                } else {
     367                        $titles = auditae_seo_rankmath_titles();
     368                        $custom = isset( $titles[ 'pt_' . $type . '_custom_robots' ] ) && 'on' === $titles[ 'pt_' . $type . '_custom_robots' ] && isset( $titles[ 'pt_' . $type . '_robots' ] );
     369                        $robots = $custom ? (array) $titles[ 'pt_' . $type . '_robots' ] : ( isset( $titles['robots_global'] ) ? (array) $titles['robots_global'] : array() );
     370                        $source = 'default';
     371                }
     372                return array(
     373                        'noindex'  => in_array( 'noindex', $robots, true ),
     374                        'nofollow' => in_array( 'nofollow', $robots, true ),
     375                        'source'   => $source,
     376                );
     377        }
     378        $f = auditae_seo_meta_get( $post_id );
     379        return array(
     380                'noindex'  => (bool) $f['noindex'],
     381                'nofollow' => (bool) $f['nofollow'],
     382                'source'   => ( $f['noindex'] || $f['nofollow'] ) ? 'post' : 'default',
     383        );
     384}
     385
     386/**
     387 * Validate a robots write: {noindex?, nofollow?} booleans (null = back to
     388 * the default).
     389 *
     390 * @param mixed $in Request value.
     391 * @return array|WP_Error Keys present only for what was sent.
     392 */
     393function auditae_seo_robots_parse( $in ) {
     394        if ( ! is_array( $in ) ) {
     395                return new WP_Error( 'auditae_bad_request', '`robots` must be an object {noindex?: boolean, nofollow?: boolean}.', array( 'status' => 400 ) );
     396        }
     397        $out = array();
     398        foreach ( array( 'noindex', 'nofollow' ) as $k ) {
     399                if ( ! array_key_exists( $k, $in ) ) {
     400                        continue;
     401                }
     402                $v = $in[ $k ];
     403                if ( null !== $v && ! is_bool( $v ) && ! is_int( $v ) && ! is_string( $v ) ) {
     404                        return new WP_Error( 'auditae_bad_request', sprintf( 'robots.%s must be true, false or null.', $k ), array( 'status' => 400 ) );
     405                }
     406                $out[ $k ] = null === $v ? null : rest_sanitize_boolean( $v );
     407        }
     408        if ( empty( $out ) ) {
     409                return new WP_Error( 'auditae_bad_request', '`robots` needs noindex and/or nofollow.', array( 'status' => 400 ) );
     410        }
     411        return $out;
     412}
     413
     414/**
     415 * Write robots into Yoast's or Rank Math's own per-post fields (the ones
     416 * their Advanced tab edits). false writes an explicit "index" / removes
     417 * "nofollow"; null puts the post back on the site default.
     418 *
     419 * @param int    $post_id  Post ID.
     420 * @param string $provider yoast | rankmath.
     421 * @param array  $robots   auditae_seo_robots_parse() result.
     422 */
     423function auditae_seo_robots_set( $post_id, $provider, array $robots ) {
     424        $post_id = (int) $post_id;
     425        if ( 'yoast' === $provider ) {
     426                if ( array_key_exists( 'noindex', $robots ) ) {
     427                        if ( null === $robots['noindex'] ) {
     428                                delete_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex' );
     429                        } else {
     430                                update_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', $robots['noindex'] ? '1' : '2' );
     431                        }
     432                }
     433                if ( array_key_exists( 'nofollow', $robots ) ) {
     434                        if ( $robots['nofollow'] ) {
     435                                update_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow', '1' );
     436                        } else {
     437                                delete_post_meta( $post_id, '_yoast_wpseo_meta-robots-nofollow' );
     438                        }
     439                }
     440                return;
     441        }
     442        if ( 'rankmath' === $provider ) {
     443                $cur = auditae_seo_robots_get( $post_id, 'rankmath' );
     444                $own = get_post_meta( $post_id, 'rank_math_robots', true );
     445                $own = is_array( $own ) ? array_values( array_filter( array_map( 'strval', $own ) ) ) : array();
     446                // Rank Math reads the per-post list whole once it's set, so start
     447                // from what the post renders now and change only what was sent.
     448                $noindex  = array_key_exists( 'noindex', $robots ) ? $robots['noindex'] : $cur['noindex'];
     449                $nofollow = array_key_exists( 'nofollow', $robots ) ? $robots['nofollow'] : $cur['nofollow'];
     450                if ( null === $noindex && null === $nofollow ) {
     451                        delete_post_meta( $post_id, 'rank_math_robots' );
     452                        return;
     453                }
     454                // Other directives the post set (noarchive, nosnippet…) are kept.
     455                $list   = array_values( array_diff( $own, array( 'index', 'noindex', 'nofollow' ) ) );
     456                $list[] = $noindex ? 'noindex' : 'index';
     457                if ( $nofollow ) {
     458                        $list[] = 'nofollow';
     459                }
     460                update_post_meta( $post_id, 'rank_math_robots', array_values( array_unique( $list ) ) );
     461        }
     462}
     463
     464/**
     465 * The title tag and meta description the provider renders for a post,
     466 * templates resolved (a post with empty fields shows its template's
     467 * output). '' for a description nothing prints. Null when it can't be
     468 * worked out outside a page view (the provider's API is unavailable).
     469 *
     470 * @param int    $post_id  Post ID.
     471 * @param string $provider yoast | rankmath | auditae.
     472 * @return array{rendered_title:?string,rendered_description:?string}
     473 */
     474function auditae_seo_rendered( $post_id, $provider ) {
     475        $post_id = (int) $post_id;
     476        $post    = get_post( $post_id );
     477        $out     = array(
     478                'rendered_title'       => null,
     479                'rendered_description' => null,
     480        );
     481        if ( ! $post ) {
     482                return $out;
     483        }
     484        $clean = static function ( $s ) {
     485                return trim( html_entity_decode( wp_strip_all_tags( (string) $s ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
     486        };
     487        try {
     488                if ( 'yoast' === $provider ) {
     489                        if ( function_exists( 'YoastSEO' ) ) {
     490                                $meta = YoastSEO()->meta->for_post( $post_id );
     491                                if ( $meta ) {
     492                                        $out['rendered_title']       = $clean( $meta->title );
     493                                        $out['rendered_description'] = $clean( $meta->description );
     494                                }
     495                        }
     496                        return $out;
     497                }
     498                if ( 'rankmath' === $provider ) {
     499                        if ( class_exists( '\RankMath\Helper' ) && method_exists( '\RankMath\Helper', 'replace_vars' ) ) {
     500                                // Rank Math registers its %vars% on the front end's `wp`
     501                                // action, which a REST request never reaches; its setup()
     502                                // runs once and is public.
     503                                $rm = function_exists( 'rank_math' ) ? rank_math() : null;
     504                                if ( ! is_object( $rm ) || ! isset( $rm->variables ) || ! is_object( $rm->variables ) ) {
     505                                        // Rank Math before its account step (connect or skip)
     506                                        // loads none of its front end and prints no title or
     507                                        // description: WordPress's own title is what shows.
     508                                        return array(
     509                                                'rendered_title'       => auditae_seo_wp_document_title( $post ),
     510                                                'rendered_description' => '',
     511                                        );
     512                                }
     513                                if ( method_exists( $rm->variables, 'setup' ) ) {
     514                                        $rm->variables->setup();
     515                                }
     516                                $titles = auditae_seo_rankmath_titles();
     517                                $type   = $post->post_type;
     518                                $title  = (string) get_post_meta( $post_id, 'rank_math_title', true );
     519                                if ( '' === $title ) {
     520                                        $title = isset( $titles[ 'pt_' . $type . '_title' ] ) ? (string) $titles[ 'pt_' . $type . '_title' ] : '%title% %sep% %sitename%';
     521                                }
     522                                $desc = (string) get_post_meta( $post_id, 'rank_math_description', true );
     523                                if ( '' === $desc ) {
     524                                        $desc = isset( $titles[ 'pt_' . $type . '_description' ] ) ? (string) $titles[ 'pt_' . $type . '_description' ] : '%excerpt%';
     525                                }
     526                                $out['rendered_title']       = $clean( \RankMath\Helper::replace_vars( $title, $post ) );
     527                                $out['rendered_description'] = $clean( \RankMath\Helper::replace_vars( $desc, $post ) );
     528                        }
     529                        return $out;
     530                }
     531        } catch ( \Throwable $e ) {
     532                return $out;
     533        }
     534        if ( 'auditae' !== $provider ) {
     535                return $out;
     536        }
     537        // No SEO plugin: AuditAE's stored title, else WordPress's own document
     538        // title (title-tag themes); the description only when one is stored
     539        // (nothing else prints one).
     540        $f = auditae_seo_meta_get( $post_id );
     541        $out['rendered_title']       = '' !== $f['title'] ? $f['title'] : auditae_seo_wp_document_title( $post );
     542        $out['rendered_description'] = $f['description'];
     543        return $out;
     544}
     545
     546/**
     547 * The <title> WordPress itself prints for a post (themes with title-tag
     548 * support and block themes), or null when the theme prints its own.
     549 *
     550 * @param WP_Post $post Post.
     551 * @return string|null
     552 */
     553function auditae_seo_wp_document_title( $post ) {
     554        if ( ! current_theme_supports( 'title-tag' ) && ! ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) ) {
     555                return null;
     556        }
     557        $sep   = apply_filters( 'document_title_separator', '-' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's filter.
     558        $front = 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === (int) $post->ID;
     559        $parts = $front
     560                ? array( get_bloginfo( 'name', 'display' ), get_bloginfo( 'description', 'display' ) )
     561                : array( get_the_title( $post ), get_bloginfo( 'name', 'display' ) );
     562        return trim( html_entity_decode( wp_strip_all_tags( implode( ' ' . $sep . ' ', array_filter( $parts ) ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
     563}
  • auditae-ai-search-toolkit/trunk/languages/auditae-ai-search-toolkit.pot

    r3775803 r3777340  
    33msgid ""
    44msgstr ""
    5 "Project-Id-Version: AuditAE – MCP Server & AI SEO 0.37.0\n"
     5"Project-Id-Version: AuditAE – MCP Server & AI SEO 0.38.0\n"
    66"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/auditae-ai-search-toolkit\n"
    77"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
    … …  
    1010"Content-Type: text/plain; charset=UTF-8\n"
    1111"Content-Transfer-Encoding: 8bit\n"
    12 "POT-Creation-Date: 2026-10-30T14:57:49+00:00\n"
     12"POT-Creation-Date: 2026-10-31T19:49:26+00:00\n"
    1313"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
    1414"X-Generator: WP-CLI 2.12.0\n"
    … …  
    19581958msgstr[1] ""
    19591959
    1960 #: includes/link-index.php:850
     1960#: includes/link-index.php:988
    19611961msgid "Post not found."
    19621962msgstr ""
    19631963
    1964 #: includes/link-index.php:856
     1964#: includes/link-index.php:994
    19651965msgid "Anchor text and target URL are both required."
    19661966msgstr ""
    19671967
    1968 #: includes/link-index.php:862
     1968#: includes/link-index.php:1000
    19691969msgid "Internal links only — the target must be on this site."
    19701970msgstr ""
    19711971
    1972 #: includes/link-index.php:867
     1972#: includes/link-index.php:1005
    19731973msgid "That phrase is no longer available as plain text in the post — it may have been edited or already linked."
    19741974msgstr ""
    … …  
    22472247msgstr ""
    22482248
    2249 #: includes/media.php:1040
    2250 #: includes/media.php:1095
     2249#: includes/media.php:1242
     2250#: includes/media.php:1297
    22512251msgid "Rename file"
    22522252msgstr ""
    22532253
    2254 #: includes/media.php:1056
     2254#: includes/media.php:1258
    22552255msgid "You don't have permission to rename this file."
    22562256msgstr ""
    22572257
    2258 #: includes/media.php:1093
     2258#: includes/media.php:1295
    22592259msgid "New file name (without extension). Renames every size and updates references in your posts:"
    22602260msgstr ""
    22612261
    2262 #: includes/media.php:1094
     2262#: includes/media.php:1296
    22632263msgid "Renaming…"
    22642264msgstr ""
    22652265
    2266 #: includes/media.php:1096
     2266#: includes/media.php:1298
    22672267msgid "Rename failed."
    22682268msgstr ""
  • auditae-ai-search-toolkit/trunk/readme.txt

    r3775803 r3777340  
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 0.37.0
     7Stable tag: 0.38.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    129129characters, so older entries are kept in the plugin's development history
    130130rather than here, where they would push the current release out of view.
     131
     132= 0.38.0 =
     133* Internal links: a link to a page that doesn't exist, or to a draft, is refused before anything is written. Pages linked from your navigation menu no longer show up as orphans, and link-graph titles read as plain text.
     134* Redirects: a redirect whose source is a live page (which would never fire, since redirects run only on a 404) is refused with a clear explanation.
     135* Undo: every AI text edit, inserted link and link fix keeps a restore point on the site, so it can be undone in one step, Elementor pages included. Edit answers are much smaller.
     136* New for paired agents: list and fix broken internal links, set alt text on up to 50 images at once (with undo), and read or set a post's noindex / nofollow through Yoast, Rank Math or AuditAE. Reading a post's SEO also shows the title and description that actually print.
     137* Schema: an agent can ask that a schema type another plugin prints (Yoast's Article, WooCommerce's Product) be refused rather than saved unseen.
     138* Renaming an image reports revisions separately from posts, and "all content types" listings skip page-builder templates.
    131139
    132140= 0.37.0 =
    … …  
    154162* New, for paired sites: AuditAE can compress Media Library images in place. Each JPEG, PNG or WebP keeps its file name and URL, its thumbnail sizes are compressed too, and the originals are kept in uploads/auditae-originals/: Restore original in the Media Library puts them back. It won't replace files while another image optimizer, image CDN or WebP copy is in place (Smush, ShortPixel, EWWW, Imagify, Jetpack's image CDN…) unless you confirm, and it skips protected folders such as WooCommerce downloads.
    155163
    156 = 0.34.0 =
    157 * New: a free Schema box in the post editor adds Article, Blog post, How-to or Product schema to a post, with a JSON-LD preview. No account needed.
    158 * Elementor 4: AuditAE can read and edit the text of atomic Heading, Paragraph and Button widgets.
    159 * Renaming an image also updates Elementor page settings, block widgets, header images and the social images Yoast, Rank Math and SEOPress store.
    160 * Coexistence: no duplicate FAQ schema next to Elementor's FAQ Schema switch or Rank Math's Schema Builder, Product schema is left to WooCommerce on product pages, and an Organization your theme or another plugin already prints is respected.
    161 * Faster: IndexNow URLs are queued and sent in batches (a post unpublished or set to noindex before its turn is skipped), the crawler log prunes once a day, and page views and 404s skip database reads for Organization schema and redirects.
    162 * The Links tab and the Dashboard widget no longer make the page wait: the link graph rebuilds and the scorecard refreshes in the background.
    163 * Connect tab: a free sign-up link with your site address filled in, a warning to paste the pairing code only at auditae.app, and how to use the site from Claude or ChatGPT over MCP. A pairing AuditAE no longer accepts asks you to re-pair.
    164 * Accessibility: higher-contrast colours, visible keyboard focus and screen-reader text on badges, new-tab links and repeated links.
    165 * A one-time review request on the AI crawlers tab after the first AI crawler visit.
    166 * Redirects tab: the Suggested redirects table fits its card, so the destination field and Save button are always visible.
    167 
    168164== Upgrade Notice ==
     165
     166= 0.38.0 =
     167Safer AI links and redirects (broken targets and live sources refused), one-step undo for AI edits, menu-aware orphan report, and new bulk alt text, broken-link and robots tools for paired agents. No re-pair required.
    169168
    170169= 0.37.0 =
  • auditae-ai-search-toolkit/trunk/uninstall.php

    r3775803 r3777340  
    7373        'auditae_redirect_sources',
    7474        'auditae_org_fill_gap',
     75        // 0.38.0: bulk alt-text undo tokens.
     76        'auditae_alt_undo',
    7577);
    7678
    … …  
    145147        $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_seo' ) );
    146148        $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_elementor_undo' ) );
     149        $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_content_undo' ) );
    147150        // Not deleted: _auditae_media_backup and uploads/auditae-originals/
    148151        // (0.35.0). They hold the only copy of the images as they were before
Note: See TracChangeset for help on using the changeset viewer.