prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * RestSeo — writing an article's SEO title and meta description (free). * * POST clusterlane/v1/seo/apply { post_id, title?, description? } → writes the given fields * POST clusterlane/v1/seo/undo { post_id } → within 30 min, if unchanged * * Used by the search preview in Article Inspector (with AI suggestions in Pro) * and by SEO Titles (0.98.154), where anyone may edit by hand. Moved here from * the premium RestSeoAi in 0.98.154 — same routes, same behaviour; only * seo/suggest (the AI) stays premium. * * Write policy: Clusterlane writes ONLY to custom fields (post meta / ACF), * never to an SEO plugin's storage (Yoast indexables, AIOSEO's own table …). * With an SEO plugin the text is copy-only — see SeoMeta::can_write(). * An empty title or description means "leave that field as it is". */ class RestSeo { public const UNDO_TTL = 30 * MINUTE_IN_SECONDS; public function __construct( private \Clusterlane\Core\Plugin $plugin ) {} public function register_routes(): void { $write = fn() => \Clusterlane\Core\Capabilities::rest_seo(); register_rest_route( 'clusterlane/v1', '/seo/apply', [ 'methods' => 'POST', 'callback' => [ $this, 'apply' ], 'permission_callback' => $write ] ); register_rest_route( 'clusterlane/v1', '/seo/undo', [ 'methods' => 'POST', 'callback' => [ $this, 'undo' ], 'permission_callback' => $write ] ); register_rest_route( 'clusterlane/v1', '/seo/restore', [ 'methods' => 'POST', 'callback' => [ $this, 'restore' ], 'permission_callback' => $write ] ); } public function apply( \WP_REST_Request $request ): \WP_REST_Response { $p = (array) $request->get_json_params(); $post_id = (int) ( $p['post_id'] ?? 0 ); $check = $this->can_write_post( $post_id ); if ( $check ) return $check; $src = \Clusterlane\Core\SeoMeta::source(); $title = isset( $p['title'] ) ? self::clean( (string) $p['title'] ) : ''; $desc = isset( $p['description'] ) ? self::clean( (string) $p['description'] ) : ''; if ( $title === '' && $desc === '' ) { return new \WP_REST_Response( [ 'error' => __( 'Nothing to apply.', 'clusterlane' ) ], 400 ); } $undo = [ 'user' => get_current_user_id(), 'time' => time(), 'fields' => [] ]; foreach ( [ 'title' => $title, 'desc' => $desc ] as $kind => $value ) { $key = $src[ $kind ]; if ( $value === '' || ! $key ) continue; $before = (string) get_post_meta( $post_id, $key, true ); if ( $before === $value ) continue; // unchanged — nothing to write or undo $undo['fields'][ $key ] = [ 'existed' => metadata_exists( 'post', $post_id, $key ), 'before' => $before, 'after' => $value, ]; \Clusterlane\Core\SeoMeta::keep_original( $post_id, $key, $undo['fields'][ $key ]['existed'], $before ); // only the first time (0.98.197) $this->write( $post_id, $key, $value ); } if ( $undo['fields'] ) { \Clusterlane\Core\SeoMeta::clear_check( $post_id ); // the AI review was of the old text set_transient( 'clusterlane_seo_undo_' . $post_id, $undo, self::UNDO_TTL ); \Clusterlane\Core\Activity::log( \Clusterlane\Core\Activity::META_WRITTEN, 'seo', $post_id, 0, implode( ', ', array_keys( $undo['fields'] ) ) ); } return rest_ensure_response( $this->after_write( $post_id ) + [ 'undo_until' => $undo['fields'] ? time() + self::UNDO_TTL : 0, 'written' => array_keys( $undo['fields'] ), ] ); } public function undo( \WP_REST_Request $request ): \WP_REST_Response { $p = (array) $request->get_json_params(); $post_id = (int) ( $p['post_id'] ?? 0 ); $check = $this->can_write_post( $post_id ); if ( $check ) return $check; $undo = get_transient( 'clusterlane_seo_undo_' . $post_id ); if ( ! is_array( $undo ) || empty( $undo['fields'] ) ) { return new \WP_REST_Response( [ 'error' => __( 'Nothing to undo — the 30 minutes have passed.', 'clusterlane' ) ], 409 ); } // Refuse if someone changed the field since (the editor, another tool). foreach ( $undo['fields'] as $key => $f ) { if ( (string) get_post_meta( $post_id, $key, true ) !== (string) $f['after'] ) { return new \WP_REST_Response( [ 'error' => __( 'The SEO fields have been changed since — undo would overwrite that change.', 'clusterlane' ) ], 409 ); } } foreach ( $undo['fields'] as $key => $f ) { if ( $f['existed'] ) $this->write( $post_id, $key, (string) $f['before'] ); else $this->remove( $post_id, $key ); } delete_transient( 'clusterlane_seo_undo_' . $post_id ); \Clusterlane\Core\SeoMeta::clear_check( $post_id ); // A restore was never counted as a write, so undoing it uncounts nothing (0.98.197). if ( empty( $undo['restore'] ) ) \Clusterlane\Core\Activity::undo_last( \Clusterlane\Core\Activity::META_WRITTEN, $post_id, 0, 'seo' ); return rest_ensure_response( $this->after_write( $post_id ) + [ 'undone' => true ] ); } /** * POST seo/restore { post_id } (0.98.197): the title and description from before * Clusterlane first changed them. The original is kept (restoring twice is harmless), * and the restore itself can be undone for 30 minutes, like Apply. */ public function restore( \WP_REST_Request $request ): \WP_REST_Response { $p = (array) $request->get_json_params(); $post_id = (int) ( $p['post_id'] ?? 0 ); $check = $this->can_write_post( $post_id ); if ( $check ) return $check; $o = \Clusterlane\Core\SeoMeta::original( $post_id ); if ( ! $o ) { return new \WP_REST_Response( [ 'error' => __( 'Clusterlane has not changed this article’s SEO fields — there is no original to restore.', 'clusterlane' ) ], 404 ); } $undo = [ 'user' => get_current_user_id(), 'time' => time(), 'fields' => [], 'restore' => true ]; foreach ( $o['fields'] as $key => $f ) { $now = (string) get_post_meta( $post_id, $key, true ); $orig = (string) ( $f['value'] ?? '' ); $exists = metadata_exists( 'post', $post_id, $key ); if ( $now === $orig && $exists === (bool) $f['existed'] ) continue; $undo['fields'][ $key ] = [ 'existed' => $exists, 'before' => $now, 'after' => $orig ]; if ( $f['existed'] ) $this->write( $post_id, $key, $orig ); else $this->remove( $post_id, $key ); } if ( $undo['fields'] ) { \Clusterlane\Core\SeoMeta::clear_check( $post_id ); set_transient( 'clusterlane_seo_undo_' . $post_id, $undo, self::UNDO_TTL ); } return rest_ensure_response( $this->after_write( $post_id ) + [ 'restored' => array_keys( $undo['fields'] ), 'undo_until' => $undo['fields'] ? time() + self::UNDO_TTL : 0, ] ); } /** Null when the current user may write this post's SEO fields, else the error response. */ private function can_write_post( int $post_id ): ?\WP_REST_Response { $post = get_post( $post_id ); if ( ! $post || \Clusterlane\Core\Scope::excludes( $post ) ) { return new \WP_REST_Response( [ 'error' => __( 'Article not found.', 'clusterlane' ) ], 404 ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_REST_Response( [ 'error' => __( 'You are not allowed to edit this article.', 'clusterlane' ) ], 403 ); } if ( ! \Clusterlane\Core\SeoMeta::can_write() ) { return new \WP_REST_Response( [ 'error' => \Clusterlane\Core\SeoMeta::write_note() ], 409 ); } return null; } /** One line of plain text: entities decoded, no tags, no surrounding quotes, single spaces, ≤ 300 characters. */ public static function clean( string $s ): string { $s = trim( wp_strip_all_tags( html_entity_decode( $s, ENT_QUOTES, 'UTF-8' ) ) ); $s = trim( $s, " \t\n\r\"'“”„" ); return mb_substr( (string) preg_replace( '/\s+/u', ' ', $s ), 0, 300, 'UTF-8' ); } /** ACF-aware write: update_field() keeps ACF's field reference (_name → field_x). */ private function write( int $post_id, string $key, string $value ): void { if ( function_exists( 'update_field' ) && function_exists( 'get_field_object' ) && get_field_object( $key, $post_id, false, false ) ) { update_field( $key, $value, $post_id ); } else { update_post_meta( $post_id, $key, wp_slash( $value ) ); } } private function remove( int $post_id, string $key ): void { if ( function_exists( 'delete_field' ) && function_exists( 'get_field_object' ) && get_field_object( $key, $post_id, false, false ) ) { delete_field( $key, $post_id ); } else { delete_post_meta( $post_id, $key ); } } /** Clear caches, rescan the post (SEO score) and return fresh preview + metrics. */ private function after_write( int $post_id ): array { clean_post_cache( $post_id ); $scanner = $this->plugin->get( 'scanner' ); if ( $scanner ) $scanner->scan_post( $post_id, true ); global $wpdb; $m = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id = %d", $post_id ) ); $post = get_post( $post_id ); return [ 'seo' => \Clusterlane\Core\SeoMeta::get( $post ) + [ 'original' => \Clusterlane\Core\SeoMeta::original_state( $post_id ), 'title_max' => \Clusterlane\Core\SeoMeta::TITLE_MAX, 'desc_min' => \Clusterlane\Core\SeoMeta::DESC_MIN, 'desc_max' => \Clusterlane\Core\SeoMeta::DESC_MAX, ], 'metrics' => $m ? [ 'word_count' => (int) $m->word_count, 'image_count' => (int) $m->image_count, 'h2_count' => (int) $m->h2_count, 'inbound_links' => (int) $m->inbound_links, 'outbound_links' => (int) $m->outbound_links, 'overall_score' => (int) $m->overall_score, 'seo_score' => (int) $m->seo_score, 'structure_score' => (int) $m->structure_score, 'content_class' => $m->content_class, 'last_scanned' => $m->last_scanned, ] : null, ]; } }