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
thinkrank / includes / admin / class-seo-quick-edit-ajax.php

class-seo-quick-edit-ajax.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/admin/class-seo-quick-edit-ajax.php

214 lines 6.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SEO Quick Edit AJAX Handler
4 *
5 * Backs the Quick Edit SEO modal on post list tables (edit.php): one action
6 * loads a post's editable SEO fields, the other persists them and returns the
7 * re-rendered SEO Overview column cell so the list updates without a reload.
8 *
9 * @package ThinkRank\Admin
10 * @since 1.28.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin;
16
17 // Prevent direct access
18 if (!defined('ABSPATH')) {
19 exit;
20 }
21
22 /**
23 * SEO Quick Edit AJAX Handler Class
24 *
25 * Single Responsibility: serve and persist the Quick Edit SEO modal fields.
26 *
27 * @since 1.28.0
28 */
29 class Seo_Quick_Edit_Ajax {
30
31 /**
32 * AJAX action that returns a post's editable SEO fields.
33 *
34 * @var string
35 */
36 private const AJAX_ACTION_GET = 'thinkrank_seo_quick_edit_get';
37
38 /**
39 * AJAX action that saves the modal's fields.
40 *
41 * @var string
42 */
43 private const AJAX_ACTION_SAVE = 'thinkrank_seo_quick_edit_save';
44
45 /**
46 * Nonce action shared by both AJAX actions.
47 *
48 * @var string
49 */
50 private const NONCE_ACTION = 'thinkrank_seo_quick_edit_nonce';
51
52 /**
53 * Initialize AJAX handlers
54 *
55 * @return void
56 */
57 public function init(): void {
58 add_action('wp_ajax_' . self::AJAX_ACTION_GET, [$this, 'handle_get']);
59 add_action('wp_ajax_' . self::AJAX_ACTION_SAVE, [$this, 'handle_save']);
60 }
61
62 /**
63 * Get nonce action name
64 *
65 * @return string
66 */
67 public static function get_nonce_action(): string {
68 return self::NONCE_ACTION;
69 }
70
71 /**
72 * Get the "load fields" AJAX action name
73 *
74 * @return string
75 */
76 public static function get_ajax_action_get(): string {
77 return self::AJAX_ACTION_GET;
78 }
79
80 /**
81 * Get the "save fields" AJAX action name
82 *
83 * @return string
84 */
85 public static function get_ajax_action_save(): string {
86 return self::AJAX_ACTION_SAVE;
87 }
88
89 /**
90 * Nonce + capability gate shared by both handlers.
91 *
92 * Sends a JSON error response (and exits) on failure; returns the validated
93 * post ID on success.
94 *
95 * @return int Validated post ID.
96 */
97 private function authorize(): int {
98 if (!check_ajax_referer(self::NONCE_ACTION, 'nonce', false)) {
99 wp_send_json_error([
100 'message' => __('Security check failed. Please refresh the page and try again.', 'thinkrank'),
101 ], 403);
102 }
103
104 $post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;
105
106 if (!$post_id || !get_post($post_id)) {
107 wp_send_json_error([
108 'message' => __('Invalid post ID.', 'thinkrank'),
109 ], 400);
110 }
111
112 if (!current_user_can('edit_post', $post_id)) {
113 wp_send_json_error([
114 'message' => __('You do not have permission to edit this post.', 'thinkrank'),
115 ], 403);
116 }
117
118 return $post_id;
119 }
120
121 /**
122 * Return the modal's field values for a post.
123 *
124 * Sends both the RAW per-post meta (what the inputs edit) and the EFFECTIVE
125 * values resolved through the Global SEO patterns (what the frontend
126 * renders when the raw value is empty). The modal shows the effective value
127 * as a placeholder, so an empty field reads as "inheriting the pattern"
128 * rather than "blank" — and clearing a field genuinely returns the post to
129 * pattern inheritance instead of saving an empty override.
130 *
131 * @return void
132 */
133 public function handle_get(): void {
134 $post_id = $this->authorize();
135 $post = get_post($post_id);
136
137 wp_send_json_success([
138 'post_title' => $post->post_title,
139 'seo_title' => (string) get_post_meta($post_id, '_thinkrank_seo_title', true),
140 'meta_description' => (string) get_post_meta($post_id, '_thinkrank_meta_description', true),
141 'effective_title' => \ThinkRank\SEO\Pattern_Resolver::effective_title($post_id),
142 'effective_description' => \ThinkRank\SEO\Pattern_Resolver::effective_description($post_id),
143 'focus_keywords' => \ThinkRank\SEO\Focus_Keywords::get($post_id),
144 'edit_link' => get_edit_post_link($post_id, 'raw'),
145 ]);
146 }
147
148 /**
149 * Persist the modal's fields and return the re-rendered column cell.
150 *
151 * Persistence routes through Metabox_Manager::save_seo_fields() — the same
152 * sanitization and storage the editor metabox uses — so Quick Edit can
153 * never drift from the metabox in how values are written. Only fields
154 * present in the request are touched.
155 *
156 * @return void
157 */
158 public function handle_save(): void {
159 $post_id = $this->authorize();
160
161 // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in authorize()
162 $fields = [];
163
164 if (isset($_POST['seo_title'])) {
165 $fields['thinkrank_seo_title'] = sanitize_text_field(wp_unslash($_POST['seo_title']));
166 }
167
168 if (isset($_POST['meta_description'])) {
169 $fields['thinkrank_meta_description'] = sanitize_textarea_field(wp_unslash($_POST['meta_description']));
170 }
171
172 if (isset($_POST['focus_keywords'])) {
173 // Comma-separated string; Focus_Keywords::save() (via the shared
174 // persistence) normalizes, dedupes and applies the plan cap.
175 $fields['thinkrank_focus_keyword'] = sanitize_text_field(wp_unslash($_POST['focus_keywords']));
176 }
177 // phpcs:enable WordPress.Security.NonceVerification.Missing
178
179 if (empty($fields)) {
180 wp_send_json_error([
181 'message' => __('Nothing to save.', 'thinkrank'),
182 ], 400);
183 }
184
185 (new Metabox_Manager())->save_seo_fields($post_id, $fields);
186
187 // Bump post_modified so any stored analysis is treated as stale — the
188 // column must re-score against the post as it now stands.
189 Focus_Keyword_Ajax::update_post_modified($post_id);
190
191 wp_send_json_success([
192 'message' => __('SEO fields updated.', 'thinkrank'),
193 'column_html' => $this->render_column_cell($post_id),
194 ]);
195 }
196
197 /**
198 * Re-render the SEO Overview column cell for a post.
199 *
200 * The saved title/description change the calculated-score fingerprint, so
201 * this render recomputes the score and the returned markup carries the
202 * post's fresh state — the JS swaps it into the row in place.
203 *
204 * @param int $post_id Post ID.
205 * @return string Column cell HTML.
206 */
207 private function render_column_cell(int $post_id): string {
208 ob_start();
209 (new Post_List_Columns())->render_column_content('thinkrank_seo_overview', $post_id);
210
211 return (string) ob_get_clean();
212 }
213 }
214