PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.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.0.0, at includes/admin/class-seo-quick-edit-ajax.php

215 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 'keyword_limit' => \ThinkRank\SEO\Focus_Keywords::limit(),
145 'edit_link' => get_edit_post_link($post_id, 'raw'),
146 ]);
147 }
148
149 /**
150 * Persist the modal's fields and return the re-rendered column cell.
151 *
152 * Persistence routes through Metabox_Manager::save_seo_fields() — the same
153 * sanitization and storage the editor metabox uses — so Quick Edit can
154 * never drift from the metabox in how values are written. Only fields
155 * present in the request are touched.
156 *
157 * @return void
158 */
159 public function handle_save(): void {
160 $post_id = $this->authorize();
161
162 // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified in authorize()
163 $fields = [];
164
165 if (isset($_POST['seo_title'])) {
166 $fields['thinkrank_seo_title'] = sanitize_text_field(wp_unslash($_POST['seo_title']));
167 }
168
169 if (isset($_POST['meta_description'])) {
170 $fields['thinkrank_meta_description'] = sanitize_textarea_field(wp_unslash($_POST['meta_description']));
171 }
172
173 if (isset($_POST['focus_keywords'])) {
174 // Comma-separated string; Focus_Keywords::save() (via the shared
175 // persistence) normalizes, dedupes and applies the plan cap.
176 $fields['thinkrank_focus_keyword'] = sanitize_text_field(wp_unslash($_POST['focus_keywords']));
177 }
178 // phpcs:enable WordPress.Security.NonceVerification.Missing
179
180 if (empty($fields)) {
181 wp_send_json_error([
182 'message' => __('Nothing to save.', 'thinkrank'),
183 ], 400);
184 }
185
186 (new Metabox_Manager())->save_seo_fields($post_id, $fields);
187
188 // Bump post_modified so any stored analysis is treated as stale — the
189 // column must re-score against the post as it now stands.
190 Focus_Keyword_Ajax::update_post_modified($post_id);
191
192 wp_send_json_success([
193 'message' => __('SEO fields updated.', 'thinkrank'),
194 'column_html' => $this->render_column_cell($post_id),
195 ]);
196 }
197
198 /**
199 * Re-render the SEO Overview column cell for a post.
200 *
201 * The saved title/description change the calculated-score fingerprint, so
202 * this render recomputes the score and the returned markup carries the
203 * post's fresh state — the JS swaps it into the row in place.
204 *
205 * @param int $post_id Post ID.
206 * @return string Column cell HTML.
207 */
208 private function render_column_cell(int $post_id): string {
209 ob_start();
210 (new Post_List_Columns())->render_column_content('thinkrank_seo_overview', $post_id);
211
212 return (string) ob_get_clean();
213 }
214 }
215