PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
thinkrank / includes / api / class-snippets-endpoint.php

class-snippets-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.10.0, at includes/api/class-snippets-endpoint.php

681 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bulk Snippets REST endpoints.
4 *
5 * Every post's SEO title, meta description, focus keyword and index state for
6 * one post type, filterable by what is wrong with it, plus batch save and a
7 * per-post AI suggestion that is never written without the user saving (#727).
8 *
9 * @package ThinkRank
10 * @subpackage API
11 * @since 2.8.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\API;
17
18 use ThinkRank\Admin\Metabox_Manager;
19 use ThinkRank\AI\Metadata_Generator;
20 use ThinkRank\Core\Capability_Manager;
21 use ThinkRank\Core\Seo_Text;
22 use ThinkRank\Core\Settings;
23 use ThinkRank\SEO\Duplicate_Snippets;
24 use ThinkRank\SEO\Focus_Keywords;
25 use ThinkRank\SEO\Global_SEO_Post_Types;
26 use ThinkRank\SEO\Snippet_Index;
27 use ThinkRank\SEO\Snippet_Issues;
28 use WP_Error;
29 use WP_REST_Request;
30 use WP_REST_Response;
31
32 // Prevent direct access.
33 if (!defined('ABSPATH')) {
34 exit;
35 }
36
37 /**
38 * Bulk Snippets endpoints.
39 *
40 * Lives under `global-seo/` rather than at `/snippets`: the screen is part of
41 * the Bulk SEO Optimization section, and the Role Manager maps that route
42 * prefix to the section's capability, so a role granted the section gets the
43 * screen and one denied it does not — with no second mapping to keep in step.
44 *
45 * @since 2.8.0
46 */
47 class Snippets_Endpoint {
48
49 /**
50 * API namespace.
51 */
52 private const NAMESPACE = 'thinkrank/v1';
53
54 /**
55 * Route base.
56 */
57 private const REST_BASE = 'global-seo/snippets';
58
59 /**
60 * Largest page the list will serve.
61 */
62 private const PER_PAGE_MAX = 100;
63
64 /**
65 * Largest batch a single save will accept.
66 */
67 private const SAVE_MAX = 100;
68
69 /**
70 * How many rows one bulk AI-fill run may generate in the free plugin.
71 *
72 * Each row is a paid request against the site's own key, so an accidental
73 * "select all" on 4,000 posts should not be able to spend it. Pro raises
74 * this through the `thinkrank_bulk_ai_fill_cap` filter.
75 */
76 public const DEFAULT_AI_FILL_CAP = 25;
77
78 /**
79 * Longest value a snippet field accepts. Far past anything useful — it
80 * exists so the endpoint cannot be used to write megabytes of meta.
81 */
82 private const FIELD_MAX_LENGTH = 1000;
83
84 /**
85 * Least post content, in characters, worth asking AI to describe. Matches
86 * the floor the editor's own Generate with AI button enforces.
87 */
88 private const MIN_CONTENT_LENGTH = 50;
89
90 /**
91 * Post statuses each status filter covers. Public so the
92 * list-snippet-issues ability filters by exactly the same sets.
93 */
94 public const STATUS_SETS = [
95 'publish' => ['publish'],
96 'draft' => ['draft', 'pending', 'future'],
97 'any' => ['publish', 'future', 'draft', 'pending', 'private'],
98 ];
99
100 /**
101 * Register routes.
102 *
103 * @return void
104 */
105 public function register_routes(): void {
106 register_rest_route(self::NAMESPACE, '/' . self::REST_BASE, [
107 [
108 'methods' => 'GET',
109 'callback' => [$this, 'list_snippets'],
110 'permission_callback' => [$this, 'check_permissions'],
111 'args' => [
112 'post_type' => [
113 'type' => 'string',
114 'required' => true,
115 'sanitize_callback' => 'sanitize_key',
116 ],
117 'issue' => [
118 'type' => 'string',
119 'default' => '',
120 'enum' => array_merge([''], Snippet_Issues::all()),
121 'validate_callback' => 'rest_validate_request_arg',
122 ],
123 'status' => [
124 'type' => 'string',
125 'default' => 'publish',
126 'enum' => array_keys(self::STATUS_SETS),
127 'validate_callback' => 'rest_validate_request_arg',
128 ],
129 'search' => [
130 'type' => 'string',
131 'default' => '',
132 'sanitize_callback' => 'sanitize_text_field',
133 ],
134 'page' => [
135 'type' => 'integer',
136 'default' => 1,
137 'minimum' => 1,
138 'sanitize_callback' => 'absint',
139 ],
140 'per_page' => [
141 'type' => 'integer',
142 'default' => 20,
143 'minimum' => 1,
144 'maximum' => self::PER_PAGE_MAX,
145 'sanitize_callback' => 'absint',
146 ],
147 ],
148 ],
149 [
150 'methods' => 'POST',
151 'callback' => [$this, 'save_snippets'],
152 'permission_callback' => [$this, 'check_permissions'],
153 'args' => [
154 'items' => [
155 'type' => 'array',
156 'required' => true,
157 'maxItems' => self::SAVE_MAX,
158 'items' => [
159 'type' => 'object',
160 'properties' => [
161 'post_id' => ['type' => 'integer'],
162 'title' => ['type' => 'string'],
163 'description' => ['type' => 'string'],
164 'focus_keyword' => ['type' => 'string'],
165 'original' => ['type' => 'object'],
166 ],
167 ],
168 'validate_callback' => 'rest_validate_request_arg',
169 ],
170 ],
171 ],
172 ]);
173
174 register_rest_route(self::NAMESPACE, '/' . self::REST_BASE . '/duplicates', [
175 'methods' => 'GET',
176 'callback' => [$this, 'list_duplicates'],
177 'permission_callback' => [$this, 'check_permissions'],
178 'args' => [
179 'refresh' => [
180 'type' => 'boolean',
181 'default' => false,
182 ],
183 ],
184 ]);
185
186 register_rest_route(self::NAMESPACE, '/' . self::REST_BASE . '/(?P<id>\d+)/generate', [
187 'methods' => 'POST',
188 'callback' => [$this, 'generate_suggestion'],
189 'permission_callback' => [$this, 'check_permissions'],
190 'args' => [
191 'id' => [
192 'type' => 'integer',
193 'required' => true,
194 'sanitize_callback' => 'absint',
195 ],
196 ],
197 ]);
198 }
199
200 /**
201 * Section permission. Per-post edit rights are checked per row.
202 *
203 * @return bool
204 */
205 public function check_permissions(): bool {
206 return Capability_Manager::current_user_can('thinkrank_global_seo');
207 }
208
209 /**
210 * How many rows one bulk AI-fill run may generate.
211 *
212 * @since 2.8.0
213 *
214 * @return int
215 */
216 public static function ai_fill_cap(): int {
217 /**
218 * Filters how many rows one Bulk Snippets AI-fill run may generate.
219 *
220 * @since 2.8.0
221 *
222 * @param int $cap Maximum rows per run. 0 or less means unlimited.
223 */
224 return (int) apply_filters('thinkrank_bulk_ai_fill_cap', self::DEFAULT_AI_FILL_CAP);
225 }
226
227 /**
228 * Is the selected AI provider configured?
229 *
230 * Settings::has_ai_provider_configured() is the one answer to this across
231 * the plugin, and it knows what a per-provider key check cannot: an
232 * OpenAI-compatible endpoint is configured by URL and model rather than by
233 * key, so a key test reports it unconfigured.
234 *
235 * This used to probe with method_exists() and fall back to the selected
236 * provider's key, because #741 and #745 were open at the same time and
237 * either could merge first. Both have landed, so the probe and its fallback
238 * are gone (#759).
239 *
240 * @since 2.8.0
241 *
242 * @return bool
243 */
244 private static function ai_configured(): bool {
245 return Settings::instance()->has_ai_provider_configured();
246 }
247
248 /**
249 * GET — one page of snippets, with issue counts for the whole filtered set.
250 *
251 * @param WP_REST_Request $request Request.
252 * @return WP_REST_Response|WP_Error
253 */
254 public function list_snippets(WP_REST_Request $request) {
255 $post_type = (string) $request->get_param('post_type');
256 if (!Global_SEO_Post_Types::is_allowed($post_type)) {
257 return new WP_Error(
258 'thinkrank_snippets_post_type',
259 __('That post type is not one ThinkRank manages.', 'thinkrank'),
260 ['status' => 400]
261 );
262 }
263
264 $status = (string) $request->get_param('status');
265 $per_page = min(self::PER_PAGE_MAX, max(1, (int) $request->get_param('per_page')));
266
267 $result = self::page([
268 'post_type' => $post_type,
269 'statuses' => self::STATUS_SETS[$status] ?? self::STATUS_SETS['publish'],
270 'issue' => (string) $request->get_param('issue'),
271 'search' => trim((string) $request->get_param('search')),
272 'page' => max(1, (int) $request->get_param('page')),
273 'per_page' => $per_page,
274 ]);
275
276 return new WP_REST_Response([
277 'rows' => array_map([self::class, 'present'], $result['snippets']),
278 'total' => $result['total'],
279 'total_pages' => (int) max(1, ceil($result['total'] / $per_page)),
280 'total_all' => $result['total_all'],
281 'page' => $result['page'],
282 'per_page' => $per_page,
283 'counts' => $result['counts'],
284 'with_problem' => $result['with_problem'],
285 // Posts whose verdict is still being worked out. While this is above
286 // zero the counts cover only the posts already analysed, and the
287 // screen keeps asking until it reaches zero.
288 'index' => ['pending' => $result['pending']],
289 'limits' => [
290 'title_min' => \ThinkRank\AI\SEOScoreCalculator::TITLE_OPTIMAL_MIN,
291 'title_max' => \ThinkRank\AI\SEOScoreCalculator::TITLE_OPTIMAL_MAX,
292 'description_min' => \ThinkRank\AI\SEOScoreCalculator::DESCRIPTION_OPTIMAL_MIN,
293 'description_max' => \ThinkRank\AI\SEOScoreCalculator::DESCRIPTION_OPTIMAL_MAX,
294 ],
295 'ai' => [
296 'configured' => self::ai_configured(),
297 'fill_cap' => self::ai_fill_cap(),
298 ],
299 ]);
300 }
301
302 /**
303 * GET — the sitewide duplicate title / description report.
304 *
305 * Separate from the list above because it answers a different question.
306 * The list is "what is wrong with this post type's snippets"; this is "which
307 * pages on this site carry the same title as another one", and the answer
308 * has to cross post types to be true (#564).
309 *
310 * Bounded like the list: each call builds one batch of missing index
311 * entries and reports how many are left, so the screen keeps asking until
312 * `pending` reaches zero rather than any one request scanning the site.
313 *
314 * @since 2.10.0
315 *
316 * @param WP_REST_Request $request Request.
317 * @return WP_REST_Response
318 */
319 public function list_duplicates(WP_REST_Request $request): WP_REST_Response {
320 $report = Duplicate_Snippets::report((bool) $request->get_param('refresh'));
321
322 $report['limits'] = [
323 'max_groups' => Duplicate_Snippets::MAX_GROUPS,
324 'max_members' => Duplicate_Snippets::MAX_MEMBERS,
325 ];
326
327 return new WP_REST_Response($report);
328 }
329
330 /**
331 * One page of snippets, answered from the persisted index.
332 *
333 * The work a request does is bounded: {@see Snippet_Index::refresh()}
334 * builds at most one batch of stale entries, counts and the page come from
335 * SQL over the index, and only the rows on the page are resolved in full.
336 * Shared with the list-snippet-issues ability so the screen and an agent
337 * see the same posts and counts.
338 *
339 * Only posts the current user may read are counted or returned. The
340 * section capability is delegable through the Role Manager, so it does not
341 * prove the user may read every draft or private post of the type.
342 *
343 * @since 2.8.0
344 *
345 * @param array{post_type:string, statuses:string[], issue:string, search:string, page:int, per_page:int} $args Query.
346 * @return array{snippets: array<int,array<string,mixed>>, total:int, page:int, counts:array<string,int>, with_problem:int, pending:int}
347 */
348 public static function page(array $args): array {
349 $pending = Snippet_Index::refresh($args['post_type'], $args['statuses']);
350
351 $result = Snippet_Index::query([
352 'post_type' => $args['post_type'],
353 'statuses' => $args['statuses'],
354 // Duplicates are looked for across every post type ThinkRank
355 // manages, not only the one being listed: a page sharing a title
356 // with a post is still a duplicate (#564).
357 'group_post_types' => Global_SEO_Post_Types::allowed(),
358 'issue' => $args['issue'],
359 'search' => $args['search'],
360 'page' => $args['page'],
361 'per_page' => $args['per_page'],
362 'visibility_sql' => Snippet_Index::visibility_sql($args['post_type']),
363 ]);
364
365 _prime_post_caches($result['ids'], false, true);
366
367 $snippets = [];
368 foreach ($result['ids'] as $post_id) {
369 $post = get_post($post_id);
370
371 // The SQL above already scopes to readable posts; this is the
372 // per-post check itself, so a policy the SQL cannot express (a
373 // plugin filtering map_meta_cap) is still honoured.
374 if (!$post instanceof \WP_Post || !current_user_can('read_post', $post_id)) {
375 continue;
376 }
377
378 $snippets[] = self::judge(
379 Snippet_Issues::snapshot($post),
380 in_array($post_id, $result['duplicate_ids'], true),
381 in_array($post_id, $result['duplicate_description_ids'], true)
382 );
383 }
384
385 return [
386 'snippets' => $snippets,
387 'total' => $result['total'],
388 'total_all' => $result['total_all'],
389 'page' => $args['page'],
390 'counts' => $result['counts'],
391 'with_problem' => $result['with_problem'],
392 'pending' => $pending,
393 ];
394 }
395
396 /**
397 * Attach the verdict to a snapshot.
398 *
399 * @param array<string,mixed> $snapshot From {@see Snippet_Issues::snapshot()}.
400 * @param bool $duplicate Whether another post shares its title.
401 * @param bool $duplicate_description Whether another post shares its description.
402 * @return array<string,mixed>
403 */
404 private static function judge(array $snapshot, bool $duplicate, bool $duplicate_description = false): array {
405 $snapshot['duplicate_title'] = $duplicate;
406 $snapshot['duplicate_description'] = $duplicate_description;
407 $snapshot['issues'] = Snippet_Issues::evaluate($snapshot);
408
409 return $snapshot;
410 }
411
412 /**
413 * Shape one collected snippet for the response.
414 *
415 * @since 2.8.0
416 *
417 * @param array<string,mixed> $snippet From {@see self::page()}.
418 * @return array<string,mixed>
419 */
420 public static function present(array $snippet): array {
421 /** @var \WP_Post $post */
422 $post = $snippet['post'];
423
424 return [
425 'post_id' => (int) $post->ID,
426 'post_title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'),
427 'status' => $post->post_status,
428 'edit_url' => (string) get_edit_post_link($post->ID, 'raw'),
429 'permalink' => (string) get_permalink($post),
430 'title' => $snippet['raw_title'],
431 'description' => $snippet['raw_description'],
432 'focus_keyword' => $snippet['focus_keyword'],
433 // What the page renders when the field above is empty, so the
434 // table can show the inherited template value as a placeholder.
435 // Decoded: the resolved value is still HTML, so a texturized
436 // `&#038;` was shown in the placeholder and counted as six
437 // characters by the length counter beside it.
438 'effective_title' => Seo_Text::as_displayed((string) $snippet['effective_title']),
439 'effective_description' => Seo_Text::as_displayed((string) $snippet['effective_description']),
440 'noindex' => (bool) $snippet['noindex'],
441 'noindex_source' => $snippet['noindex_source'],
442 'issues' => $snippet['issues'],
443 'can_edit' => current_user_can('edit_post', $post->ID),
444 ];
445 }
446
447 /**
448 * POST — save a batch of edited snippets.
449 *
450 * Each row is authorized and saved on its own, and the response reports
451 * each row's outcome with the values actually stored — a row the store
452 * reduced (sanitization, a background write it declined to clobber) is
453 * reported as what it became, not as what was sent.
454 *
455 * @param WP_REST_Request $request Request.
456 * @return WP_REST_Response
457 */
458 public function save_snippets(WP_REST_Request $request): WP_REST_Response {
459 $items = $request->get_param('items');
460 $items = is_array($items) ? array_slice($items, 0, self::SAVE_MAX) : [];
461
462 $manager = new Metabox_Manager();
463 $results = [];
464 $saved = 0;
465 $reload = [];
466 $saved_ids = [];
467
468 foreach ($items as $item) {
469 $result = self::save_one(is_array($item) ? $item : [], $manager);
470 if (!empty($result['success'])) {
471 $saved++;
472 $post = get_post((int) $result['post_id']);
473 $reload[$post->post_type][$post->post_status] = true;
474 $saved_ids[$post->post_type][] = (int) $post->ID;
475 }
476 $results[] = $result;
477 }
478
479 // Re-read and re-judge what was saved, so each row the client
480 // re-renders is the one now in storage, with issue flags that match it.
481 // Only the saved posts are rebuilt; duplicate status comes from the
482 // index, so peers need no work — the other half of a duplicate pair
483 // clears itself the next time the list is read.
484 $fresh = [];
485 $group_types = Global_SEO_Post_Types::allowed();
486 foreach ($reload as $post_type => $statuses) {
487 $ids = $saved_ids[$post_type] ?? [];
488 Snippet_Index::rebuild($ids);
489 $duplicates = Snippet_Index::duplicates_among($group_types, array_keys($statuses), $ids);
490
491 foreach ($ids as $post_id) {
492 $post = get_post($post_id);
493 if ($post instanceof \WP_Post) {
494 $fresh[$post_id] = self::judge(
495 Snippet_Issues::snapshot($post),
496 in_array($post_id, $duplicates['title'], true),
497 in_array($post_id, $duplicates['description'], true)
498 );
499 }
500 }
501 }
502
503 foreach ($results as $index => $result) {
504 $post_id = (int) $result['post_id'];
505 if (!empty($result['success']) && isset($fresh[$post_id])) {
506 $results[$index]['row'] = self::present($fresh[$post_id]);
507 }
508 }
509
510 return new WP_REST_Response([
511 'success' => $saved === count($items),
512 'saved' => $saved,
513 'failed' => count($items) - $saved,
514 'results' => $results,
515 ], $saved > 0 || empty($items) ? 200 : 400);
516 }
517
518 /**
519 * Save one row.
520 *
521 * @param array<string,mixed> $item Row payload.
522 * @param Metabox_Manager $manager Shared persistence.
523 * @return array<string,mixed> Row result.
524 */
525 private static function save_one(array $item, Metabox_Manager $manager): array {
526 $post_id = absint($item['post_id'] ?? 0);
527 $post = $post_id ? get_post($post_id) : null;
528
529 if (!$post instanceof \WP_Post || !Global_SEO_Post_Types::is_allowed($post->post_type)) {
530 return self::row_error($post_id, __('Post not found.', 'thinkrank'));
531 }
532
533 if (!current_user_can('edit_post', $post_id)) {
534 return self::row_error($post_id, __('You are not allowed to edit this post.', 'thinkrank'));
535 }
536
537 $map = [
538 'title' => 'thinkrank_seo_title',
539 'description' => 'thinkrank_meta_description',
540 'focus_keyword' => 'thinkrank_focus_keyword',
541 ];
542
543 $original = is_array($item['original'] ?? null) ? $item['original'] : [];
544 $fields = [];
545
546 foreach ($map as $key => $field) {
547 if (!array_key_exists($key, $item)) {
548 continue;
549 }
550
551 $value = (string) $item[$key];
552 if (mb_strlen($value) > self::FIELD_MAX_LENGTH) {
553 return self::row_error(
554 $post_id,
555 /* translators: %d: maximum length in characters. */
556 sprintf(__('A value is longer than %d characters.', 'thinkrank'), self::FIELD_MAX_LENGTH)
557 );
558 }
559
560 $fields[$field] = $value;
561
562 // The value the table loaded. When the user clears a field that was
563 // blank on screen but has since been filled in storage — Auto AI or
564 // an import landed while the table was open — the shared metabox
565 // persistence keeps the stored value instead of blanking it.
566 if (array_key_exists($key, $original) && 'focus_keyword' !== $key) {
567 $fields[$field . '__orig'] = (string) $original[$key];
568 }
569 }
570
571 if (empty($fields)) {
572 return self::row_error($post_id, __('Nothing to save for this post.', 'thinkrank'));
573 }
574
575 $manager->save_seo_fields($post_id, $fields);
576
577 return [
578 'post_id' => $post_id,
579 'success' => true,
580 ];
581 }
582
583 /**
584 * POST — an AI suggestion for one post, returned and never saved.
585 *
586 * The client runs these one post at a time so a run of 25 is 25 short
587 * requests the user can watch and cancel, not one request that outlives
588 * PHP's time limit. Nothing is written: the user reviews the suggestion in
589 * the table and saves it like any other edit.
590 *
591 * @param WP_REST_Request $request Request.
592 * @return WP_REST_Response
593 */
594 public function generate_suggestion(WP_REST_Request $request): WP_REST_Response {
595 $post_id = (int) $request->get_param('id');
596 $post = get_post($post_id);
597
598 if (!$post instanceof \WP_Post || !Global_SEO_Post_Types::is_allowed($post->post_type)) {
599 return new WP_REST_Response(['success' => false, 'message' => __('Post not found.', 'thinkrank')], 404);
600 }
601
602 if (!current_user_can('edit_post', $post_id)) {
603 return new WP_REST_Response(['success' => false, 'message' => __('You are not allowed to edit this post.', 'thinkrank')], 403);
604 }
605
606 if (!self::ai_configured()) {
607 return new WP_REST_Response([
608 'success' => false,
609 'message' => __('AI is not set up yet. Choose a provider under ThinkRank → Settings.', 'thinkrank'),
610 ], 400);
611 }
612
613 // Same floor the editor's "Generate with AI" applies. Below it the
614 // model has only the post title to go on and answers with filler — a
615 // bulk run over empty posts would spend the user's key on "..." and put
616 // it in front of them for review.
617 $text = trim(wp_strip_all_tags(strip_shortcodes((string) $post->post_content)));
618 if (mb_strlen($text) < self::MIN_CONTENT_LENGTH) {
619 return new WP_REST_Response([
620 'success' => false,
621 'code' => 'thinkrank_snippets_thin_content',
622 'message' => __('This post has too little content for AI to describe. Add content first, or write the snippet by hand.', 'thinkrank'),
623 ], 422);
624 }
625
626 try {
627 $keywords = Focus_Keywords::get($post_id);
628 $metadata = (new Metadata_Generator())->suggest_for_post($post_id, [
629 // Keep the user's keyword when there is one; the suggestion
630 // should serve it, not replace it.
631 'target_keyword' => (string) ($keywords[0] ?? ''),
632 ]);
633 } catch (\Exception $e) {
634 return new WP_REST_Response([
635 'success' => false,
636 'message' => wp_strip_all_tags($e->getMessage()),
637 ], 502);
638 }
639
640 $suggestion = [
641 'title' => trim((string) ($metadata['title'] ?? '')),
642 'description' => trim((string) ($metadata['description'] ?? '')),
643 'focus_keyword' => trim((string) ($metadata['focus_keyword'] ?? '')),
644 ];
645
646 // A reply with no letters or digits in it ("...", "—") is a model
647 // declining to answer, not a snippet. Offering it for review invites a
648 // tired reviewer to save punctuation as a meta description.
649 foreach (['title', 'description'] as $field) {
650 if (!preg_match('/[\p{L}\p{N}]/u', $suggestion[$field])) {
651 return new WP_REST_Response([
652 'success' => false,
653 'code' => 'thinkrank_snippets_empty_suggestion',
654 'message' => __('The AI did not return a usable title and description for this post. Try again, or write them by hand.', 'thinkrank'),
655 ], 502);
656 }
657 }
658
659 return new WP_REST_Response([
660 'success' => true,
661 'post_id' => $post_id,
662 'suggestion' => $suggestion,
663 ]);
664 }
665
666 /**
667 * A failed row.
668 *
669 * @param int $post_id Post ID.
670 * @param string $message Reason.
671 * @return array<string,mixed>
672 */
673 private static function row_error(int $post_id, string $message): array {
674 return [
675 'post_id' => $post_id,
676 'success' => false,
677 'message' => $message,
678 ];
679 }
680 }
681