settings = $settings; $this->client = $client; $this->seo = $seo; $this->log = $log; $this->actions = $actions; } /** $manual is true for the "Check for fixes" button; the cron skips a token that was rejected. */ public function run( $manual = false ) { if ( $this->settings->get_token() === '' ) { return $this->finish( 'error', 'Not connected. Paste your connection code first.', 0 ); } if ( ! $manual && $this->settings->token_rejected() ) { return array( 'level' => 'rejected', 'message' => 'Connection code not accepted.', 'new' => 0 ); } $res = $this->client->fetch_fixes(); if ( $res['code'] === 401 ) { // why: stop here. No retry, so a bad token is never hammered. return $this->finish( 'rejected', 'Connection code not accepted. Create a new one in your Clientsask dashboard.', 0 ); } if ( $res['code'] === 429 ) { return $this->finish( 'error', 'Too many checks this hour. Try again later.', 0 ); } if ( $res['code'] >= 500 ) { return $this->finish( 'error', 'Clientsask had a problem. Try again in a few minutes.', 0 ); } if ( ! $res['ok'] ) { return $this->finish( 'error', 'Could not reach Clientsask. It will try again later.', 0 ); } $this->report_setup_done(); // why: a cancelled account gets no fixes and nothing more is changed on its site (Alvin, 2026-10-23), so changes left waiting stay waiting. A server older than 0.1.6 sends no flag. if ( ! isset( $res['data']['active'] ) || $res['data']['active'] !== false ) { $this->apply_waiting(); } $clean = Clientsask_Validator::response( $res['data'] ); foreach ( $clean['rejected'] as $bad ) { if ( $bad['id'] !== null ) { $this->client->send_event( $bad['id'], 'failed', null, 'Fix was not valid: ' . $bad['reason'] ); } } $new = 0; foreach ( $clean['fixes'] as $fix ) { if ( $this->log->has_fix( $fix['id'] ) ) { continue; // already handled on an earlier check. } $this->client->send_event( $fix['id'], 'received' ); try { if ( $fix['kind'] === 'blog' ) { $this->handle_blog( $fix ); } else { $this->handle_meta( $fix ); } $new++; } catch ( Exception $e ) { $this->client->send_event( $fix['id'], 'failed', null, 'Could not process this fix.' ); } } return $this->finish( 'ok', $new > 0 ? 'Connected. ' . $new . ' new fix(es) received.' : 'Connected. No new approved fixes yet.', $new ); } /** Changes applied while Rank Math's setup was unfinished go live once it is done: tell the dashboard then, once each. */ private function report_setup_done() { // why: only Rank Math, now set up, prints these; with Rank Math switched off or swapped for Yoast nothing shows them, so nothing is reported live. if ( $this->seo->detect() !== 'rankmath' || $this->seo->needs_setup() ) { return; } foreach ( $this->log->by( 'seo', array( 'applied' ) ) as $row ) { if ( empty( $row['waits_setup'] ) || $row['plugin'] !== 'rankmath' ) { continue; } // why: the flag is cleared only once the dashboard has heard it, so a failed request is tried again on the next check. if ( empty( $row['notify'] ) || $this->client->send_event( $row['fix_id'], 'applied', $row['post_id'] ) ) { $this->log->update( $row['id'], array( 'waits_setup' => false ) ); } } } /** * In automatic mode nothing should sit in "Staged": changes staged by 0.1.5 or while review-first was on, and * any whose apply failed last time, are applied on the next check. The owner already approved each one. */ private function apply_waiting() { if ( $this->settings->review_first() ) { return; } foreach ( array_reverse( $this->log->by( 'seo', array( 'staged' ) ) ) as $row ) { $this->actions->apply_row( $row['id'] ); } } private function finish( $level, $message, $new ) { $this->settings->set_status( $level, $message ); return array( 'level' => $level, 'message' => $message, 'new' => $new ); } /** The one place a post is created. The status is fixed here and asserted. */ public static function draft_args( $fix ) { $args = array( 'post_type' => 'post', 'post_status' => 'draft', 'post_title' => sanitize_text_field( $fix['title'] ), 'post_name' => $fix['slug'], 'post_content' => wp_kses_post( $fix['html'] ), 'post_excerpt' => sanitize_text_field( $fix['metaDescription'] ), ); if ( $args['post_status'] !== 'draft' ) { throw new RuntimeException( 'Clientsask only ever creates drafts.' ); } return $args; } private function handle_blog( $fix ) { // why: wp_insert_post expects slashed data. $post_id = wp_insert_post( wp_slash( self::draft_args( $fix ) ), true ); if ( is_wp_error( $post_id ) || ! $post_id ) { throw new RuntimeException( 'wp_insert_post failed' ); } if ( $fix['jsonld'] !== '' ) { update_post_meta( $post_id, self::FAQ_META, wp_slash( $fix['jsonld'] ) ); } $this->log->add( array( 'type' => 'draft', 'status' => 'draft', 'fix_id' => $fix['id'], 'post_id' => $post_id, 'title' => $fix['title'], ) ); // The draft's own SEO title and description follow the same flow as a title fix, but stay quiet toward // the Worker: its blog fix is already "draft created". Written to a draft, they show only once it is published. if ( $fix['metaTitle'] !== '' && $fix['metaDescription'] !== '' ) { $row = $this->stage( $fix, array( 'kind' => 'post', 'id' => $post_id ), '', $fix['metaTitle'], $fix['metaDescription'], false ); if ( $row !== '' && ! $this->settings->review_first() ) { $this->actions->apply_row( $row ); } } $this->client->send_event( $fix['id'], 'draft_created', $post_id ); } private function handle_meta( $fix ) { $target = $this->seo->resolve( $fix['url'] ); if ( ! $target ) { return $this->manual( $fix, null, 'We could not match this address to a page on your site. Paste it by hand.', true ); } if ( $this->seo->detect() === '' ) { return $this->manual( $fix, $target, self::NO_SEO, true ); } $row = $this->stage( $fix, $target, $fix['url'], $fix['title'], $fix['description'], true ); // why: the owner's Approve in Clientsask is the go (Alvin, 2026-11-02), so the change is applied now. apply_row tells the // dashboard "applied" (or "waiting_setup" for Rank Math). If it could not apply, or the site reviews first, it waits for Apply. if ( $row !== '' && ( $this->settings->review_first() || ! $this->actions->apply_row( $row )['ok'] ) ) { $this->client->send_event( $fix['id'], 'staged', $target['kind'] === 'post' ? $target['id'] : null ); } } /** Returns the new log row id, or '' when there is nowhere to write it (then it is offered to copy by hand). */ private function stage( $fix, $target, $url, $title, $desc, $notify ) { $plugin = $this->seo->detect(); if ( $plugin === '' ) { $this->manual( $fix, $target, self::NO_SEO, false ); return ''; } return $this->log->add( array( 'type' => 'seo', 'status' => 'staged', 'fix_id' => $fix['id'], 'post_id' => $target['kind'] === 'post' ? $target['id'] : 0, 'target' => $target, 'url' => $url, 'plugin' => $plugin, 'new_title' => $title, 'new_desc' => $desc, 'notify' => $notify, ) ); } private function manual( $fix, $target, $reason, $notify ) { $title = isset( $fix['metaTitle'] ) ? $fix['metaTitle'] : ( isset( $fix['title'] ) ? $fix['title'] : '' ); $desc = isset( $fix['metaDescription'] ) ? $fix['metaDescription'] : ( isset( $fix['description'] ) ? $fix['description'] : '' ); if ( $title === '' && $desc === '' ) { return; } $this->log->add( array( 'type' => 'seo', 'status' => 'manual', 'fix_id' => $fix['id'], 'post_id' => $target && $target['kind'] === 'post' ? $target['id'] : 0, 'url' => isset( $fix['url'] ) ? $fix['url'] : '', 'plugin' => '', 'new_title' => $title, 'new_desc' => $desc, 'reason' => $reason, 'notify' => $notify, ) ); if ( $notify ) { $this->client->send_event( $fix['id'], 'failed', null, $reason ); } } }