Changeset 3777340
- Timestamp:
- 10/31/2026 07:50:24 PM (4 hours ago)
- Location:
- auditae-ai-search-toolkit
- Files:
-
- 2 added
- 22 edited
- 1 copied
-
tags/0.38.0 (copied) (copied from auditae-ai-search-toolkit/trunk )
-
tags/0.38.0/auditae.php (modified) (4 diffs)
-
tags/0.38.0/includes/broken-links-rest.php (added)
-
tags/0.38.0/includes/elementor.php (modified) (1 diff)
-
tags/0.38.0/includes/internal-links.php (modified) (11 diffs)
-
tags/0.38.0/includes/link-index.php (modified) (11 diffs)
-
tags/0.38.0/includes/media.php (modified) (8 diffs)
-
tags/0.38.0/includes/redirects.php (modified) (1 diff)
-
tags/0.38.0/includes/rest-routes.php (modified) (28 diffs)
-
tags/0.38.0/includes/seo-meta.php (modified) (1 diff)
-
tags/0.38.0/languages/auditae-ai-search-toolkit.pot (modified) (4 diffs)
-
tags/0.38.0/readme.txt (modified) (3 diffs)
-
tags/0.38.0/uninstall.php (modified) (2 diffs)
-
trunk/auditae.php (modified) (4 diffs)
-
trunk/includes/broken-links-rest.php (added)
-
trunk/includes/elementor.php (modified) (1 diff)
-
trunk/includes/internal-links.php (modified) (11 diffs)
-
trunk/includes/link-index.php (modified) (11 diffs)
-
trunk/includes/media.php (modified) (8 diffs)
-
trunk/includes/redirects.php (modified) (1 diff)
-
trunk/includes/rest-routes.php (modified) (28 diffs)
-
trunk/includes/seo-meta.php (modified) (1 diff)
-
trunk/languages/auditae-ai-search-toolkit.pot (modified) (4 diffs)
-
trunk/readme.txt (modified) (3 diffs)
-
trunk/uninstall.php (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
auditae-ai-search-toolkit/tags/0.38.0/auditae.php
r3775803 r3777340 4 4 * Plugin URI: https://auditae.app/wordpress-ai-plugin 5 5 * 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.3 7.06 * Version: 0.38.0 7 7 * Requires at least: 6.0 8 8 * Requires PHP: 7.4 … … 18 18 defined( 'ABSPATH' ) || exit; 19 19 20 define( 'AUDITAE_VERSION', '0.3 7.0' );20 define( 'AUDITAE_VERSION', '0.38.0' ); 21 21 // REST contract version the app can check before relying on newer fields. 22 22 // Bump when a route gains or changes a field the app depends on. … … 33 33 // seo_provider; POST /posts/{id}/untrash and /elementor-restore; GET /posts 34 34 // 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()). 41 define( 'AUDITAE_API_VERSION', 7 ); 37 42 define( 'AUDITAE_PLUGIN_FILE', __FILE__ ); 38 43 define( 'AUDITAE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); … … 110 115 // dead end), so it loads after redirects.php. 111 116 require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links.php'; 117 require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links-rest.php'; 112 118 // Fan-out reads the link index (anchor phrases, stopwords) and the readiness 113 119 // helpers (builder detection), so it loads after both. -
auditae-ai-search-toolkit/tags/0.38.0/includes/elementor.php
r3775803 r3777340 1105 1105 } 1106 1106 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. 1113 define( '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 */ 1124 function 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 */ 1155 function 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 */ 1221 function 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 */ 1258 function 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 1107 1280 /** 1108 1281 * 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 92 92 // the graph is missing or out of date a background rebuild is queued 93 93 // and the response says so ('building'). 94 $status = auditae_links_index_status ();94 $status = auditae_links_index_status_catch_up(); 95 95 $index = $status['index']; 96 96 … … 117 117 $items[] = array( 118 118 'id' => (int) $p->ID, 119 'title' => get_the_title( $p ),119 'title' => auditae_rest_plain_title( $p ), 120 120 'slug' => $p->post_name, 121 121 'view_url' => get_permalink( $p ), 122 122 'outgoing_count' => (int) $stats['out'], 123 123 '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 ) ), 125 128 // False when the post sits outside the indexed window, so a 126 129 // consumer can tell "no incoming links" from "not measured". … … 147 150 // Non-null while a sliced rebuild runs: pages scanned so far. 148 151 '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, 149 156 ), 150 157 ) … … 241 248 if ( in_array( auditae_links_normalize_path( $link['href'] ), $target_keys, true ) ) { 242 249 $incoming[] = array( 250 'source' => 'content', 243 251 'source_id' => (int) $cand->ID, 244 'source_title' => get_the_title( $cand->ID ),252 'source_title' => auditae_rest_plain_title( $cand->ID ), 245 253 'anchor_text' => $link['anchor'], 246 254 'href' => $link['href'], … … 251 259 } 252 260 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 253 276 return rest_ensure_response( 254 277 array( 255 278 'post' => array( 256 279 'id' => (int) $post->ID, 257 'title' => get_the_title( $post ),280 'title' => auditae_rest_plain_title( $post ), 258 281 'slug' => $post->post_name, 259 282 ), 260 'outgoing' => $outgoing, 261 'incoming' => $incoming, 283 'outgoing' => $outgoing, 284 'incoming' => $incoming, 285 'menu_links' => $menu_hits, 262 286 // Only computed when there's nothing incoming, since that's the 263 287 // only case where a suggestion is the next action. Each one is 264 288 // pre-verified to be insertable, so AEBOT can act on it without 265 289 // 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(), 267 291 ) 268 292 ); … … 279 303 return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) ); 280 304 } 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 } 281 308 282 309 $json = $request->get_json_params(); … … 285 312 $anchor = isset( $body['anchor_text'] ) ? trim( (string) $body['anchor_text'] ) : ''; 286 313 $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 ) : ''; 289 355 290 356 // Validation, the kses/slashing dance and the save all live in 291 357 // auditae_links_insert() so this endpoint and the one-click button on 292 358 // 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 ); 294 361 if ( is_wp_error( $result ) ) { 295 362 $status = array( … … 307 374 } 308 375 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 ); 321 422 } 322 423 … … 410 511 * @return string Content with at most one new anchor. 411 512 */ 412 function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href ) { 513 function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href, &$inserted = null ) { 514 $inserted = ''; 413 515 if ( ! is_string( $content ) || '' === $content || '' === trim( (string) $anchor ) ) { 414 516 return $content; … … 443 545 // A callback, not a replacement string: esc_url() allows "$", so a 444 546 // 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; 447 551 }; 448 552 … … 482 586 } 483 587 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 } 484 593 return $replaced ? $out : $content; 485 594 } -
auditae-ai-search-toolkit/tags/0.38.0/includes/link-index.php
r3733266 r3777340 175 175 176 176 /** 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 */ 188 function 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 /** 177 215 * Queue the next build slice to run as soon as cron fires. Safe to call on 178 216 * every read: WordPress refuses a single event when one for the same hook … … 219 257 * @return array|null The freshly built index, or null when a continuation was scheduled. 220 258 */ 221 function auditae_links_index_build( ) {259 function auditae_links_index_build( $budget = null ) { 222 260 global $wpdb; 223 261 … … 271 309 } 272 310 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 ); 275 315 $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); 276 316 $total = count( $state['ids'] ); … … 475 515 'in' => 0, 476 516 'out' => 0, 517 'menu' => 0, 477 518 'known' => false, 478 519 ); 479 520 } 521 $menu = auditae_links_menu_inlinks(); 480 522 return array( 481 523 'in' => isset( $index['in'][ $post_id ] ) ? (int) $index['in'][ $post_id ] : 0, 482 524 '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, 483 527 'known' => in_array( $post_id, $index['ids'], true ), 484 528 ); 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 */ 544 function 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; 485 616 } 486 617 … … 530 661 $limit = max( 1, (int) $limit ); 531 662 $exempt = auditae_links_orphan_exempt_ids(); 663 $menu = auditae_links_menu_inlinks(); 532 664 $orphans = array(); 533 665 foreach ( $index['ids'] as $id ) { 534 if ( isset( $index['in'][ $id ] ) || i n_array( (int) $id, $exempt, true ) ) {666 if ( isset( $index['in'][ $id ] ) || isset( $menu[ (int) $id ] ) || in_array( (int) $id, $exempt, true ) ) { 535 667 continue; 536 668 } … … 556 688 } 557 689 $exempt = auditae_links_orphan_exempt_ids(); 690 $menu = auditae_links_menu_inlinks(); 558 691 $n = 0; 559 692 foreach ( $index['ids'] as $id ) { 560 if ( ! isset( $index['in'][ $id ] ) && ! i n_array( (int) $id, $exempt, true ) ) {693 if ( ! isset( $index['in'][ $id ] ) && ! isset( $menu[ (int) $id ] ) && ! in_array( (int) $id, $exempt, true ) ) { 561 694 $n++; 562 695 } … … 816 949 * @return bool Whether a link was placed. 817 950 */ 818 function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href ) { 951 function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href, &$inserted = null ) { 952 $inserted = ''; 819 953 return auditae_elementor_map_slots( 820 954 $data, 821 function ( $value, $kind, &$stop ) use ( $anchor, $href ) {955 function ( $value, $kind, &$stop ) use ( $anchor, $href, &$inserted ) { 822 956 if ( 'rich' !== $kind ) { 823 957 return $value; 824 958 } 825 $new = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href ); 959 $made = ''; 960 $new = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href, $made ); 826 961 if ( $new !== $value ) { 827 $stop = true; 962 $stop = true; 963 $inserted = $made; 828 964 } 829 965 return $new; … … 842 978 * @param string $anchor Text to wrap. 843 979 * @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). 844 981 * @return true|WP_Error 845 982 */ 846 function auditae_links_insert( $source_id, $anchor, $href ) { 983 function auditae_links_insert( $source_id, $anchor, $href, &$info = null ) { 984 $info = array(); 847 985 $source_id = (int) $source_id; 848 986 $post = get_post( $source_id ); … … 872 1010 if ( auditae_elementor_is_built( $source_id ) ) { 873 1011 $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 ) ) { 875 1014 return $not_found; 876 1015 } 1016 $info = auditae_links_insert_info( $made ); 877 1017 $result = auditae_elementor_save( $source_id, $data ); 878 1018 if ( is_wp_error( $result ) ) { … … 884 1024 885 1025 $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 ); 887 1028 if ( $updated === $original ) { 888 1029 return $not_found; 889 1030 } 1031 $info = auditae_links_insert_info( $made ); 890 1032 891 1033 // Requests authenticated by bearer token run as user 0, where kses is … … 918 1060 } 919 1061 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 */ 1068 function 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 */ 1085 function 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 920 1106 // ──────────────────────────────────────────────────────────────────── 921 1107 // Scheduled rebuild -
auditae-ai-search-toolkit/tags/0.38.0/includes/media.php
r3775803 r3777340 70 70 // references inside published post content, so it carries more risk 71 71 // 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 72 93 register_rest_route( 73 94 $namespace, … … 241 262 'media' => auditae_format_media( get_post( $id ), true ), 242 263 '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. 274 define( 'AUDITAE_OPT_ALT_UNDO', 'auditae_alt_undo' ); 275 define( '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 */ 282 function 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 */ 371 function 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, 243 426 ) 244 427 ); … … 614 797 615 798 $rewrote_posts = 0; 799 $rewrote_revs = 0; 616 800 $rewrote_meta = 0; 617 801 $leftovers = array(); … … 619 803 // Pre-filter on the size stem (a prefix of the -scaled stem), so 620 804 // 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 ); 622 806 $rewrote_meta = auditae_rewrite_media_meta_references( $old_size_stem, $url_map ); 623 807 auditae_rewrite_media_option_references( $url_map ); … … 632 816 return array( 633 817 '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, 635 823 // Posts that still mention an old file name after the rewrite (a 636 824 // reference in a form we couldn't safely rewrite). Empty is good. … … 651 839 * @param string $old_stem Filename stem (no extension) to pre-filter on. 652 840 * @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. 654 843 */ 655 function auditae_rewrite_media_references( $old_stem, array $url_map ) { 844 function auditae_rewrite_media_references( $old_stem, array $url_map, &$revisions = null ) { 845 $revisions = 0; 656 846 global $wpdb; 657 847 … … 694 884 $el_done[ $pid ] = true; 695 885 } 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 } 697 894 } 698 895 } … … 718 915 clean_post_cache( $pid ); 719 916 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 } 721 923 } 722 924 } -
auditae-ai-search-toolkit/tags/0.38.0/includes/redirects.php
r3773612 r3777340 401 401 } 402 402 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 */ 417 function 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; 403 468 } 404 469 -
auditae-ai-search-toolkit/tags/0.38.0/includes/rest-routes.php
r3775803 r3777340 113 113 register_rest_route( 114 114 $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, 115 127 '/seo/yoast/settings', 116 128 array( … … 706 718 } 707 719 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 & Tricks"), which 725 * agents then quoted back literally. 726 * 727 * @param int|WP_Post $post Post. 728 * @return string 729 */ 730 function 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' ); 708 732 } 709 733 … … 887 911 // Behaviours the app may rely on (api_version 6+). 888 912 '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 ), 889 921 ) 890 922 ); … … 903 935 'untrash', // POST /posts/{id}/untrash, GET /posts?status=trash 904 936 '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 } 905 954 ); 906 955 } … … 1026 1075 $query_type = array(); 1027 1076 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 ) ) { 1029 1078 $query_type[] = $obj->name; 1030 1079 } … … 1062 1111 'post_type' => $p->post_type, 1063 1112 'is_front_page' => auditae_rest_is_front_page( $p ), 1064 'title' => get_the_title( $p ),1113 'title' => auditae_rest_plain_title( $p ), 1065 1114 'status' => $p->post_status, 1066 1115 'date' => auditae_rest_post_date( $p ), … … 1326 1375 1327 1376 /** 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. 1332 1385 */ 1333 1386 function auditae_rest_elementor_restore( WP_REST_Request $request ) { … … 1337 1390 return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) ); 1338 1391 } 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 }1342 1392 $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' ) ) ); 1347 1400 if ( is_wp_error( $res ) ) { 1348 1401 return $res; 1349 1402 } 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 ); 1351 1409 1352 1410 $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 } 1360 1420 if ( '' !== $res['restore_id'] ) { 1361 $answer[ 'elementor_restore_id'] = $res['restore_id'];1421 $answer[ $elementor ? 'elementor_restore_id' : 'content_restore_id' ] = $res['restore_id']; 1362 1422 } 1363 1423 return rest_ensure_response( $answer ); … … 1665 1725 } 1666 1726 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 */ 1737 function 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 1667 1772 // Idempotency transient value while the create it guards is still running. 1668 1773 define( 'AUDITAE_IDEM_PENDING', 'pending' ); … … 1888 1993 $remember( 'title', $post->post_title ); 1889 1994 } 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 } 1890 2010 if ( is_string( $content ) ) { 1891 2011 $update['post_content'] = $content; 1892 $remember( 'content', $post->post_content ); 2012 if ( $full_previous ) { 2013 $remember( 'content', $post->post_content ); 2014 } 1893 2015 } 1894 2016 $replaced = null; … … 1896 2018 if ( is_array( $replacements ) ) { 1897 2019 $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 } 1902 2026 } 1903 2027 // Elementor pages are saved inside; plain posts hand back the new … … 1992 2116 } 1993 2117 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 ); 1995 2130 1996 2131 $answer = array( 1997 2132 'post_id' => $post_id, 1998 2133 '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, 2000 2137 'edit_url' => auditae_rest_edit_url( $post_id ), 2001 2138 'builder' => auditae_rest_builder( get_post( $post_id ) ), … … 2011 2148 if ( '' !== $elementor_undo ) { 2012 2149 $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; 2013 2161 } 2014 2162 return rest_ensure_response( $answer ); … … 2285 2433 } 2286 2434 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 ) ) ); 2287 2438 $map = auditae_post_seo_meta_map(); 2288 2439 $snapshot = array(); 2289 foreach ( array( 'yoast', 'rankmath' )as $engine ) {2440 foreach ( $engines as $engine ) { 2290 2441 $snapshot[ $engine ] = array(); 2291 2442 foreach ( $map[ $engine ] as $logical => $meta_key ) { 2292 2443 $snapshot[ $engine ][ $logical ] = get_post_meta( $post_id, $meta_key, true ); 2293 2444 } 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']; 2294 2450 } 2295 2451 2296 2452 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 } 2297 2460 return rest_ensure_response( 2298 2461 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, 2305 2468 ) 2306 2469 ); … … 2309 2472 // SEO fields are single strings; an array or object would be stored 2310 2473 // 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(); 2312 2477 foreach ( array( 'yoast', 'rankmath' ) as $engine ) { 2313 2478 $requested = $request->get_param( $engine ); … … 2320 2485 } 2321 2486 } 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 } 2322 2505 } 2323 2506 2324 2507 $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 ) { 2332 2511 $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; 2340 2521 } 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 ); 2347 2535 } 2348 2536 } … … 2351 2539 return new WP_Error( 2352 2540 '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.', 2354 2542 array( 'status' => 400 ) 2355 2543 ); … … 2421 2609 'yoast_active' => false, 2422 2610 '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' ) ), 2424 2614 'output' => $output, 2425 2615 ) … … 2435 2625 } 2436 2626 $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 } 2437 2638 if ( empty( $fields ) ) { 2438 2639 return new WP_Error( … … 2537 2738 ); 2538 2739 } 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 } 2539 2753 $res = auditae_faq_save( $post_id, $qas ); 2540 2754 if ( 0 === $res['applied'] ) { … … 2691 2905 ); 2692 2906 } 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 } 2693 2946 $res = auditae_post_schema_save( $post_id, $schemas ); 2694 2947 if ( ! empty( $res['error'] ) ) { … … 2796 3049 ); 2797 3050 } 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 } 2798 3072 $res = auditae_org_schema_save( $schemas ); 2799 3073 if ( ! empty( $res['error'] ) ) { … … 2842 3116 // { @type: [@id, …] } maps: an empty PHP array would encode as [] and 2843 3117 // 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 ) { 2845 3140 if ( is_array( $probe ) && array_key_exists( $key, $probe ) && is_array( $probe[ $key ] ) && empty( $probe[ $key ] ) ) { 2846 3141 $probe[ $key ] = new stdClass(); … … 2848 3143 } 2849 3144 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 */ 3152 function 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; 2850 3162 } 2851 3163 … … 2884 3196 ); 2885 3197 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 ) 2891 3205 ) 2892 3206 ); … … 2920 3234 ); 2921 3235 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 */ 3257 function 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; 2930 3266 } 2931 3267 … … 3010 3346 break; 3011 3347 } 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 ); 3012 3375 } 3013 3376 … … 3037 3400 ); 3038 3401 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 ); 3046 3414 } 3047 3415 -
auditae-ai-search-toolkit/tags/0.38.0/includes/seo-meta.php
r3775803 r3777340 317 317 return 1 === preg_match( $pattern, (string) $html ); 318 318 } 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 */ 330 function 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 */ 344 function 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 */ 393 function 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 */ 423 function 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 */ 474 function 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 */ 553 function 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 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: AuditAE – MCP Server & AI SEO 0.3 7.0\n"5 "Project-Id-Version: AuditAE – MCP Server & AI SEO 0.38.0\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/auditae-ai-search-toolkit\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026-10-3 0T14:57:49+00:00\n"12 "POT-Creation-Date: 2026-10-31T19:49:26+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 1958 1958 msgstr[1] "" 1959 1959 1960 #: includes/link-index.php: 8501960 #: includes/link-index.php:988 1961 1961 msgid "Post not found." 1962 1962 msgstr "" 1963 1963 1964 #: includes/link-index.php: 8561964 #: includes/link-index.php:994 1965 1965 msgid "Anchor text and target URL are both required." 1966 1966 msgstr "" 1967 1967 1968 #: includes/link-index.php: 8621968 #: includes/link-index.php:1000 1969 1969 msgid "Internal links only — the target must be on this site." 1970 1970 msgstr "" 1971 1971 1972 #: includes/link-index.php: 8671972 #: includes/link-index.php:1005 1973 1973 msgid "That phrase is no longer available as plain text in the post — it may have been edited or already linked." 1974 1974 msgstr "" … … 2247 2247 msgstr "" 2248 2248 2249 #: includes/media.php:1 0402250 #: includes/media.php:1 0952249 #: includes/media.php:1242 2250 #: includes/media.php:1297 2251 2251 msgid "Rename file" 2252 2252 msgstr "" 2253 2253 2254 #: includes/media.php:1 0562254 #: includes/media.php:1258 2255 2255 msgid "You don't have permission to rename this file." 2256 2256 msgstr "" 2257 2257 2258 #: includes/media.php:1 0932258 #: includes/media.php:1295 2259 2259 msgid "New file name (without extension). Renames every size and updates references in your posts:" 2260 2260 msgstr "" 2261 2261 2262 #: includes/media.php:1 0942262 #: includes/media.php:1296 2263 2263 msgid "Renaming…" 2264 2264 msgstr "" 2265 2265 2266 #: includes/media.php:1 0962266 #: includes/media.php:1298 2267 2267 msgid "Rename failed." 2268 2268 msgstr "" -
auditae-ai-search-toolkit/tags/0.38.0/readme.txt
r3775803 r3777340 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 0.3 7.07 Stable tag: 0.38.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 129 129 characters, so older entries are kept in the plugin's development history 130 130 rather 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. 131 139 132 140 = 0.37.0 = … … 154 162 * 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. 155 163 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 168 164 == Upgrade Notice == 165 166 = 0.38.0 = 167 Safer 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. 169 168 170 169 = 0.37.0 = -
auditae-ai-search-toolkit/tags/0.38.0/uninstall.php
r3775803 r3777340 73 73 'auditae_redirect_sources', 74 74 'auditae_org_fill_gap', 75 // 0.38.0: bulk alt-text undo tokens. 76 'auditae_alt_undo', 75 77 ); 76 78 … … 145 147 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_seo' ) ); 146 148 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_elementor_undo' ) ); 149 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_content_undo' ) ); 147 150 // Not deleted: _auditae_media_backup and uploads/auditae-originals/ 148 151 // (0.35.0). They hold the only copy of the images as they were before -
auditae-ai-search-toolkit/trunk/auditae.php
r3775803 r3777340 4 4 * Plugin URI: https://auditae.app/wordpress-ai-plugin 5 5 * 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.3 7.06 * Version: 0.38.0 7 7 * Requires at least: 6.0 8 8 * Requires PHP: 7.4 … … 18 18 defined( 'ABSPATH' ) || exit; 19 19 20 define( 'AUDITAE_VERSION', '0.3 7.0' );20 define( 'AUDITAE_VERSION', '0.38.0' ); 21 21 // REST contract version the app can check before relying on newer fields. 22 22 // Bump when a route gains or changes a field the app depends on. … … 33 33 // seo_provider; POST /posts/{id}/untrash and /elementor-restore; GET /posts 34 34 // 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()). 41 define( 'AUDITAE_API_VERSION', 7 ); 37 42 define( 'AUDITAE_PLUGIN_FILE', __FILE__ ); 38 43 define( 'AUDITAE_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); … … 110 115 // dead end), so it loads after redirects.php. 111 116 require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links.php'; 117 require_once AUDITAE_PLUGIN_DIR . 'includes/broken-links-rest.php'; 112 118 // Fan-out reads the link index (anchor phrases, stopwords) and the readiness 113 119 // helpers (builder detection), so it loads after both. -
auditae-ai-search-toolkit/trunk/includes/elementor.php
r3775803 r3777340 1105 1105 } 1106 1106 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. 1113 define( '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 */ 1124 function 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 */ 1155 function 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 */ 1221 function 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 */ 1258 function 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 1107 1280 /** 1108 1281 * The post_content mirror Elementor's own save would write for this tree -
auditae-ai-search-toolkit/trunk/includes/internal-links.php
r3733266 r3777340 92 92 // the graph is missing or out of date a background rebuild is queued 93 93 // and the response says so ('building'). 94 $status = auditae_links_index_status ();94 $status = auditae_links_index_status_catch_up(); 95 95 $index = $status['index']; 96 96 … … 117 117 $items[] = array( 118 118 'id' => (int) $p->ID, 119 'title' => get_the_title( $p ),119 'title' => auditae_rest_plain_title( $p ), 120 120 'slug' => $p->post_name, 121 121 'view_url' => get_permalink( $p ), 122 122 'outgoing_count' => (int) $stats['out'], 123 123 '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 ) ), 125 128 // False when the post sits outside the indexed window, so a 126 129 // consumer can tell "no incoming links" from "not measured". … … 147 150 // Non-null while a sliced rebuild runs: pages scanned so far. 148 151 '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, 149 156 ), 150 157 ) … … 241 248 if ( in_array( auditae_links_normalize_path( $link['href'] ), $target_keys, true ) ) { 242 249 $incoming[] = array( 250 'source' => 'content', 243 251 'source_id' => (int) $cand->ID, 244 'source_title' => get_the_title( $cand->ID ),252 'source_title' => auditae_rest_plain_title( $cand->ID ), 245 253 'anchor_text' => $link['anchor'], 246 254 'href' => $link['href'], … … 251 259 } 252 260 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 253 276 return rest_ensure_response( 254 277 array( 255 278 'post' => array( 256 279 'id' => (int) $post->ID, 257 'title' => get_the_title( $post ),280 'title' => auditae_rest_plain_title( $post ), 258 281 'slug' => $post->post_name, 259 282 ), 260 'outgoing' => $outgoing, 261 'incoming' => $incoming, 283 'outgoing' => $outgoing, 284 'incoming' => $incoming, 285 'menu_links' => $menu_hits, 262 286 // Only computed when there's nothing incoming, since that's the 263 287 // only case where a suggestion is the next action. Each one is 264 288 // pre-verified to be insertable, so AEBOT can act on it without 265 289 // 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(), 267 291 ) 268 292 ); … … 279 303 return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) ); 280 304 } 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 } 281 308 282 309 $json = $request->get_json_params(); … … 285 312 $anchor = isset( $body['anchor_text'] ) ? trim( (string) $body['anchor_text'] ) : ''; 286 313 $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 ) : ''; 289 355 290 356 // Validation, the kses/slashing dance and the save all live in 291 357 // auditae_links_insert() so this endpoint and the one-click button on 292 358 // 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 ); 294 361 if ( is_wp_error( $result ) ) { 295 362 $status = array( … … 307 374 } 308 375 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 ); 321 422 } 322 423 … … 410 511 * @return string Content with at most one new anchor. 411 512 */ 412 function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href ) { 513 function auditae_wrap_first_anchor_occurrence( $content, $anchor, $href, &$inserted = null ) { 514 $inserted = ''; 413 515 if ( ! is_string( $content ) || '' === $content || '' === trim( (string) $anchor ) ) { 414 516 return $content; … … 443 545 // A callback, not a replacement string: esc_url() allows "$", so a 444 546 // 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; 447 551 }; 448 552 … … 482 586 } 483 587 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 } 484 593 return $replaced ? $out : $content; 485 594 } -
auditae-ai-search-toolkit/trunk/includes/link-index.php
r3733266 r3777340 175 175 176 176 /** 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 */ 188 function 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 /** 177 215 * Queue the next build slice to run as soon as cron fires. Safe to call on 178 216 * every read: WordPress refuses a single event when one for the same hook … … 219 257 * @return array|null The freshly built index, or null when a continuation was scheduled. 220 258 */ 221 function auditae_links_index_build( ) {259 function auditae_links_index_build( $budget = null ) { 222 260 global $wpdb; 223 261 … … 271 309 } 272 310 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 ); 275 315 $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); 276 316 $total = count( $state['ids'] ); … … 475 515 'in' => 0, 476 516 'out' => 0, 517 'menu' => 0, 477 518 'known' => false, 478 519 ); 479 520 } 521 $menu = auditae_links_menu_inlinks(); 480 522 return array( 481 523 'in' => isset( $index['in'][ $post_id ] ) ? (int) $index['in'][ $post_id ] : 0, 482 524 '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, 483 527 'known' => in_array( $post_id, $index['ids'], true ), 484 528 ); 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 */ 544 function 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; 485 616 } 486 617 … … 530 661 $limit = max( 1, (int) $limit ); 531 662 $exempt = auditae_links_orphan_exempt_ids(); 663 $menu = auditae_links_menu_inlinks(); 532 664 $orphans = array(); 533 665 foreach ( $index['ids'] as $id ) { 534 if ( isset( $index['in'][ $id ] ) || i n_array( (int) $id, $exempt, true ) ) {666 if ( isset( $index['in'][ $id ] ) || isset( $menu[ (int) $id ] ) || in_array( (int) $id, $exempt, true ) ) { 535 667 continue; 536 668 } … … 556 688 } 557 689 $exempt = auditae_links_orphan_exempt_ids(); 690 $menu = auditae_links_menu_inlinks(); 558 691 $n = 0; 559 692 foreach ( $index['ids'] as $id ) { 560 if ( ! isset( $index['in'][ $id ] ) && ! i n_array( (int) $id, $exempt, true ) ) {693 if ( ! isset( $index['in'][ $id ] ) && ! isset( $menu[ (int) $id ] ) && ! in_array( (int) $id, $exempt, true ) ) { 561 694 $n++; 562 695 } … … 816 949 * @return bool Whether a link was placed. 817 950 */ 818 function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href ) { 951 function auditae_elementor_wrap_first_anchor( array &$data, $anchor, $href, &$inserted = null ) { 952 $inserted = ''; 819 953 return auditae_elementor_map_slots( 820 954 $data, 821 function ( $value, $kind, &$stop ) use ( $anchor, $href ) {955 function ( $value, $kind, &$stop ) use ( $anchor, $href, &$inserted ) { 822 956 if ( 'rich' !== $kind ) { 823 957 return $value; 824 958 } 825 $new = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href ); 959 $made = ''; 960 $new = auditae_wrap_first_anchor_occurrence( $value, $anchor, $href, $made ); 826 961 if ( $new !== $value ) { 827 $stop = true; 962 $stop = true; 963 $inserted = $made; 828 964 } 829 965 return $new; … … 842 978 * @param string $anchor Text to wrap. 843 979 * @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). 844 981 * @return true|WP_Error 845 982 */ 846 function auditae_links_insert( $source_id, $anchor, $href ) { 983 function auditae_links_insert( $source_id, $anchor, $href, &$info = null ) { 984 $info = array(); 847 985 $source_id = (int) $source_id; 848 986 $post = get_post( $source_id ); … … 872 1010 if ( auditae_elementor_is_built( $source_id ) ) { 873 1011 $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 ) ) { 875 1014 return $not_found; 876 1015 } 1016 $info = auditae_links_insert_info( $made ); 877 1017 $result = auditae_elementor_save( $source_id, $data ); 878 1018 if ( is_wp_error( $result ) ) { … … 884 1024 885 1025 $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 ); 887 1028 if ( $updated === $original ) { 888 1029 return $not_found; 889 1030 } 1031 $info = auditae_links_insert_info( $made ); 890 1032 891 1033 // Requests authenticated by bearer token run as user 0, where kses is … … 918 1060 } 919 1061 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 */ 1068 function 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 */ 1085 function 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 920 1106 // ──────────────────────────────────────────────────────────────────── 921 1107 // Scheduled rebuild -
auditae-ai-search-toolkit/trunk/includes/media.php
r3775803 r3777340 70 70 // references inside published post content, so it carries more risk 71 71 // 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 72 93 register_rest_route( 73 94 $namespace, … … 241 262 'media' => auditae_format_media( get_post( $id ), true ), 242 263 '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. 274 define( 'AUDITAE_OPT_ALT_UNDO', 'auditae_alt_undo' ); 275 define( '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 */ 282 function 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 */ 371 function 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, 243 426 ) 244 427 ); … … 614 797 615 798 $rewrote_posts = 0; 799 $rewrote_revs = 0; 616 800 $rewrote_meta = 0; 617 801 $leftovers = array(); … … 619 803 // Pre-filter on the size stem (a prefix of the -scaled stem), so 620 804 // 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 ); 622 806 $rewrote_meta = auditae_rewrite_media_meta_references( $old_size_stem, $url_map ); 623 807 auditae_rewrite_media_option_references( $url_map ); … … 632 816 return array( 633 817 '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, 635 823 // Posts that still mention an old file name after the rewrite (a 636 824 // reference in a form we couldn't safely rewrite). Empty is good. … … 651 839 * @param string $old_stem Filename stem (no extension) to pre-filter on. 652 840 * @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. 654 843 */ 655 function auditae_rewrite_media_references( $old_stem, array $url_map ) { 844 function auditae_rewrite_media_references( $old_stem, array $url_map, &$revisions = null ) { 845 $revisions = 0; 656 846 global $wpdb; 657 847 … … 694 884 $el_done[ $pid ] = true; 695 885 } 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 } 697 894 } 698 895 } … … 718 915 clean_post_cache( $pid ); 719 916 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 } 721 923 } 722 924 } -
auditae-ai-search-toolkit/trunk/includes/redirects.php
r3773612 r3777340 401 401 } 402 402 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 */ 417 function 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; 403 468 } 404 469 -
auditae-ai-search-toolkit/trunk/includes/rest-routes.php
r3775803 r3777340 113 113 register_rest_route( 114 114 $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, 115 127 '/seo/yoast/settings', 116 128 array( … … 706 718 } 707 719 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 & Tricks"), which 725 * agents then quoted back literally. 726 * 727 * @param int|WP_Post $post Post. 728 * @return string 729 */ 730 function 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' ); 708 732 } 709 733 … … 887 911 // Behaviours the app may rely on (api_version 6+). 888 912 '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 ), 889 921 ) 890 922 ); … … 903 935 'untrash', // POST /posts/{id}/untrash, GET /posts?status=trash 904 936 '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 } 905 954 ); 906 955 } … … 1026 1075 $query_type = array(); 1027 1076 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 ) ) { 1029 1078 $query_type[] = $obj->name; 1030 1079 } … … 1062 1111 'post_type' => $p->post_type, 1063 1112 'is_front_page' => auditae_rest_is_front_page( $p ), 1064 'title' => get_the_title( $p ),1113 'title' => auditae_rest_plain_title( $p ), 1065 1114 'status' => $p->post_status, 1066 1115 'date' => auditae_rest_post_date( $p ), … … 1326 1375 1327 1376 /** 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. 1332 1385 */ 1333 1386 function auditae_rest_elementor_restore( WP_REST_Request $request ) { … … 1337 1390 return new WP_Error( 'auditae_not_found', 'post not found', array( 'status' => 404 ) ); 1338 1391 } 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 }1342 1392 $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' ) ) ); 1347 1400 if ( is_wp_error( $res ) ) { 1348 1401 return $res; 1349 1402 } 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 ); 1351 1409 1352 1410 $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 } 1360 1420 if ( '' !== $res['restore_id'] ) { 1361 $answer[ 'elementor_restore_id'] = $res['restore_id'];1421 $answer[ $elementor ? 'elementor_restore_id' : 'content_restore_id' ] = $res['restore_id']; 1362 1422 } 1363 1423 return rest_ensure_response( $answer ); … … 1665 1725 } 1666 1726 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 */ 1737 function 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 1667 1772 // Idempotency transient value while the create it guards is still running. 1668 1773 define( 'AUDITAE_IDEM_PENDING', 'pending' ); … … 1888 1993 $remember( 'title', $post->post_title ); 1889 1994 } 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 } 1890 2010 if ( is_string( $content ) ) { 1891 2011 $update['post_content'] = $content; 1892 $remember( 'content', $post->post_content ); 2012 if ( $full_previous ) { 2013 $remember( 'content', $post->post_content ); 2014 } 1893 2015 } 1894 2016 $replaced = null; … … 1896 2018 if ( is_array( $replacements ) ) { 1897 2019 $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 } 1902 2026 } 1903 2027 // Elementor pages are saved inside; plain posts hand back the new … … 1992 2116 } 1993 2117 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 ); 1995 2130 1996 2131 $answer = array( 1997 2132 'post_id' => $post_id, 1998 2133 '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, 2000 2137 'edit_url' => auditae_rest_edit_url( $post_id ), 2001 2138 'builder' => auditae_rest_builder( get_post( $post_id ) ), … … 2011 2148 if ( '' !== $elementor_undo ) { 2012 2149 $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; 2013 2161 } 2014 2162 return rest_ensure_response( $answer ); … … 2285 2433 } 2286 2434 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 ) ) ); 2287 2438 $map = auditae_post_seo_meta_map(); 2288 2439 $snapshot = array(); 2289 foreach ( array( 'yoast', 'rankmath' )as $engine ) {2440 foreach ( $engines as $engine ) { 2290 2441 $snapshot[ $engine ] = array(); 2291 2442 foreach ( $map[ $engine ] as $logical => $meta_key ) { 2292 2443 $snapshot[ $engine ][ $logical ] = get_post_meta( $post_id, $meta_key, true ); 2293 2444 } 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']; 2294 2450 } 2295 2451 2296 2452 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 } 2297 2460 return rest_ensure_response( 2298 2461 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, 2305 2468 ) 2306 2469 ); … … 2309 2472 // SEO fields are single strings; an array or object would be stored 2310 2473 // 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(); 2312 2477 foreach ( array( 'yoast', 'rankmath' ) as $engine ) { 2313 2478 $requested = $request->get_param( $engine ); … … 2320 2485 } 2321 2486 } 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 } 2322 2505 } 2323 2506 2324 2507 $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 ) { 2332 2511 $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; 2340 2521 } 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 ); 2347 2535 } 2348 2536 } … … 2351 2539 return new WP_Error( 2352 2540 '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.', 2354 2542 array( 'status' => 400 ) 2355 2543 ); … … 2421 2609 'yoast_active' => false, 2422 2610 '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' ) ), 2424 2614 'output' => $output, 2425 2615 ) … … 2435 2625 } 2436 2626 $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 } 2437 2638 if ( empty( $fields ) ) { 2438 2639 return new WP_Error( … … 2537 2738 ); 2538 2739 } 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 } 2539 2753 $res = auditae_faq_save( $post_id, $qas ); 2540 2754 if ( 0 === $res['applied'] ) { … … 2691 2905 ); 2692 2906 } 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 } 2693 2946 $res = auditae_post_schema_save( $post_id, $schemas ); 2694 2947 if ( ! empty( $res['error'] ) ) { … … 2796 3049 ); 2797 3050 } 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 } 2798 3072 $res = auditae_org_schema_save( $schemas ); 2799 3073 if ( ! empty( $res['error'] ) ) { … … 2842 3116 // { @type: [@id, …] } maps: an empty PHP array would encode as [] and 2843 3117 // 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 ) { 2845 3140 if ( is_array( $probe ) && array_key_exists( $key, $probe ) && is_array( $probe[ $key ] ) && empty( $probe[ $key ] ) ) { 2846 3141 $probe[ $key ] = new stdClass(); … … 2848 3143 } 2849 3144 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 */ 3152 function 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; 2850 3162 } 2851 3163 … … 2884 3196 ); 2885 3197 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 ) 2891 3205 ) 2892 3206 ); … … 2920 3234 ); 2921 3235 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 */ 3257 function 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; 2930 3266 } 2931 3267 … … 3010 3346 break; 3011 3347 } 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 ); 3012 3375 } 3013 3376 … … 3037 3400 ); 3038 3401 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 ); 3046 3414 } 3047 3415 -
auditae-ai-search-toolkit/trunk/includes/seo-meta.php
r3775803 r3777340 317 317 return 1 === preg_match( $pattern, (string) $html ); 318 318 } 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 */ 330 function 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 */ 344 function 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 */ 393 function 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 */ 423 function 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 */ 474 function 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 */ 553 function 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 3 3 msgid "" 4 4 msgstr "" 5 "Project-Id-Version: AuditAE – MCP Server & AI SEO 0.3 7.0\n"5 "Project-Id-Version: AuditAE – MCP Server & AI SEO 0.38.0\n" 6 6 "Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/auditae-ai-search-toolkit\n" 7 7 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" … … 10 10 "Content-Type: text/plain; charset=UTF-8\n" 11 11 "Content-Transfer-Encoding: 8bit\n" 12 "POT-Creation-Date: 2026-10-3 0T14:57:49+00:00\n"12 "POT-Creation-Date: 2026-10-31T19:49:26+00:00\n" 13 13 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 14 14 "X-Generator: WP-CLI 2.12.0\n" … … 1958 1958 msgstr[1] "" 1959 1959 1960 #: includes/link-index.php: 8501960 #: includes/link-index.php:988 1961 1961 msgid "Post not found." 1962 1962 msgstr "" 1963 1963 1964 #: includes/link-index.php: 8561964 #: includes/link-index.php:994 1965 1965 msgid "Anchor text and target URL are both required." 1966 1966 msgstr "" 1967 1967 1968 #: includes/link-index.php: 8621968 #: includes/link-index.php:1000 1969 1969 msgid "Internal links only — the target must be on this site." 1970 1970 msgstr "" 1971 1971 1972 #: includes/link-index.php: 8671972 #: includes/link-index.php:1005 1973 1973 msgid "That phrase is no longer available as plain text in the post — it may have been edited or already linked." 1974 1974 msgstr "" … … 2247 2247 msgstr "" 2248 2248 2249 #: includes/media.php:1 0402250 #: includes/media.php:1 0952249 #: includes/media.php:1242 2250 #: includes/media.php:1297 2251 2251 msgid "Rename file" 2252 2252 msgstr "" 2253 2253 2254 #: includes/media.php:1 0562254 #: includes/media.php:1258 2255 2255 msgid "You don't have permission to rename this file." 2256 2256 msgstr "" 2257 2257 2258 #: includes/media.php:1 0932258 #: includes/media.php:1295 2259 2259 msgid "New file name (without extension). Renames every size and updates references in your posts:" 2260 2260 msgstr "" 2261 2261 2262 #: includes/media.php:1 0942262 #: includes/media.php:1296 2263 2263 msgid "Renaming…" 2264 2264 msgstr "" 2265 2265 2266 #: includes/media.php:1 0962266 #: includes/media.php:1298 2267 2267 msgid "Rename failed." 2268 2268 msgstr "" -
auditae-ai-search-toolkit/trunk/readme.txt
r3775803 r3777340 5 5 Tested up to: 7.1 6 6 Requires PHP: 7.4 7 Stable tag: 0.3 7.07 Stable tag: 0.38.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 129 129 characters, so older entries are kept in the plugin's development history 130 130 rather 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. 131 139 132 140 = 0.37.0 = … … 154 162 * 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. 155 163 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 168 164 == Upgrade Notice == 165 166 = 0.38.0 = 167 Safer 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. 169 168 170 169 = 0.37.0 = -
auditae-ai-search-toolkit/trunk/uninstall.php
r3775803 r3777340 73 73 'auditae_redirect_sources', 74 74 'auditae_org_fill_gap', 75 // 0.38.0: bulk alt-text undo tokens. 76 'auditae_alt_undo', 75 77 ); 76 78 … … 145 147 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_seo' ) ); 146 148 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_elementor_undo' ) ); 149 $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_auditae_content_undo' ) ); 147 150 // Not deleted: _auditae_media_backup and uploads/auditae-originals/ 148 151 // (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.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)