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.11.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 All 52 releases
← All changes | includes/api/class-usage-analytics-endpoint.php +203 -66 2.2.0 → 2.10.0 View file →
@@ -290,18 +290,28 @@
290 290 'type' => 'string',
291 291 'enum' => ['7d', '30d', '90d', 'all'],
292 292 'sanitize_callback' => 'sanitize_key'
293 293 ],
294 - 'group_by' => [
295 - 'default' => 'day',
296 - 'type' => 'string',
297 - 'enum' => ['day', 'week', 'month'],
298 - 'sanitize_callback' => 'sanitize_key'
299 - ],
300 294 'user_id' => [
301 295 'default' => 0,
302 296 'type' => 'integer',
303 297 'sanitize_callback' => 'absint'
298 + ],
299 + // Declared because the handler reads them. They were validated
300 + // only by the handler's own clamping, so they had no type
301 + // coercion and did not appear in the endpoint's schema.
302 + 'page' => [
303 + 'default' => 1,
304 + 'type' => 'integer',
305 + 'minimum' => 1,
306 + 'sanitize_callback' => 'absint'
307 + ],
308 + 'per_page' => [
309 + 'default' => 20,
310 + 'type' => 'integer',
311 + 'minimum' => 10,
312 + 'maximum' => 100,
313 + 'sanitize_callback' => 'absint'
304 314 ]
305 315 ]
306 316 ]);
307 317
@@ -319,9 +329,9 @@
319 329 ],
320 330 'provider' => [
321 331 'default' => 'all',
322 332 'type' => 'string',
323 - 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter'],
333 + 'enum' => ['all', 'openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'],
324 334 'sanitize_callback' => 'sanitize_key'
325 335 ],
326 336 'user_id' => [
327 337 'default' => 0,
@@ -380,9 +390,8 @@
380 390 'ai_actions' => $ai_metrics['total_actions'],
381 391 'features_used_count' => $ai_metrics['features_used_count'],
382 392 'most_used_feature' => $ai_metrics['most_used_feature'],
383 393 'most_used_count' => $ai_metrics['most_used_count'],
384 - 'success_rate' => $ai_metrics['success_rate'],
385 394 'content_briefs' => $brief_metrics['total_briefs'],
386 395 'feature_breakdown' => $ai_metrics['feature_breakdown'],
387 396 'provider_breakdown' => $cost_data['by_provider']
388 397 ],
@@ -446,8 +455,34 @@
446 455 return true;
447 456 }
448 457
449 458 /**
459 + * Bind the cache-invalidation listeners for the whole request lifecycle.
460 + *
461 + * The listeners used to be registered only by the constructor, which runs
462 + * on rest_api_init — so usage logged during cron, WP-CLI or an admin-post
463 + * request found no listener and the cached overview rode out its full TTL.
464 + * Called from API\Manager::init() on every request instead.
465 + *
466 + * @since 2.2.1
467 + * @return void
468 + */
469 + public static function boot_cache_invalidation(): void {
470 + static $booted = false;
471 +
472 + if ($booted) {
473 + return;
474 + }
475 +
476 + $booted = true;
477 +
478 + // Constructing the endpoint registers the listeners; the guard in
479 + // setup_cache_invalidation() keeps a later REST construction from
480 + // double-binding them.
481 + new self();
482 + }
483 +
484 + /**
450 485 * Set up cache invalidation hooks
451 486 *
452 487 * @since 1.0.0
453 488 * @return void
@@ -452,8 +487,23 @@
452 487 * @since 1.0.0
453 488 * @return void
454 489 */
455 490 private function setup_cache_invalidation(): void {
491 + // The endpoint is constructed more than once per request — once on
492 + // init via boot_cache_invalidation(), again on rest_api_init, and
493 + // potentially by callers resolving it on demand. Bind once per
494 + // request, or every event invalidates N times.
495 + //
496 + // A has_action() check cannot do this: the callback is [$this, ...]
497 + // and each construction is a different instance, so it never matches.
498 + static $bound = false;
499 +
500 + if ($bound) {
501 + return;
502 + }
503 +
504 + $bound = true;
505 +
456 506 // Invalidate analytics cache when AI usage is logged
457 507 add_action('thinkrank_ai_usage_logged', [$this, 'invalidate_analytics_cache']);
458 508
459 509 // Invalidate analytics cache when SEO scores are updated
@@ -539,8 +589,12 @@
539 589 'openai' => 0,
540 590 'claude' => 0,
541 591 'gemini' => 0,
542 592 'openrouter' => 0,
593 + // Costed only when the user told us what their endpoint charges;
594 + // otherwise it stays 0 and the UI shows "—" rather than implying
595 + // that a local model was free of charge or that we know the price.
596 + 'openai_compatible' => 0,
543 597 'total' => 0,
544 598 'by_provider' => []
545 599 ];
546 600
@@ -545,46 +599,50 @@
545 599 ];
546 600
547 601 foreach ($usage_data as $usage) {
548 602 $tokens = (int) $usage['tokens_used'];
549 - $provider = $usage['provider'];
603 + $provider = (string) $usage['provider'];
550 604
551 - // Estimate 70% input, 30% output tokens
552 - $input_tokens = $tokens * 0.7;
553 - $output_tokens = $tokens * 0.3;
605 + // Unknown provider: no pricing table, so it cannot be costed. Skip
606 + // rather than let `+=` invent a key that the total below misses.
607 + if (!isset($costs[$provider])) {
608 + continue;
609 + }
554 610
555 - $cost = 0;
611 + // Price at the model the request actually used. Reading only the
612 + // provider meant every row was costed at that provider's default
613 + // model, so this total disagreed with the per-record figures in
614 + // the Usage Breakdown tab — by 4.5x on a gpt-4o-mini workload.
615 + $metadata = !empty($usage['metadata']) ? json_decode((string) $usage['metadata'], true) : [];
616 + $model = is_array($metadata) && !empty($metadata['actual_model'])
617 + ? (string) $metadata['actual_model']
618 + : $this->get_default_model($provider);
556 619
557 - // Use the robust pricing helper for consistent cost calculation
558 - $pricing = $this->get_model_pricing($provider);
559 - if ($pricing) {
560 - $cost = ($input_tokens * $pricing['input'] / 1000000) +
561 - ($output_tokens * $pricing['output'] / 1000000);
562 - $costs[$provider] += $cost;
563 - }
620 + // Single source of truth for per-row pricing, shared with
621 + // get_detailed_usage_breakdown() so both tabs always agree.
622 + $costs[$provider] += $this->calculate_record_cost($provider, $tokens, $model);
564 623 }
565 624
566 - $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'];
625 + $costs['total'] = $costs['openai'] + $costs['claude'] + $costs['gemini'] + $costs['openrouter'] + $costs['openai_compatible'];
567 626
568 - // Format provider breakdown
569 - $costs['by_provider'] = [
570 - 'openai' => [
571 - 'cost' => round($costs['openai'], 4),
572 - 'percentage' => $costs['total'] > 0 ? round(($costs['openai'] / $costs['total']) * 100, 1) : 0
573 - ],
574 - 'claude' => [
575 - 'cost' => round($costs['claude'], 4),
576 - 'percentage' => $costs['total'] > 0 ? round(($costs['claude'] / $costs['total']) * 100, 1) : 0
577 - ],
578 - 'gemini' => [
579 - 'cost' => round($costs['gemini'], 4),
580 - 'percentage' => $costs['total'] > 0 ? round(($costs['gemini'] / $costs['total']) * 100, 1) : 0
581 - ],
582 - 'openrouter' => [
583 - 'cost' => round($costs['openrouter'], 4),
584 - 'percentage' => $costs['total'] > 0 ? round(($costs['openrouter'] / $costs['total']) * 100, 1) : 0
585 - ]
586 - ];
627 + // Report only providers that actually incurred cost. Emitting all four
628 + // unconditionally meant a site with no AI usage rendered four ranked
629 + // rows at "$0.0000 (0%)" — reading as "four providers were used and
630 + // each was free" — and made the panel's own "No provider cost data"
631 + // empty state unreachable.
632 + $costs['by_provider'] = [];
633 + foreach (['openai', 'claude', 'gemini', 'openrouter', 'openai_compatible'] as $provider) {
634 + if ($costs[$provider] <= 0) {
635 + continue;
636 + }
637 +
638 + $costs['by_provider'][$provider] = [
639 + 'cost' => round($costs[$provider], 4),
640 + 'percentage' => $costs['total'] > 0
641 + ? round(($costs[$provider] / $costs['total']) * 100, 1)
642 + : 0
643 + ];
644 + }
587 645
588 646 return $costs;
589 647 }
590 648
@@ -624,9 +682,9 @@
624 682 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
625 683 $usage_data = $wpdb->get_results(
626 684 $wpdb->prepare(
627 685 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
628 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
686 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d AND created_at >= %s",
629 687 $user_id,
630 688 $cutoff
631 689 ),
632 690 ARRAY_A
@@ -635,9 +693,9 @@
635 693 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
636 694 $usage_data = $wpdb->get_results(
637 695 $wpdb->prepare(
638 696 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is escaped via esc_sql().
639 - "SELECT provider, action, tokens_used, created_at FROM `{$table_name}` WHERE user_id = %d",
697 + "SELECT provider, action, tokens_used, metadata, created_at FROM `{$table_name}` WHERE user_id = %d",
640 698 $user_id
641 699 ),
642 700 ARRAY_A
643 701 );
@@ -648,8 +706,16 @@
648 706 // get_overview_metrics() reads every key unconditionally, so a
649 707 // short array here surfaces as undefined-key warnings and null
650 708 // fields for any user with no AI usage yet (i.e. a fresh install).
651 709 // The values mirror what the loop below produces for zero rows.
710 + //
711 + // The change fields are COMPUTED here rather than hardcoded to 0.
712 + // An empty current window does not mean "nothing changed": a user
713 + // whose usage fell from five actions last month to none this month
714 + // was shown a 0 — rendered as the same em-dash a genuinely flat
715 + // period gets — instead of the -100% that actually happened.
716 + $previous = $this->get_previous_period_data($user_id, $date_condition);
717 +
652 718 return [
653 719 'total_actions' => 0,
654 720 'total_tokens' => 0,
655 721 'feature_breakdown' => [],
@@ -655,12 +721,17 @@
655 721 'feature_breakdown' => [],
656 722 'features_used_count' => 0,
657 723 'most_used_feature' => '',
658 724 'most_used_count' => 0,
659 - 'success_rate' => 0,
660 725 'usage_data' => [],
661 - 'cost_change' => 0,
662 - 'time_saved_change' => 0
726 + 'cost_change' => $this->calculate_percentage_change(
727 + array_key_exists('total_cost', $previous) ? $previous['total_cost'] : 0,
728 + 0.0
729 + ),
730 + 'time_saved_change' => $this->calculate_percentage_change(
731 + array_key_exists('time_saved', $previous) ? $previous['time_saved'] : 0,
732 + 0.0
733 + )
663 734 ];
664 735 }
665 736
666 737 // Calculate feature breakdown and new metrics
@@ -692,20 +763,24 @@
692 763 $most_used_count = $count;
693 764 }
694 765 }
695 766
696 - // Calculate success rate (assuming all logged actions are successful for now)
697 - // In future, we could track failed attempts separately
698 - $success_rate = $total_actions > 0 ? 100 : 0;
767 + // No success rate here on purpose. It used to be
768 + // `$total_actions > 0 ? 100 : 0` — a constant presented as a
769 + // measurement, and one that could only ever read 100% or 0%. Failed
770 + // AI calls are never written to this table, so there is nothing to
771 + // compute a rate from; the KPI card is gone until there is.
699 772
700 773 // Calculate changes from previous period
701 774 $previous_period_data = $this->get_previous_period_data($user_id, $date_condition);
775 + // Note the lack of `?? 0`: a null here means "no previous period",
776 + // and coalescing it to zero would turn that back into a fake 100%.
702 777 $cost_change = $this->calculate_percentage_change(
703 - $previous_period_data['total_cost'] ?? 0,
778 + array_key_exists('total_cost', $previous_period_data) ? $previous_period_data['total_cost'] : 0,
704 779 $this->calculate_total_cost($usage_data)
705 780 );
706 781 $time_saved_change = $this->calculate_percentage_change(
707 - $previous_period_data['time_saved'] ?? 0,
782 + array_key_exists('time_saved', $previous_period_data) ? $previous_period_data['time_saved'] : 0,
708 783 $this->calculate_time_saved($feature_breakdown)
709 784 );
710 785
711 786 return [
@@ -714,9 +789,8 @@
714 789 'feature_breakdown' => $feature_breakdown,
715 790 'features_used_count' => $features_used_count,
716 791 'most_used_feature' => $most_used_feature,
717 792 'most_used_count' => $most_used_count,
718 - 'success_rate' => $success_rate,
719 793 'usage_data' => $usage_data,
720 794 'cost_change' => $cost_change,
721 795 'time_saved_change' => $time_saved_change
722 796 ];
@@ -758,24 +832,38 @@
758 832 );
759 833 }
760 834
761 835 if (!$result || (int) $result['content_optimized'] === 0) {
836 + // Same reasoning as the empty branch in get_ai_usage_metrics():
837 + // an empty current window is not "no change". A user who
838 + // optimized three posts last month and none this month should
839 + // see -100%, not the em-dash a flat period gets — and for `all`
840 + // there is no previous window, so the change is null.
841 + $previous = $this->get_previous_seo_data($user_id, $date_condition);
842 +
762 843 return [
763 844 'content_optimized' => 0,
764 845 'average_seo_score' => 0,
765 - 'content_optimized_change' => 0,
766 - 'seo_score_change' => 0
846 + 'content_optimized_change' => $this->calculate_percentage_change(
847 + array_key_exists('content_optimized', $previous) ? $previous['content_optimized'] : 0,
848 + 0.0
849 + ),
850 + 'seo_score_change' => $this->calculate_percentage_change(
851 + array_key_exists('average_seo_score', $previous) ? $previous['average_seo_score'] : 0,
852 + 0.0
853 + )
767 854 ];
768 855 }
769 856
770 857 // Calculate changes from previous period
771 858 $previous_seo_data = $this->get_previous_seo_data($user_id, $date_condition);
859 + // As above: no `?? 0`, so a null "no previous period" survives.
772 860 $content_optimized_change = $this->calculate_percentage_change(
773 - $previous_seo_data['content_optimized'] ?? 0,
861 + array_key_exists('content_optimized', $previous_seo_data) ? $previous_seo_data['content_optimized'] : 0,
774 862 (int) $result['content_optimized']
775 863 );
776 864 $seo_score_change = $this->calculate_percentage_change(
777 - $previous_seo_data['average_seo_score'] ?? 0,
865 + array_key_exists('average_seo_score', $previous_seo_data) ? $previous_seo_data['average_seo_score'] : 0,
778 866 round((float) $result['average_score'], 1)
779 867 );
780 868
781 869 return [
@@ -832,9 +920,14 @@
832 920 * @return WP_REST_Response|WP_Error Response object
833 921 */
834 922 public function get_usage_breakdown(WP_REST_Request $request) {
835 923 try {
836 - $user_id = get_current_user_id();
924 + // Mirrors get_overview_metrics(). The two endpoints declared the
925 + // same `user_id` argument but only overview honoured it, so the
926 + // same query string described two different users depending on
927 + // which one you asked. check_permissions() already requires
928 + // manage_options before another user's id is accepted.
929 + $user_id = $request->get_param('user_id') ?: get_current_user_id();
837 930 $period = $request->get_param('period') ?? '30d';
838 931 // `(int)` binds tighter than `??`, so `(int) null` is 0 and the
839 932 // `?? 20` fallback was unreachable — per_page silently defaulted to
840 933 // the max(10, 0) floor of 10 rather than the 20 it advertises, and
@@ -857,9 +950,10 @@
857 950 'pagination' => [
858 951 'page' => $page,
859 952 'per_page' => $per_page,
860 953 'total_records' => $total_records,
861 - 'total_pages' => ceil($total_records / $per_page)
954 + // (int) so it serialises as 2, not 2.0.
955 + 'total_pages' => $per_page > 0 ? (int) ceil($total_records / $per_page) : 0
862 956 ],
863 957 'period' => $period
864 958 ]
865 959 ], 200);
@@ -907,14 +1001,22 @@
907 1001 * @param float $old_value Previous period value
908 1002 * @param float $new_value Current period value
909 1003 * @return float Percentage change
910 1004 */
911 - private function calculate_percentage_change(float $old_value, float $new_value): float {
1005 + private function calculate_percentage_change($old_value, float $new_value): ?float {
1006 + // No previous period at all (the 'all' range).
1007 + if (null === $old_value) {
1008 + return null;
1009 + }
1010 +
912 1011 if ((float) $old_value === 0.0) {
913 - return $new_value > 0 ? 100 : 0;
1012 + // Growth from nothing has no percentage. Reporting a flat 100%
1013 + // dressed it up as a measured change; null lets the UI say "new"
1014 + // (or say nothing) instead of inventing a number.
1015 + return $new_value > 0 ? null : 0.0;
914 1016 }
915 1017
916 - return round((($new_value - $old_value) / $old_value) * 100, 1);
1018 + return round((($new_value - (float) $old_value) / (float) $old_value) * 100, 1);
917 1019 }
918 1020
919 1021 /**
920 1022 * Get previous period data for comparison
@@ -931,8 +1033,14 @@
931 1033
932 1034 // Extract the interval from current date condition to calculate previous period
933 1035 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
934 1036
1037 + // No preceding window: report "not comparable" rather than querying a
1038 + // made-up one.
1039 + if (null === $previous_date_condition) {
1040 + return ['total_cost' => null, 'time_saved' => null];
1041 + }
1042 +
935 1043 // Prepare and execute query with proper parameter binding to prevent SQL injection
936 1044 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
937 1045 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
938 1046 $usage_data = $wpdb->get_results(
@@ -939,9 +1047,10 @@
939 1047 $wpdb->prepare("
940 1048 SELECT
941 1049 provider,
942 1050 action,
943 - tokens_used
1051 + tokens_used,
1052 + metadata
944 1053 FROM `{$table_name}`
945 1054 WHERE user_id = %d
946 1055 {$previous_date_condition}
947 1056 ", $user_id),
@@ -1118,8 +1227,22 @@
1118 1227 return self::OPENROUTER_PRICING[$model];
1119 1228 }
1120 1229 return self::OPENROUTER_PRICING['openai/gpt-4o-mini'] ?? null;
1121 1230
1231 + case 'openai_compatible':
1232 + // There is no price table for someone else's server: it may be
1233 + // a free local model, an Azure contract or a hosted open model.
1234 + // The only honest number is the one the administrator entered,
1235 + // as a flat per-1M-token rate applied to both directions.
1236 + //
1237 + // Read at report time, so changing the rate (or repointing the
1238 + // provider at another server) re-costs past rows too. Accepted:
1239 + // storing a price per row would mean a schema change for an
1240 + // estimate the administrator typed in the first place.
1241 + $price = (float) \ThinkRank\Core\Settings::instance()->get('openai_compatible_price_per_million', 0);
1242 +
1243 + return $price > 0 ? ['input' => $price, 'output' => $price] : null;
1244 +
1122 1245 default:
1123 1246 return null;
1124 1247 }
1125 1248 }
@@ -1139,8 +1262,11 @@
1139 1262 case 'gemini':
1140 1263 return \ThinkRank\Core\Settings::DEFAULT_GEMINI_MODEL;
1141 1264 case 'openrouter':
1142 1265 return \ThinkRank\Core\Settings::DEFAULT_OPENROUTER_MODEL;
1266 + case 'openai_compatible':
1267 + // Whatever the user pointed us at; there is no default.
1268 + return (string) \ThinkRank\Core\Settings::instance()->get('openai_compatible_model', '');
1143 1269 default:
1144 1270 return 'unknown';
1145 1271 }
1146 1272 }
@@ -1159,8 +1285,13 @@
1159 1285 $table_name = esc_sql($this->database->get_table('seo_scores'));
1160 1286
1161 1287 $previous_date_condition = $this->get_previous_period_condition($current_date_condition);
1162 1288
1289 + // See get_previous_period_data(): no preceding window, no comparison.
1290 + if (null === $previous_date_condition) {
1291 + return ['content_optimized' => null, 'average_seo_score' => null];
1292 + }
1293 +
1163 1294 // Prepare and execute query with proper parameter binding to prevent SQL injection
1164 1295 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is properly escaped, date condition is from controlled source
1165 1296 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analytics data is real-time and shouldn't be cached
1166 1297 $result = $wpdb->get_row(
@@ -1207,12 +1338,18 @@
1207 1338
1208 1339 /**
1209 1340 * Convert current period condition to previous period condition
1210 1341 *
1342 + * Returns null when there is no preceding window to compare against.
1343 + * `all` produces an empty date condition, which used to fall through to a
1344 + * hardcoded 30–60 day fallback — so "all time" was compared against an
1345 + * arbitrary month and reported a large, meaningless increase. "No
1346 + * comparison" is now representable instead of being a parse failure.
1347 + *
1211 1348 * @param string $current_condition Current period SQL condition
1212 - * @return string Previous period SQL condition
1349 + * @return string|null Previous period SQL condition, or null when none exists
1213 1350 */
1214 - private function get_previous_period_condition(string $current_condition): string {
1351 + private function get_previous_period_condition(string $current_condition): ?string {
1215 1352 // Extract interval from conditions like "AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
1216 1353 if (preg_match('/INTERVAL (\d+) (\w+)/', $current_condition, $matches)) {
1217 1354 $interval = (int) $matches[1];
1218 1355 $unit = $matches[2];
@@ -1224,9 +1361,9 @@
1224 1361 return "AND created_at >= DATE_SUB(NOW(), INTERVAL {$start_interval} {$unit})
1225 1362 AND created_at < DATE_SUB(NOW(), INTERVAL {$end_interval} {$unit})";
1226 1363 }
1227 1364
1228 - // Fallback for unknown conditions
1229 - return "AND created_at >= DATE_SUB(NOW(), INTERVAL 60 DAY)
1230 - AND created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)";
1365 + // No interval means no window — 'all'. Comparing every record ever
1366 + // against a fabricated 30-day slice is not a trend.
1367 + return null;
1231 1368 }
1232 1369 }