PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / third-party / seo / aioseo / class-avcf-abilities-aioseo.php
atarim-visual-collaboration / third-party / seo / aioseo Last commit date
class-avcf-abilities-aioseo.php 2 weeks ago class-avcf-aioseo-detector.php 4 weeks ago
class-avcf-abilities-aioseo.php
561 lines
1 <?php
2 /**
3 * All in One SEO (AIOSEO) — MCP abilities (orchestrator + all abilities).
4 *
5 * AIOSEO stores SEO data in its own tables, accessed via its ORM models
6 * (\AIOSEO\Plugin\Common\Models\Post / Term). Per-post/term reads and writes
7 * go through getPost()/getTerm() + ->save(). Robots is exposed via friendly
8 * index/follow labels; AIOSEO's robots_default flag means "use site defaults".
9 * Redirects are AIOSEO Pro only and self-gate on the Pro redirect model.
10 *
11 * Because the ORM surface varies across AIOSEO versions, every callback is
12 * wrapped defensively and returns a structured message on failure rather than
13 * fataling. Redirects in particular should be validated first on a live site.
14 *
15 * Exposed abilities (atarim/aioseo-*):
16 * check-setup, get-post-seo, edit-post-seo, get-term-seo, edit-term-seo,
17 * get-post-schema, edit-post-schema, get-settings,
18 * list-redirects, create-redirect, edit-redirect, delete-redirect
19 *
20 * @package atarim-visual-collaboration
21 */
22
23 if ( ! defined('ABSPATH') ) {
24 exit;
25 }
26
27 class AVCF_Abilities_AIOSEO extends AVCF_Abilities_Base {
28
29 /** @var AVCF_AIOSEO_Detector */
30 private $detector;
31
32 public function __construct() {
33 $this->detector = new AVCF_AIOSEO_Detector();
34 }
35
36 public function register() {
37 if ( ! $this->detector->avcf_aioseo_is_available() ) {
38 return;
39 }
40 $this->register_check_setup();
41 $this->register_post_seo();
42 $this->register_term_seo();
43 $this->register_post_schema();
44 $this->register_settings();
45 $this->register_redirects();
46 }
47
48 /* ----------------------------- helpers ----------------------------- */
49
50 private function can_edit_post( $post_id ) {
51 return current_user_can( 'edit_post', (int) $post_id ) || current_user_can( 'edit_posts' );
52 }
53 private function ro_meta() {
54 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ] ];
55 }
56 private function write_meta( $destructive = false ) {
57 return [ 'mcp' => [ 'public' => true, 'type' => 'tool' ], 'annotations' => [ 'readonly' => false, 'destructive' => (bool) $destructive, 'idempotent' => false ] ];
58 }
59
60 /**
61 * AIOSEO's Post model hands its JSON columns back already decoded, and as a
62 * stdClass rather than an array — read_focus has always handled all three
63 * shapes. build_keyphrases only handled the string, so an object fell
64 * through to an empty array and wiped `additional` and the score/analysis
65 * it was meant to preserve. Normalise once, here.
66 *
67 * @return array
68 */
69 private function normalise_keyphrases( $keyphrases ) {
70 if ( is_string( $keyphrases ) ) {
71 $keyphrases = '' !== $keyphrases ? json_decode( $keyphrases, true ) : null;
72 } elseif ( is_object( $keyphrases ) ) {
73 $keyphrases = json_decode( wp_json_encode( $keyphrases ), true );
74 }
75
76 return is_array( $keyphrases ) ? $keyphrases : [];
77 }
78
79 /** Read the focus keyphrase out of AIOSEO's keyphrases JSON. */
80 private function read_focus( $keyphrases ) {
81 if ( is_string( $keyphrases ) ) {
82 $keyphrases = json_decode( $keyphrases, true );
83 }
84 if ( is_array( $keyphrases ) && isset( $keyphrases['focus']['keyphrase'] ) ) {
85 return (string) $keyphrases['focus']['keyphrase'];
86 }
87 if ( is_object( $keyphrases ) && isset( $keyphrases->focus->keyphrase ) ) {
88 return (string) $keyphrases->focus->keyphrase;
89 }
90 return '';
91 }
92
93 /**
94 * Preserve whatever AIOSEO already computed. Rewriting score/analysis to
95 * zero on every focus-keyphrase write left the AIOSEO panel worse than
96 * before the AI touched it; only the keyphrase itself should change, and
97 * only its own score/analysis reset when the keyphrase actually differs.
98 */
99 private function build_keyphrases( $kw, $existing = null ) {
100 $decoded = $this->normalise_keyphrases( $existing );
101
102 $focus = isset( $decoded['focus'] ) && is_array( $decoded['focus'] ) ? $decoded['focus'] : [];
103 $unchanged = isset( $focus['keyphrase'] ) && (string) $focus['keyphrase'] === (string) $kw;
104
105 $focus['keyphrase'] = (string) $kw;
106 $focus['score'] = $unchanged && isset( $focus['score'] ) ? $focus['score'] : 0;
107 $focus['analysis'] = $unchanged && isset( $focus['analysis'] ) ? $focus['analysis'] : [];
108
109 return wp_json_encode( [
110 'focus' => $focus,
111 'additional' => isset( $decoded['additional'] ) && is_array( $decoded['additional'] ) ? $decoded['additional'] : [],
112 ] );
113 }
114
115 private function read_post_model( $model ) {
116 $g = function( $prop ) use ( $model ) { return isset( $model->$prop ) ? $model->$prop : null; };
117 $robots_default = (bool) $g( 'robots_default' );
118 return [
119 'seo_title' => (string) $g( 'title' ),
120 'meta_description' => (string) $g( 'description' ),
121 'canonical' => (string) $g( 'canonical_url' ),
122 'focus_keyphrase' => $this->read_focus( $g( 'keyphrases' ) ),
123 'robots' => [
124 'index' => $robots_default ? 'default' : ( $g( 'robots_noindex' ) ? 'noindex' : 'index' ),
125 'follow' => $g( 'robots_nofollow' ) ? 'nofollow' : 'follow',
126 ],
127 'og_title' => (string) $g( 'og_title' ),
128 'og_description' => (string) $g( 'og_description' ),
129 'og_image' => (string) $g( 'og_image_custom_url' ),
130 'twitter_title' => (string) $g( 'twitter_title' ),
131 'twitter_description' => (string) $g( 'twitter_description' ),
132 'twitter_image' => (string) $g( 'twitter_image_custom_url' ),
133 ];
134 }
135
136 /* --------------------------- check-setup --------------------------- */
137
138 private function register_check_setup() {
139 $detector = $this->detector;
140 wp_register_ability( 'atarim/aioseo-check-setup', [
141 'label' => 'Check AIOSEO Setup',
142 'description' => 'Call first before other AIOSEO abilities. Reports whether AIOSEO is active, its version, and whether the Pro Redirects feature is available (required for the redirect abilities).',
143 'category' => 'atarim',
144 'input_schema' => [ 'type' => 'object', 'properties' => [], 'additionalProperties' => false ],
145 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'active' => [ 'type' => 'boolean' ], 'version' => [ 'type' => 'string' ], 'redirects_available' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'active', 'message' ] ],
146 'execute_callback' => function( $input = [] ) use ( $detector ) {
147 return [
148 'success' => true,
149 'active' => $detector->avcf_aioseo_is_available(),
150 'version' => $detector->avcf_aioseo_version(),
151 'redirects_available' => $detector->avcf_aioseo_has_redirects(),
152 'message' => 'OK.',
153 ];
154 },
155 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
156 'meta' => $this->ro_meta(),
157 ] );
158 }
159
160 /* --------------------------- post SEO ------------------------------ */
161
162 private function register_post_seo() {
163 $self = $this;
164
165 wp_register_ability( 'atarim/aioseo-get-post-seo', [
166 'label' => 'Get Post SEO (AIOSEO)',
167 'description' => 'Read a post\'s AIOSEO settings: seo_title, meta_description, canonical, focus_keyphrase, robots (index: default|index|noindex, follow: follow|nofollow), and OG/Twitter fields.',
168 'category' => 'atarim',
169 'input_schema' => [ 'type' => 'object', 'properties' => [ 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ] ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
170 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'post_id' => [ 'type' => 'integer' ], 'seo' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
171 'execute_callback' => function( $input = [] ) use ( $self ) {
172 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
173 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
174 try {
175 $model = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
176 return [ 'success' => true, 'post_id' => $post_id, 'seo' => $self->read_post_model( $model ), 'message' => 'OK.' ];
177 } catch ( \Throwable $e ) {
178 return [ 'success' => false, 'message' => 'Failed to read AIOSEO data: ' . $e->getMessage() ];
179 }
180 },
181 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
182 'meta' => $this->ro_meta(),
183 ] );
184
185 wp_register_ability( 'atarim/aioseo-edit-post-seo', [
186 'label' => 'Edit Post SEO (AIOSEO)',
187 'description' => 'Partial update of a post\'s AIOSEO settings — only fields you send change. Fields: seo_title, meta_description, canonical, focus_keyphrase, robots {index: default|index|noindex, follow: follow|nofollow}, og_title, og_description, og_image, twitter_title, twitter_description, twitter_image. Read first with aioseo-get-post-seo.',
188 'category' => 'atarim',
189 'input_schema' => [
190 'type' => 'object',
191 'properties' => [
192 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
193 'seo_title' => [ 'type' => 'string' ],
194 'meta_description' => [ 'type' => 'string' ],
195 'canonical' => [ 'type' => 'string' ],
196 'focus_keyphrase' => [ 'type' => 'string' ],
197 'robots' => [ 'type' => 'object', 'properties' => [ 'index' => [ 'type' => 'string', 'enum' => [ 'default', 'index', 'noindex' ] ], 'follow' => [ 'type' => 'string', 'enum' => [ 'follow', 'nofollow' ] ] ] ],
198 'og_title' => [ 'type' => 'string' ],
199 'og_description' => [ 'type' => 'string' ],
200 'og_image' => [ 'type' => 'string' ],
201 'twitter_title' => [ 'type' => 'string' ],
202 'twitter_description' => [ 'type' => 'string' ],
203 'twitter_image' => [ 'type' => 'string' ],
204 ],
205 'required' => [ 'post_id' ],
206 'additionalProperties' => false,
207 ],
208 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'post_id' => [ 'type' => 'integer' ], 'seo' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
209 'execute_callback' => function( $input = [] ) use ( $self ) {
210 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
211 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
212 if ( ! $self->can_edit_post( $post_id ) ) { return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ]; }
213 try {
214 $model = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
215 $direct = [ 'seo_title' => 'title', 'meta_description' => 'description', 'canonical' => 'canonical_url', 'og_title' => 'og_title', 'og_description' => 'og_description', 'og_image' => 'og_image_custom_url', 'twitter_title' => 'twitter_title', 'twitter_description' => 'twitter_description', 'twitter_image' => 'twitter_image_custom_url' ];
216 foreach ( $direct as $friendly => $prop ) {
217 if ( array_key_exists( $friendly, $input ) ) {
218 $model->$prop = (string) $input[ $friendly ];
219 }
220 }
221 if ( array_key_exists( 'focus_keyphrase', $input ) ) {
222 $model->keyphrases = $self->build_keyphrases(
223 $input['focus_keyphrase'],
224 isset( $model->keyphrases ) ? $model->keyphrases : null
225 );
226 }
227 if ( isset( $input['robots'] ) && is_array( $input['robots'] ) ) {
228 if ( array_key_exists( 'index', $input['robots'] ) ) {
229 if ( $input['robots']['index'] === 'default' ) {
230 $model->robots_default = true;
231 } else {
232 $model->robots_default = false;
233 $model->robots_noindex = ( $input['robots']['index'] === 'noindex' );
234 }
235 }
236 if ( array_key_exists( 'follow', $input['robots'] ) ) {
237 $model->robots_default = false;
238 $model->robots_nofollow = ( $input['robots']['follow'] === 'nofollow' );
239 }
240 }
241 $model->save();
242 $fresh = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
243 return [ 'success' => true, 'post_id' => $post_id, 'seo' => $self->read_post_model( $fresh ), 'message' => 'Updated.' ];
244 } catch ( \Throwable $e ) {
245 return [ 'success' => false, 'message' => 'Update failed: ' . $e->getMessage() ];
246 }
247 },
248 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
249 'meta' => $this->write_meta( false ),
250 ] );
251 }
252
253 /* --------------------------- term SEO ------------------------------ */
254
255 private function register_term_seo() {
256 $self = $this;
257
258 wp_register_ability( 'atarim/aioseo-get-term-seo', [
259 'label' => 'Get Term SEO (AIOSEO)',
260 'description' => 'Read a term\'s AIOSEO settings: seo_title, meta_description, canonical, robots (index/follow). Requires an AIOSEO version with term support.',
261 'category' => 'atarim',
262 'input_schema' => [ 'type' => 'object', 'properties' => [ 'term_id' => [ 'type' => 'integer', 'minimum' => 1 ] ], 'required' => [ 'term_id' ], 'additionalProperties' => false ],
263 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'seo' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
264 'execute_callback' => function( $input = [] ) use ( $self ) {
265 $term_id = isset( $input['term_id'] ) ? (int) $input['term_id'] : 0;
266 if ( $term_id <= 0 ) { return [ 'success' => false, 'message' => 'A valid term_id is required.' ]; }
267 if ( ! class_exists( '\AIOSEO\Plugin\Common\Models\Term' ) ) {
268 return [ 'success' => false, 'message' => 'This AIOSEO version does not expose term SEO via the Term model.' ];
269 }
270 try {
271 $model = \AIOSEO\Plugin\Common\Models\Term::getTerm( $term_id );
272 $g = function( $p ) use ( $model ) { return isset( $model->$p ) ? $model->$p : null; };
273 $robots_default = (bool) $g( 'robots_default' );
274 return [ 'success' => true, 'seo' => [
275 'seo_title' => (string) $g( 'title' ),
276 'meta_description' => (string) $g( 'description' ),
277 'canonical' => (string) $g( 'canonical_url' ),
278 'robots' => [ 'index' => $robots_default ? 'default' : ( $g( 'robots_noindex' ) ? 'noindex' : 'index' ), 'follow' => $g( 'robots_nofollow' ) ? 'nofollow' : 'follow' ],
279 ], 'message' => 'OK.' ];
280 } catch ( \Throwable $e ) {
281 return [ 'success' => false, 'message' => 'Failed to read AIOSEO term data: ' . $e->getMessage() ];
282 }
283 },
284 'permission_callback' => function() { return current_user_can( 'manage_categories' ); },
285 'meta' => $this->ro_meta(),
286 ] );
287
288 wp_register_ability( 'atarim/aioseo-edit-term-seo', [
289 'label' => 'Edit Term SEO (AIOSEO)',
290 'description' => 'Partial update of a term\'s AIOSEO settings. Fields: seo_title, meta_description, canonical, robots {index, follow}. Requires an AIOSEO version with term support.',
291 'category' => 'atarim',
292 'input_schema' => [ 'type' => 'object', 'properties' => [
293 'term_id' => [ 'type' => 'integer', 'minimum' => 1 ],
294 'seo_title' => [ 'type' => 'string' ],
295 'meta_description' => [ 'type' => 'string' ],
296 'canonical' => [ 'type' => 'string' ],
297 'robots' => [ 'type' => 'object', 'properties' => [ 'index' => [ 'type' => 'string', 'enum' => [ 'default', 'index', 'noindex' ] ], 'follow' => [ 'type' => 'string', 'enum' => [ 'follow', 'nofollow' ] ] ] ],
298 ], 'required' => [ 'term_id' ], 'additionalProperties' => false ],
299 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
300 'execute_callback' => function( $input = [] ) use ( $self ) {
301 $term_id = isset( $input['term_id'] ) ? (int) $input['term_id'] : 0;
302 if ( $term_id <= 0 ) { return [ 'success' => false, 'message' => 'A valid term_id is required.' ]; }
303 if ( ! class_exists( '\AIOSEO\Plugin\Common\Models\Term' ) ) {
304 return [ 'success' => false, 'message' => 'This AIOSEO version does not expose term SEO via the Term model.' ];
305 }
306 if ( ! current_user_can( 'manage_categories' ) ) {
307 return [ 'success' => false, 'message' => 'You do not have permission to edit terms.' ];
308 }
309 try {
310 $model = \AIOSEO\Plugin\Common\Models\Term::getTerm( $term_id );
311 $direct = [ 'seo_title' => 'title', 'meta_description' => 'description', 'canonical' => 'canonical_url' ];
312 foreach ( $direct as $friendly => $prop ) {
313 if ( array_key_exists( $friendly, $input ) ) { $model->$prop = (string) $input[ $friendly ]; }
314 }
315 if ( isset( $input['robots'] ) && is_array( $input['robots'] ) ) {
316 if ( array_key_exists( 'index', $input['robots'] ) ) {
317 if ( $input['robots']['index'] === 'default' ) { $model->robots_default = true; }
318 else { $model->robots_default = false; $model->robots_noindex = ( $input['robots']['index'] === 'noindex' ); }
319 }
320 if ( array_key_exists( 'follow', $input['robots'] ) ) {
321 $model->robots_default = false;
322 $model->robots_nofollow = ( $input['robots']['follow'] === 'nofollow' );
323 }
324 }
325 $model->save();
326 return [ 'success' => true, 'message' => 'Updated.' ];
327 } catch ( \Throwable $e ) {
328 return [ 'success' => false, 'message' => 'Update failed: ' . $e->getMessage() ];
329 }
330 },
331 'permission_callback' => function() { return current_user_can( 'manage_categories' ); },
332 'meta' => $this->write_meta( false ),
333 ] );
334 }
335
336 /* --------------------------- post schema --------------------------- */
337
338 private function register_post_schema() {
339 wp_register_ability( 'atarim/aioseo-get-post-schema', [
340 'label' => 'Get Post Schema (AIOSEO)',
341 'description' => 'Read the AIOSEO schema graph configuration for a post (decoded from the model\'s schema JSON). Returns an empty object if none is set or the AIOSEO version predates schema support.',
342 'category' => 'atarim',
343 'input_schema' => [ 'type' => 'object', 'properties' => [ 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ] ], 'required' => [ 'post_id' ], 'additionalProperties' => false ],
344 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'schema' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
345 'execute_callback' => function( $input = [] ) {
346 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
347 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
348 try {
349 $model = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
350 $schema = isset( $model->schema ) ? $model->schema : null;
351 if ( is_string( $schema ) ) { $schema = json_decode( $schema, true ); }
352 return [ 'success' => true, 'schema' => is_array( $schema ) ? $schema : [], 'message' => 'OK.' ];
353 } catch ( \Throwable $e ) {
354 return [ 'success' => false, 'message' => 'Failed to read schema: ' . $e->getMessage() ];
355 }
356 },
357 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
358 'meta' => $this->ro_meta(),
359 ] );
360
361 wp_register_ability( 'atarim/aioseo-edit-post-schema', [
362 'label' => 'Edit Post Schema (AIOSEO)',
363 'description' => 'Replace the AIOSEO schema graph for a post with the supplied schema object (stored as the model\'s schema JSON). Advanced — read with aioseo-get-post-schema first and pass the modified object back whole.',
364 'category' => 'atarim',
365 'input_schema' => [ 'type' => 'object', 'properties' => [
366 'post_id' => [ 'type' => 'integer', 'minimum' => 1 ],
367 'schema' => [ 'type' => 'object', 'description' => 'The full schema graph object to store.' ],
368 ], 'required' => [ 'post_id', 'schema' ], 'additionalProperties' => false ],
369 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
370 'execute_callback' => function( $input = [] ) {
371 $post_id = isset( $input['post_id'] ) ? (int) $input['post_id'] : 0;
372 if ( $post_id <= 0 || ! get_post( $post_id ) ) { return [ 'success' => false, 'message' => 'A valid post_id is required.' ]; }
373 if ( ! ( current_user_can( 'edit_post', $post_id ) || current_user_can( 'edit_posts' ) ) ) {
374 return [ 'success' => false, 'message' => 'You do not have permission to edit this post.' ];
375 }
376 try {
377 $model = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
378 if ( ! property_exists( $model, 'schema' ) && ! isset( $model->schema ) ) {
379 return [ 'success' => false, 'message' => 'This AIOSEO version does not support per-post schema editing.' ];
380 }
381 $model->schema = wp_json_encode( $input['schema'] );
382 $model->save();
383 return [ 'success' => true, 'message' => 'Schema updated.' ];
384 } catch ( \Throwable $e ) {
385 return [ 'success' => false, 'message' => 'Update failed: ' . $e->getMessage() ];
386 }
387 },
388 'permission_callback' => function() { return current_user_can( 'edit_posts' ); },
389 'meta' => $this->write_meta( false ),
390 ] );
391 }
392
393 /* --------------------------- settings ------------------------------ */
394
395 private function register_settings() {
396 wp_register_ability( 'atarim/aioseo-get-settings', [
397 'label' => 'Get AIOSEO Settings',
398 'description' => 'Read selected site-wide AIOSEO settings (read-only) from the stored options: separator and the global title/description templates where available.',
399 'category' => 'atarim',
400 'input_schema' => [ 'type' => 'object', 'properties' => [], 'additionalProperties' => false ],
401 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'settings' => [ 'type' => 'object' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
402 'execute_callback' => function( $input = [] ) {
403 $raw = get_option( 'aioseo_options', '' );
404 $opts = is_string( $raw ) ? json_decode( $raw, true ) : ( is_array( $raw ) ? $raw : [] );
405 $sep = '';
406 $home_title = '';
407 $home_desc = '';
408 if ( is_array( $opts ) ) {
409 $sep = isset( $opts['searchAppearance']['global']['separator'] ) ? $opts['searchAppearance']['global']['separator'] : '';
410 $home_title = isset( $opts['searchAppearance']['global']['siteTitle'] ) ? $opts['searchAppearance']['global']['siteTitle'] : '';
411 $home_desc = isset( $opts['searchAppearance']['global']['metaDescription'] ) ? $opts['searchAppearance']['global']['metaDescription'] : '';
412 }
413 return [ 'success' => true, 'settings' => [ 'separator' => $sep, 'site_title' => $home_title, 'meta_description' => $home_desc ], 'message' => 'OK.' ];
414 },
415 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
416 'meta' => $this->ro_meta(),
417 ] );
418 }
419
420 /* --------------------------- redirects ----------------------------- */
421
422 private function register_redirects() {
423 $detector = $this->detector;
424
425 $guard = function() use ( $detector ) {
426 if ( ! $detector->avcf_aioseo_has_redirects() ) {
427 return [ 'success' => false, 'message' => 'AIOSEO Pro with the Redirects feature is required; it is not active on this site.' ];
428 }
429 return null;
430 };
431
432 wp_register_ability( 'atarim/aioseo-list-redirects', [
433 'label' => 'List Redirects (AIOSEO Pro)',
434 'description' => 'List AIOSEO redirects (source, target, type, enabled). Requires AIOSEO Pro Redirects.',
435 'category' => 'atarim',
436 'input_schema' => [ 'type' => 'object', 'properties' => [ 'limit' => [ 'type' => 'integer', 'default' => 100, 'minimum' => 1 ] ], 'additionalProperties' => false ],
437 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'total' => [ 'type' => 'integer' ], 'redirects' => [ 'type' => 'array' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
438 'execute_callback' => function( $input = [] ) use ( $guard ) {
439 $g = $guard(); if ( $g ) { return $g; }
440 global $wpdb;
441 $limit = isset( $input['limit'] ) ? max( 1, (int) $input['limit'] ) : 100;
442 $table = $wpdb->prefix . 'aioseo_redirects';
443 try {
444 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, source_url, target_url, type, enabled FROM {$table} ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A );
445 $out = [];
446 foreach ( (array) $rows as $r ) {
447 $out[] = [ 'id' => (int) $r['id'], 'source' => $r['source_url'], 'target' => $r['target_url'], 'type' => (int) $r['type'], 'enabled' => (bool) $r['enabled'] ];
448 }
449 return [ 'success' => true, 'total' => count( $out ), 'redirects' => $out, 'message' => 'OK.' ];
450 } catch ( \Throwable $e ) {
451 return [ 'success' => false, 'message' => 'Failed to read redirects: ' . $e->getMessage() ];
452 }
453 },
454 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
455 'meta' => $this->ro_meta(),
456 ] );
457
458 wp_register_ability( 'atarim/aioseo-create-redirect', [
459 'label' => 'Create Redirect (AIOSEO Pro)',
460 'description' => 'Create an AIOSEO redirect from a source path to a target. type is the HTTP code (301, 302, 307, 410, 451). Requires AIOSEO Pro Redirects.',
461 'category' => 'atarim',
462 'input_schema' => [ 'type' => 'object', 'properties' => [
463 'source' => [ 'type' => 'string', 'description' => 'Source path (e.g. "/old-page/").' ],
464 'target' => [ 'type' => 'string', 'description' => 'Destination URL or path.' ],
465 'type' => [ 'type' => 'integer', 'enum' => [ 301, 302, 307, 410, 451 ], 'default' => 301 ],
466 ], 'required' => [ 'source' ], 'additionalProperties' => false ],
467 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'id' => [ 'type' => 'integer' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
468 'execute_callback' => function( $input = [] ) use ( $guard, $detector ) {
469 $g = $guard(); if ( $g ) { return $g; }
470 $source = isset( $input['source'] ) ? (string) $input['source'] : '';
471 if ( $source === '' ) { return [ 'success' => false, 'message' => 'source is required.' ]; }
472 $class = $detector->avcf_aioseo_redirect_model();
473 if ( $class === '' ) { return [ 'success' => false, 'message' => 'AIOSEO redirect model not found.' ]; }
474 try {
475 $model = new $class();
476 $model->source_url = $source;
477 $model->target_url = isset( $input['target'] ) ? (string) $input['target'] : '';
478 $model->type = isset( $input['type'] ) ? (int) $input['type'] : 301;
479 $model->enabled = true;
480 $model->save();
481 $id = isset( $model->id ) ? (int) $model->id : 0;
482 return [ 'success' => true, 'id' => $id, 'message' => sprintf( 'Created redirect from %s.', $source ) ];
483 } catch ( \Throwable $e ) {
484 return [ 'success' => false, 'message' => 'Create failed: ' . $e->getMessage() ];
485 }
486 },
487 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
488 'meta' => $this->write_meta( false ),
489 ] );
490
491 wp_register_ability( 'atarim/aioseo-edit-redirect', [
492 'label' => 'Edit Redirect (AIOSEO Pro)',
493 'description' => 'Update an AIOSEO redirect by id. Supply any of target, type, enabled. Requires AIOSEO Pro Redirects.',
494 'category' => 'atarim',
495 'input_schema' => [ 'type' => 'object', 'properties' => [
496 'id' => [ 'type' => 'integer', 'minimum' => 1 ],
497 'target' => [ 'type' => 'string' ],
498 'type' => [ 'type' => 'integer', 'enum' => [ 301, 302, 307, 410, 451 ] ],
499 'enabled' => [ 'type' => 'boolean' ],
500 ], 'required' => [ 'id' ], 'additionalProperties' => false ],
501 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
502 'execute_callback' => function( $input = [] ) use ( $guard, $detector ) {
503 $g = $guard(); if ( $g ) { return $g; }
504 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
505 if ( $id <= 0 ) { return [ 'success' => false, 'message' => 'id is required.' ]; }
506 $class = $detector->avcf_aioseo_redirect_model();
507 if ( $class === '' ) { return [ 'success' => false, 'message' => 'AIOSEO redirect model not found.' ]; }
508 try {
509 $model = new $class( $id );
510 if ( empty( $model->id ) ) { return [ 'success' => false, 'message' => sprintf( 'Redirect %d not found.', $id ) ]; }
511 if ( array_key_exists( 'target', $input ) ) { $model->target_url = (string) $input['target']; }
512 if ( array_key_exists( 'type', $input ) ) { $model->type = (int) $input['type']; }
513 if ( array_key_exists( 'enabled', $input ) ) { $model->enabled = (bool) $input['enabled']; }
514 $model->save();
515 return [ 'success' => true, 'message' => sprintf( 'Updated redirect %d.', $id ) ];
516 } catch ( \Throwable $e ) {
517 return [ 'success' => false, 'message' => 'Edit failed: ' . $e->getMessage() ];
518 }
519 },
520 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
521 'meta' => $this->write_meta( false ),
522 ] );
523
524 wp_register_ability( 'atarim/aioseo-delete-redirect', [
525 'label' => 'Delete Redirect (AIOSEO Pro)',
526 'description' => 'Delete an AIOSEO redirect by id. Dry run unless confirm:true. Requires AIOSEO Pro Redirects.',
527 'category' => 'atarim',
528 'input_schema' => [ 'type' => 'object', 'properties' => [
529 'id' => [ 'type' => 'integer', 'minimum' => 1 ],
530 'confirm' => [ 'type' => 'boolean', 'default' => false ],
531 ], 'required' => [ 'id' ], 'additionalProperties' => false ],
532 'output_schema'=> [ 'type' => 'object', 'properties' => [ 'success' => [ 'type' => 'boolean' ], 'deleted' => [ 'type' => 'boolean' ], 'message' => [ 'type' => 'string' ] ], 'required' => [ 'success', 'message' ] ],
533 'execute_callback' => function( $input = [] ) use ( $guard, $detector ) {
534 $g = $guard(); if ( $g ) { return $g; }
535 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
536 if ( $id <= 0 ) { return [ 'success' => false, 'message' => 'id is required.' ]; }
537 $class = $detector->avcf_aioseo_redirect_model();
538 if ( $class === '' ) { return [ 'success' => false, 'message' => 'AIOSEO redirect model not found.' ]; }
539 try {
540 $model = new $class( $id );
541 if ( empty( $model->id ) ) { return [ 'success' => false, 'message' => sprintf( 'Redirect %d not found.', $id ) ]; }
542 if ( empty( $input['confirm'] ) ) {
543 return [ 'success' => true, 'deleted' => false, 'message' => sprintf( 'Dry run: would delete redirect %d (%s). Re-call with confirm:true.', $id, isset( $model->source_url ) ? $model->source_url : '' ) ];
544 }
545 if ( method_exists( $model, 'delete' ) ) {
546 $model->delete();
547 } else {
548 global $wpdb;
549 $wpdb->delete( $wpdb->prefix . 'aioseo_redirects', [ 'id' => $id ] );
550 }
551 return [ 'success' => true, 'deleted' => true, 'message' => sprintf( 'Deleted redirect %d.', $id ) ];
552 } catch ( \Throwable $e ) {
553 return [ 'success' => false, 'message' => 'Delete failed: ' . $e->getMessage() ];
554 }
555 },
556 'permission_callback' => function() { return current_user_can( 'manage_options' ); },
557 'meta' => $this->write_meta( true ),
558 ] );
559 }
560 }
561