| 1 |
<?php |
| 2 |
/** |
| 3 |
* Get post content ability. |
| 4 |
* |
| 5 |
* @package ThinkRank\Abilities\Content |
| 6 |
*/ |
| 7 |
|
| 8 |
declare(strict_types=1); |
| 9 |
|
| 10 |
namespace ThinkRank\Abilities\Content; |
| 11 |
|
| 12 |
use ThinkRank\Abilities\Ability_Base; |
| 13 |
use ThinkRank\SEO\Builder_Content; |
| 14 |
|
| 15 |
if ( ! defined( 'ABSPATH' ) ) { |
| 16 |
exit; // Exit if accessed directly. |
| 17 |
} |
| 18 |
|
| 19 |
/** |
| 20 |
* Reads a post's body content. |
| 21 |
* |
| 22 |
* update-post-seo declares `additionalProperties: false` over an SEO-only field |
| 23 |
* list, and nothing else read the body at all — so an agent was optimising a |
| 24 |
* title and description for content it could not see (#676). |
| 25 |
* |
| 26 |
* Read-only, deliberately. There is no counterpart that writes the body, and |
| 27 |
* that is the point rather than an omission: page-builder content does not live |
| 28 |
* in `post_content` — Bricks, Oxygen, Breakdance and Elementor keep their trees |
| 29 |
* in postmeta — so a write ability that set `post_content` would appear to |
| 30 |
* succeed and change nothing on a builder page, or worse, resurrect stale |
| 31 |
* blocks the builder had superseded. That is the failure mode #335 already |
| 32 |
* documents. A write surface needs to be builder-aware before it is safe, which |
| 33 |
* is a larger piece of work than this. |
| 34 |
* |
| 35 |
* `content` is therefore what the page actually renders, resolved through the |
| 36 |
* same pipeline the SEO score and meta description use, and `post_content` is |
| 37 |
* the raw stored column beside it so the difference is visible rather than |
| 38 |
* guessed at. |
| 39 |
*/ |
| 40 |
class Get_Post_Content extends Ability_Base { |
| 41 |
|
| 42 |
/** |
| 43 |
* Constructor. |
| 44 |
*/ |
| 45 |
public function __construct() { |
| 46 |
$this->id = 'thinkrank/get-post-content'; |
| 47 |
$this->label = __( 'Get ThinkRank Post Content', 'thinkrank' ); |
| 48 |
$this->description = __( 'Read a post or page\'s body content so you can judge what it is about before changing its SEO fields. Returns the text the page actually renders, resolved from the page builder when one owns the page, plus the raw stored post_content and which builder is in use. Read-only: use update-post-seo to change SEO fields. Use list-content-items to find ids.', 'thinkrank' ); |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* {@inheritDoc} |
| 53 |
* |
| 54 |
* @return array<string, bool|float|string> |
| 55 |
*/ |
| 56 |
public function get_annotations() { |
| 57 |
return [ |
| 58 |
'readonly' => true, |
| 59 |
'destructive' => false, |
| 60 |
'idempotent' => true, |
| 61 |
'priority' => 1.0, |
| 62 |
'openWorldHint' => false, |
| 63 |
]; |
| 64 |
} |
| 65 |
|
| 66 |
/** |
| 67 |
* {@inheritDoc} |
| 68 |
* |
| 69 |
* @return array<string, mixed> |
| 70 |
*/ |
| 71 |
public function get_input_schema() { |
| 72 |
return [ |
| 73 |
'type' => 'object', |
| 74 |
'additionalProperties' => false, |
| 75 |
'required' => [ 'post_id' ], |
| 76 |
'properties' => [ |
| 77 |
'post_id' => [ |
| 78 |
'type' => 'integer', |
| 79 |
'description' => __( 'Post or page ID from list-content-items.', 'thinkrank' ), |
| 80 |
], |
| 81 |
'max_chars' => [ |
| 82 |
'type' => 'integer', |
| 83 |
'description' => __( 'Truncate the returned body to this many characters. Omit for the whole thing.', 'thinkrank' ), |
| 84 |
], |
| 85 |
], |
| 86 |
]; |
| 87 |
} |
| 88 |
|
| 89 |
/** |
| 90 |
* {@inheritDoc} |
| 91 |
* |
| 92 |
* @return array<string, mixed> |
| 93 |
*/ |
| 94 |
public function get_output_schema() { |
| 95 |
return [ |
| 96 |
'type' => 'object', |
| 97 |
'properties' => [ |
| 98 |
'id' => [ 'type' => 'integer' ], |
| 99 |
'title' => [ 'type' => 'string' ], |
| 100 |
'post_type' => [ 'type' => 'string' ], |
| 101 |
'status' => [ 'type' => 'string' ], |
| 102 |
'url' => [ 'type' => 'string' ], |
| 103 |
'edit_url' => [ 'type' => 'string' ], |
| 104 |
'excerpt' => [ 'type' => 'string' ], |
| 105 |
'content' => [ |
| 106 |
'type' => 'string', |
| 107 |
'description' => __( 'Plain-text body as the page renders it, resolved from the page builder where one owns the page.', 'thinkrank' ), |
| 108 |
], |
| 109 |
'post_content' => [ |
| 110 |
'type' => 'string', |
| 111 |
'description' => __( 'The raw stored post_content column. Empty or stale on builder pages, which is why it is reported separately.', 'thinkrank' ), |
| 112 |
], |
| 113 |
'content_source' => [ |
| 114 |
'type' => 'string', |
| 115 |
'enum' => [ 'post_content', 'builder' ], |
| 116 |
'description' => __( 'Where the rendered body came from.', 'thinkrank' ), |
| 117 |
], |
| 118 |
'builder' => [ |
| 119 |
'type' => [ 'string', 'null' ], |
| 120 |
'description' => __( 'The page builder that owns this post, or null for the block/classic editor.', 'thinkrank' ), |
| 121 |
], |
| 122 |
'word_count' => [ 'type' => 'integer' ], |
| 123 |
'truncated' => [ 'type' => 'boolean' ], |
| 124 |
], |
| 125 |
]; |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Execute ability. |
| 130 |
* |
| 131 |
* @param array<string, mixed> $input Ability input payload. |
| 132 |
* @return array<string, mixed>|\WP_Error |
| 133 |
*/ |
| 134 |
public function execute( $input ) { |
| 135 |
$input = (array) $input; |
| 136 |
$post_id = (int) ( $input['post_id'] ?? 0 ); |
| 137 |
$post = $post_id > 0 ? get_post( $post_id ) : null; |
| 138 |
|
| 139 |
if ( ! $post instanceof \WP_Post ) { |
| 140 |
return new \WP_Error( |
| 141 |
'thinkrank_post_not_found', |
| 142 |
sprintf( |
| 143 |
/* translators: %d: post ID that was sent. */ |
| 144 |
__( 'No post with id %d. Use list-content-items to find valid ids.', 'thinkrank' ), |
| 145 |
$post_id |
| 146 |
), |
| 147 |
[ 'status' => 404 ] |
| 148 |
); |
| 149 |
} |
| 150 |
|
| 151 |
// Reading a password-protected body would hand its contents to anyone |
| 152 |
// the connector answers, so it is withheld the way the front end |
| 153 |
// withholds it. |
| 154 |
if ( '' !== (string) $post->post_password ) { |
| 155 |
return new \WP_Error( |
| 156 |
'thinkrank_post_password_protected', |
| 157 |
__( 'This post is password protected, so its body is not returned.', 'thinkrank' ), |
| 158 |
[ 'status' => 403 ] |
| 159 |
); |
| 160 |
} |
| 161 |
|
| 162 |
$resolved = trim( wp_strip_all_tags( Builder_Content::resolve( $post ) ) ); |
| 163 |
$builder = $this->builder_for( $post_id ); |
| 164 |
|
| 165 |
$body = $resolved; |
| 166 |
$truncated = false; |
| 167 |
|
| 168 |
if ( isset( $input['max_chars'] ) ) { |
| 169 |
$limit = max( 1, (int) $input['max_chars'] ); |
| 170 |
|
| 171 |
if ( mb_strlen( $body ) > $limit ) { |
| 172 |
$body = mb_substr( $body, 0, $limit ); |
| 173 |
$truncated = true; |
| 174 |
} |
| 175 |
} |
| 176 |
|
| 177 |
return [ |
| 178 |
'id' => $post_id, |
| 179 |
'title' => (string) get_the_title( $post ), |
| 180 |
'post_type' => (string) $post->post_type, |
| 181 |
'status' => (string) $post->post_status, |
| 182 |
'url' => (string) get_permalink( $post ), |
| 183 |
'edit_url' => (string) get_edit_post_link( $post_id, 'raw' ), |
| 184 |
'excerpt' => (string) $post->post_excerpt, |
| 185 |
'content' => $body, |
| 186 |
'post_content' => (string) $post->post_content, |
| 187 |
'content_source' => null === $builder ? 'post_content' : 'builder', |
| 188 |
'builder' => $builder, |
| 189 |
'word_count' => '' === $resolved ? 0 : count( preg_split( '/\s+/u', $resolved ) ?: [] ), |
| 190 |
'truncated' => $truncated, |
| 191 |
]; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Which page builder owns this post, if any. |
| 196 |
* |
| 197 |
* Derived from the meta keys Builder_Content already resolves through, so |
| 198 |
* the answer cannot drift from the resolution above. |
| 199 |
* |
| 200 |
* @param int $post_id Post ID. |
| 201 |
* @return string|null Builder label, or null for the block/classic editor. |
| 202 |
*/ |
| 203 |
private function builder_for( int $post_id ): ?string { |
| 204 |
if ( Builder_Content::bricks_supersedes_post_content( $post_id ) ) { |
| 205 |
return 'bricks'; |
| 206 |
} |
| 207 |
|
| 208 |
// Same keys Builder_Content resolves through, in the same order. Kept |
| 209 |
// as a local map because this one also needs a display label per key, |
| 210 |
// and Oxygen spans several of them across its generations — Oxygen 6 is |
| 211 |
// Breakdance under the hood, and Oxygen classic 4.8.3+ prefixes its |
| 212 |
// keys with an underscore. Missing the JSON and prefixed keys, a |
| 213 |
// current Oxygen classic page was reported as built in no builder. |
| 214 |
$labels = [ |
| 215 |
'_breakdance_data' => 'breakdance', |
| 216 |
'_oxygen_data' => 'oxygen', |
| 217 |
'_ct_builder_json' => 'oxygen', |
| 218 |
'ct_builder_json' => 'oxygen', |
| 219 |
'_ct_builder_shortcodes' => 'oxygen', |
| 220 |
'ct_builder_shortcodes' => 'oxygen', |
| 221 |
'_elementor_data' => 'elementor', |
| 222 |
'_fl_builder_data' => 'beaver-builder', |
| 223 |
]; |
| 224 |
|
| 225 |
foreach ( $labels as $key => $label ) { |
| 226 |
$stored = get_post_meta( $post_id, $key, true ); |
| 227 |
|
| 228 |
if ( ( is_string( $stored ) && '' !== trim( $stored ) ) || ( is_array( $stored ) && ! empty( $stored ) ) ) { |
| 229 |
return $label; |
| 230 |
} |
| 231 |
} |
| 232 |
|
| 233 |
return null; |
| 234 |
} |
| 235 |
} |
| 236 |
|