PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 1.0.2 1.1.0 1.10.0 All 48 releases
← All changes | includes/api/class-content-brief-endpoint.php +67 -31 1.25.02.7.0 View file →
@@ -39,8 +39,15 @@
39 39 */
40 40 private ?Content_Brief_Generator $generator = null;
41 41
42 42 /**
43 + * Generator instance for read-only work (no AI client attached)
44 + *
45 + * @var Content_Brief_Generator|null
46 + */
47 + private ?Content_Brief_Generator $storage_generator = null;
48 +
49 + /**
43 50 * Constructor
44 51 */
45 52 public function __construct() {
46 53 // Don't instantiate generator here - do it lazily when needed
@@ -48,8 +55,11 @@
48 55
49 56 /**
50 57 * Get generator instance (lazy loading)
51 58 *
59 + * Only for routes that actually call the AI provider. Storage-only routes
60 + * must use get_storage_generator() instead.
61 + *
52 62 * @return Content_Brief_Generator
53 63 * @throws \Exception If generator cannot be created
54 64 */
55 65 private function get_generator(): Content_Brief_Generator {
@@ -63,8 +73,25 @@
63 73 return $this->generator;
64 74 }
65 75
66 76 /**
77 + * Get a generator for storage-only work (list / export / delete).
78 + *
79 + * These routes only read and write the briefs table, so they must not
80 + * require an AI client: on a site with no API key configured — the default
81 + * for a fresh install — building one throws and turns a plain database read
82 + * into a 500.
83 + *
84 + * @return Content_Brief_Generator
85 + */
86 + private function get_storage_generator(): Content_Brief_Generator {
87 + if ($this->storage_generator === null) {
88 + $this->storage_generator = new Content_Brief_Generator(null, null, false);
89 + }
90 + return $this->storage_generator;
91 + }
92 +
93 + /**
67 94 * Register REST API routes
68 95 *
69 96 * @return void
70 97 */
@@ -181,9 +208,9 @@
181 208 *
182 209 * @param WP_REST_Request $request Request object
183 210 * @return WP_REST_Response|WP_Error Response object
184 211 */
185 - public function generate_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
212 + public function generate_brief(WP_REST_Request $request) {
186 213 try {
187 214 // Persistent per-user throttle on this paid AI-backed route (the other
188 215 // AI endpoints do the same) to prevent an edit_posts user looping it.
189 216 if (!$this->check_ai_rate_limit()) {
@@ -226,14 +253,14 @@
226 253 *
227 254 * @param WP_REST_Request $request Request object
228 255 * @return WP_REST_Response|WP_Error Response object
229 256 */
230 - public function get_briefs(WP_REST_Request $request): WP_REST_Response|WP_Error {
257 + public function get_briefs(WP_REST_Request $request) {
231 258 try {
232 259 $limit = $request->get_param('limit');
233 260 $offset = $request->get_param('offset');
234 261
235 - $briefs = $this->get_generator()->get_user_briefs($limit, $offset);
262 + $briefs = $this->get_storage_generator()->get_user_briefs($limit, $offset);
236 263
237 264 return new WP_REST_Response([
238 265 'success' => true,
239 266 'data' => $briefs,
@@ -240,17 +267,8 @@
240 267 'total' => count($briefs)
241 268 ], 200);
242 269
243 270 } catch (\Exception $e) {
244 - // If no API key is configured, return empty list instead of error
245 - if (strpos($e->getMessage(), 'Please configure your AI provider') !== false) {
246 - return new WP_REST_Response([
247 - 'success' => true,
248 - 'data' => [],
249 - 'total' => 0
250 - ], 200);
251 - }
252 -
253 271 return new WP_Error(
254 272 'briefs_fetch_failed',
255 273 $e->getMessage(),
256 274 ['status' => 500]
@@ -263,12 +281,12 @@
263 281 *
264 282 * @param WP_REST_Request $request Request object
265 283 * @return WP_REST_Response|WP_Error Response object
266 284 */
267 - public function delete_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
285 + public function delete_brief(WP_REST_Request $request) {
268 286 try {
269 287 $brief_id = $request->get_param('id');
270 - $success = $this->get_generator()->delete_brief($brief_id);
288 + $success = $this->get_storage_generator()->delete_brief($brief_id);
271 289
272 290 if ($success) {
273 291 return new WP_REST_Response([
274 292 'success' => true,
@@ -296,9 +314,9 @@
296 314 *
297 315 * @param WP_REST_Request $request Request object
298 316 * @return WP_REST_Response|WP_Error Response object
299 317 */
300 - public function export_brief(WP_REST_Request $request): WP_REST_Response|WP_Error {
318 + public function export_brief(WP_REST_Request $request) {
301 319 try {
302 320 $brief_id = (int) $request->get_param('id');
303 321 $format = $request->get_param('format');
304 322
@@ -303,9 +321,9 @@
303 321 $format = $request->get_param('format');
304 322
305 323 // Fetch the requested brief, scoped to the current user. Returns null
306 324 // (→ 404) when the id doesn't exist or belongs to another user.
307 - $brief = $this->get_generator()->get_brief($brief_id);
325 + $brief = $this->get_storage_generator()->get_brief($brief_id);
308 326
309 327 if (!$brief) {
310 328 return new WP_Error(
311 329 'brief_not_found',
@@ -338,28 +356,46 @@
338 356 * @param string $format Export format
339 357 * @return string Formatted content
340 358 */
341 359 private function format_brief_for_export(array $brief, string $format): string {
342 - $brief_data = $brief['brief_data'];
343 -
344 - $content = "Content Brief: " . $brief['title'] . "\n\n";
345 - $content .= "Target Keywords: " . implode(', ', $brief['target_keywords']) . "\n";
346 - $content .= "Content Type: " . $brief['content_type'] . "\n\n";
347 -
348 - if (!empty($brief_data['outline'])) {
360 + // Every read here is a field of json_decode() output, so nothing about
361 + // its shape is guaranteed. implode() on null and str_repeat() on a
362 + // negative count are a TypeError and a ValueError respectively, and
363 + // neither is an \Exception — so the catch around this call never
364 + // matched and an export of a malformed brief was a fatal (#394).
365 + $brief_data = is_array($brief['brief_data'] ?? null) ? $brief['brief_data'] : [];
366 + $keywords = is_array($brief['target_keywords'] ?? null) ? $brief['target_keywords'] : [];
367 +
368 + $content = "Content Brief: " . (string) ($brief['title'] ?? '') . "\n\n";
369 + $content .= "Target Keywords: " . implode(', ', array_map('strval', $keywords)) . "\n";
370 + $content .= "Content Type: " . (string) ($brief['content_type'] ?? '') . "\n\n";
371 +
372 + if (!empty($brief_data['outline']) && is_array($brief_data['outline'])) {
349 373 $content .= "Content Outline:\n";
374 +
350 375 foreach ($brief_data['outline'] as $item) {
351 - $indent = str_repeat(' ', $item['level'] - 1);
352 - $content .= $indent . "H{$item['level']}: " . $item['heading'];
353 - if ($item['word_count'] > 0) {
354 - $content .= " ({$item['word_count']} words)";
376 + if (!is_array($item)) {
377 + continue;
355 378 }
379 +
380 + // Clamped: a level of 0 or a missing one made the repeat count
381 + // negative.
382 + $level = max(1, min(6, (int) ($item['level'] ?? 1)));
383 + $word_count = (int) ($item['word_count'] ?? 0);
384 + $indent = str_repeat(' ', $level - 1);
385 +
386 + $content .= $indent . "H{$level}: " . (string) ($item['heading'] ?? '');
387 +
388 + if ($word_count > 0) {
389 + $content .= " ({$word_count} words)";
390 + }
391 +
356 392 $content .= "\n";
357 393 }
358 394 }
359 -
360 - $content .= "\nGenerated on: " . $brief['created_at'];
361 -
395 +
396 + $content .= "\nGenerated on: " . (string) ($brief['created_at'] ?? '');
397 +
362 398 return $content;
363 399 }
364 400
365 401 /**
@@ -367,9 +403,9 @@
367 403 *
368 404 * @param array $keywords Keywords to validate
369 405 * @return bool|WP_Error Validation result
370 406 */
371 - public function validate_keywords($keywords): bool|WP_Error {
407 + public function validate_keywords($keywords) {
372 408 // A custom validate_callback replaces WP's array type-coercion, so the
373 409 // raw param arrives here as-is; reject non-arrays instead of letting a
374 410 // strict array type hint throw an uncaught TypeError during dispatch.
375 411 if (!is_array($keywords)) {