| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPSP\API; |
| 4 |
use WPSP\Helper; |
| 5 |
|
| 6 |
/** |
| 7 |
* Custom Social Templates API Handler |
| 8 |
* |
| 9 |
* Handles all custom social template related functionality including: |
| 10 |
* - REST API endpoints for CRUD operations |
| 11 |
* - Meta field registration |
| 12 |
* - Template validation |
| 13 |
* - Scheduling functionality |
| 14 |
* |
| 15 |
* @since 2.6.0 |
| 16 |
*/ |
| 17 |
class CustomSocialTemplates |
| 18 |
{ |
| 19 |
/** |
| 20 |
* Instance of this class. |
| 21 |
* |
| 22 |
* @since 2.6.0 |
| 23 |
* |
| 24 |
* @var object |
| 25 |
*/ |
| 26 |
protected static $instance = null; |
| 27 |
|
| 28 |
/** |
| 29 |
* Initialize hooks |
| 30 |
*/ |
| 31 |
private function __construct() |
| 32 |
{ |
| 33 |
$this->do_hooks(); |
| 34 |
} |
| 35 |
|
| 36 |
/** |
| 37 |
* Set up WordPress hooks and filters |
| 38 |
* |
| 39 |
* @return void |
| 40 |
*/ |
| 41 |
public function do_hooks() |
| 42 |
{ |
| 43 |
add_action('rest_api_init', array($this, 'register_custom_template_routes')); |
| 44 |
add_action('rest_api_init', array($this, 'register_custom_template_meta')); |
| 45 |
add_action('wp_insert_post', array($this, 'initialize_custom_templates_meta'), 10, 2); |
| 46 |
|
| 47 |
// Dynamic scheduling hooks |
| 48 |
// add_action('transition_post_status', array($this, 'handle_post_status_change'), 10, 3); |
| 49 |
add_action('post_updated', array($this, 'handle_post_update'), 10, 3); |
| 50 |
add_action('wpsp_custom_social_template', array($this, 'handle_social_scheduling_published'), 99, 1); |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* Register custom template related meta fields |
| 55 |
*/ |
| 56 |
public function register_custom_template_meta() |
| 57 |
{ |
| 58 |
$allow_post_types = Helper::get_all_allowed_post_type(); |
| 59 |
$allow_post_types = (!empty($allow_post_types) ? $allow_post_types : array('post')); |
| 60 |
|
| 61 |
foreach ($allow_post_types as $type) { |
| 62 |
// Register enable/disable meta for Add Social Template |
| 63 |
register_post_meta( |
| 64 |
$type, |
| 65 |
'_wpsp_enable_custom_social_template', |
| 66 |
[ |
| 67 |
'show_in_rest' => true, |
| 68 |
'single' => true, |
| 69 |
'type' => 'boolean', |
| 70 |
'default' => false, |
| 71 |
'auth_callback' => function() { |
| 72 |
return current_user_can( 'edit_posts' ); |
| 73 |
} |
| 74 |
] |
| 75 |
); |
| 76 |
|
| 77 |
// Social scheduling data |
| 78 |
register_post_meta( |
| 79 |
$type, |
| 80 |
'_wpsp_social_scheduling', |
| 81 |
[ |
| 82 |
'show_in_rest' => [ |
| 83 |
'schema' => [ |
| 84 |
'type' => 'object', |
| 85 |
'properties' => [ |
| 86 |
'enabled' => ['type' => 'boolean'], |
| 87 |
'datetime' => ['type' => ['string', 'null'], 'format' => 'date-time'], |
| 88 |
'platforms' => ['type' => 'array', 'items' => ['type' => 'string']], |
| 89 |
'status' => ['type' => 'string'], |
| 90 |
'dateOption' => ['type' => 'string'], |
| 91 |
'timeOption' => ['type' => 'string'], |
| 92 |
'customDays' => ['type' => 'string'], |
| 93 |
'customHours' => ['type' => 'string'], |
| 94 |
'customDate' => ['type' => 'string'], |
| 95 |
'customTime' => ['type' => 'string'], |
| 96 |
'schedulingType' => ['type' => 'string'], |
| 97 |
], |
| 98 |
] |
| 99 |
], |
| 100 |
'single' => true, |
| 101 |
'type' => 'object', |
| 102 |
'default' => [ |
| 103 |
'enabled' => false, |
| 104 |
'datetime' => null, |
| 105 |
'platforms' => [], |
| 106 |
'status' => 'template_only', |
| 107 |
'dateOption' => 'today', |
| 108 |
'timeOption' => 'now', |
| 109 |
'customDays' => '', |
| 110 |
'customHours' => '', |
| 111 |
'customDate' => '', |
| 112 |
'customTime' => '', |
| 113 |
'schedulingType' => 'absolute' |
| 114 |
], |
| 115 |
'auth_callback' => function() { |
| 116 |
return current_user_can( 'edit_posts' ); |
| 117 |
} |
| 118 |
] |
| 119 |
); |
| 120 |
|
| 121 |
register_post_meta( $type, '_wpsp_active_default_template', [ |
| 122 |
'type' => 'boolean', |
| 123 |
'single' => true, |
| 124 |
'show_in_rest' => true, |
| 125 |
'default' => true, |
| 126 |
'auth_callback' => function() { |
| 127 |
return current_user_can( 'edit_posts' ); |
| 128 |
}, |
| 129 |
] ); |
| 130 |
} |
| 131 |
} |
| 132 |
|
| 133 |
/** |
| 134 |
* Initialize custom templates meta field for new posts |
| 135 |
* |
| 136 |
* @param int $post_id |
| 137 |
* @param WP_Post $post |
| 138 |
*/ |
| 139 |
public function initialize_custom_templates_meta($post_id, $post) { |
| 140 |
// Only initialize for allowed post types |
| 141 |
$allow_post_types = Helper::get_all_allowed_post_type(); |
| 142 |
$allow_post_types = (!empty($allow_post_types) ? $allow_post_types : array('post')); |
| 143 |
|
| 144 |
if (!in_array($post->post_type, $allow_post_types)) { |
| 145 |
return; |
| 146 |
} |
| 147 |
|
| 148 |
// Check if meta already exists |
| 149 |
$existing_meta = get_post_meta($post_id, '_wpsp_custom_templates', true); |
| 150 |
if (!empty($existing_meta)) { |
| 151 |
return; |
| 152 |
} |
| 153 |
|
| 154 |
// Initialize with default structure |
| 155 |
$default_templates = array( |
| 156 |
'facebook' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 157 |
'twitter' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 158 |
'linkedin' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 159 |
'pinterest' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 160 |
'instagram' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 161 |
'medium' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 162 |
'threads' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 163 |
'google_business' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 164 |
'bluesky' => ['template' => '', 'profiles' => [], 'is_global' => false], |
| 165 |
'mastodon' => ['template' => '', 'profiles' => [], 'is_global' => false] |
| 166 |
); |
| 167 |
|
| 168 |
update_post_meta($post_id, '_wpsp_custom_templates', $default_templates); |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Handle post status changes for dynamic scheduling |
| 173 |
* |
| 174 |
* @param string $new_status |
| 175 |
* @param string $old_status |
| 176 |
* @param WP_Post $post |
| 177 |
*/ |
| 178 |
public function handle_post_status_change($new_status, $old_status, $post) { |
| 179 |
// @todo |
| 180 |
} |
| 181 |
|
| 182 |
/** |
| 183 |
* Handle post updates for dynamic scheduling |
| 184 |
* |
| 185 |
* @param int $post_id |
| 186 |
* @param WP_Post $post_after |
| 187 |
* @param WP_Post $post_before |
| 188 |
*/ |
| 189 |
public function handle_post_update($post_id, $post_after, $post_before) { |
| 190 |
// Only process allowed post types |
| 191 |
$allow_post_types = Helper::get_all_allowed_post_type(); |
| 192 |
if (!in_array($post_after->post_type, $allow_post_types)) { |
| 193 |
return; |
| 194 |
} |
| 195 |
|
| 196 |
// Get scheduling data |
| 197 |
$scheduling_data = get_post_meta($post_id, '_wpsp_social_scheduling', true); |
| 198 |
if ( $post_after->post_status === 'future' && $post_before->post_date !== $post_after->post_date ) { |
| 199 |
$this->handle_scheduled_post_scheduling($post_id, $scheduling_data, $post_after); |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Reset social scheduling after cron event executed |
| 205 |
* |
| 206 |
* @param int $post_id |
| 207 |
*/ |
| 208 |
public function handle_social_scheduling_published($post_id) { |
| 209 |
if (empty($post_id) || !get_post($post_id)) { |
| 210 |
return; |
| 211 |
} |
| 212 |
|
| 213 |
$scheduling_data = get_post_meta($post_id, '_wpsp_social_scheduling', true); |
| 214 |
if (empty($scheduling_data) || !is_array($scheduling_data)) { |
| 215 |
return; |
| 216 |
} |
| 217 |
|
| 218 |
// Only reset if any scheduling was active |
| 219 |
if ( !empty($scheduling_data['customTime']) || !empty($scheduling_data['timeOption']) ) { |
| 220 |
$scheduling_data['customTime'] = ''; |
| 221 |
$scheduling_data['timeOption'] = 'in_1h'; |
| 222 |
$default_scheduling = $this->normalize_scheduling_data($scheduling_data, get_post_status($post_id)); |
| 223 |
update_post_meta($post_id, '_wpsp_social_scheduling', $default_scheduling); |
| 224 |
} |
| 225 |
} |
| 226 |
|
| 227 |
|
| 228 |
/** |
| 229 |
* Register custom template REST API routes |
| 230 |
*/ |
| 231 |
public function register_custom_template_routes() |
| 232 |
{ |
| 233 |
$namespace = WPSP_PLUGIN_SLUG . '/v1'; |
| 234 |
|
| 235 |
// Get custom templates |
| 236 |
register_rest_route($namespace, 'custom-templates/(?P<post_id>\d+)', array( |
| 237 |
'methods' => 'GET', |
| 238 |
'callback' => array($this, 'get_custom_templates'), |
| 239 |
'permission_callback' => function() { |
| 240 |
return current_user_can( 'edit_posts' ); |
| 241 |
}, |
| 242 |
'args' => array( |
| 243 |
'post_id' => array( |
| 244 |
'required' => true, |
| 245 |
'validate_callback' => function($param, $request, $key) { |
| 246 |
return is_numeric($param); |
| 247 |
} |
| 248 |
), |
| 249 |
), |
| 250 |
)); |
| 251 |
|
| 252 |
// Save custom template (supports both single platform and batch operations) |
| 253 |
register_rest_route($namespace, 'custom-templates/(?P<post_id>\d+)', array( |
| 254 |
'methods' => 'POST', |
| 255 |
'callback' => array($this, 'save_custom_template'), |
| 256 |
'permission_callback' => function() { |
| 257 |
return current_user_can( 'edit_posts' ); |
| 258 |
}, |
| 259 |
'args' => array( |
| 260 |
'post_id' => array( |
| 261 |
'required' => true, |
| 262 |
'validate_callback' => function($param, $request, $key) { |
| 263 |
return is_numeric($param); |
| 264 |
} |
| 265 |
), |
| 266 |
// For backward compatibility - single platform mode |
| 267 |
'platform' => array( |
| 268 |
'required' => false, |
| 269 |
'validate_callback' => function($param, $request, $key) { |
| 270 |
return in_array($param, ['facebook', 'twitter', 'linkedin', 'pinterest', 'instagram', 'medium', 'threads', 'google_business', 'bluesky', 'mastodon']); |
| 271 |
} |
| 272 |
), |
| 273 |
'template' => array( |
| 274 |
'required' => false, |
| 275 |
), |
| 276 |
'is_global' => array( |
| 277 |
'required' => false, |
| 278 |
'default' => false, |
| 279 |
), |
| 280 |
), |
| 281 |
)); |
| 282 |
|
| 283 |
// Delete custom template |
| 284 |
register_rest_route($namespace, 'custom-templates/(?P<post_id>\d+)', array( |
| 285 |
'methods' => 'DELETE', |
| 286 |
'callback' => array($this, 'delete_custom_template'), |
| 287 |
'permission_callback' => function() { |
| 288 |
return current_user_can( 'edit_posts' ); |
| 289 |
}, |
| 290 |
'args' => array( |
| 291 |
'post_id' => array( |
| 292 |
'required' => true, |
| 293 |
'validate_callback' => function($param, $request, $key) { |
| 294 |
return is_numeric($param); |
| 295 |
} |
| 296 |
), |
| 297 |
'platform' => array( |
| 298 |
'required' => true, |
| 299 |
'validate_callback' => function($param, $request, $key) { |
| 300 |
return in_array($param, ['facebook', 'twitter', 'linkedin', 'pinterest', 'instagram', 'medium', 'threads', 'google_business', 'bluesky', 'mastodon']); |
| 301 |
} |
| 302 |
), |
| 303 |
), |
| 304 |
)); |
| 305 |
|
| 306 |
|
| 307 |
// Save social settings (Disable social share, custom banner) |
| 308 |
register_rest_route($namespace, 'social-settings/(?P<post_id>\d+)', array( |
| 309 |
'methods' => 'POST', |
| 310 |
'callback' => array($this, 'save_social_settings'), |
| 311 |
'permission_callback' => function() { |
| 312 |
return current_user_can( 'edit_posts' ); |
| 313 |
}, |
| 314 |
'args' => array( |
| 315 |
'post_id' => array( |
| 316 |
'required' => true, |
| 317 |
'validate_callback' => function($param, $request, $key) { |
| 318 |
return is_numeric($param); |
| 319 |
} |
| 320 |
), |
| 321 |
'disable_social_share' => array( |
| 322 |
'required' => false, |
| 323 |
'type' => 'boolean' |
| 324 |
), |
| 325 |
'social_banner_id' => array( |
| 326 |
'required' => false, |
| 327 |
'type' => ['integer', 'string', 'null'] |
| 328 |
), |
| 329 |
), |
| 330 |
)); |
| 331 |
|
| 332 |
} |
| 333 |
|
| 334 |
/** |
| 335 |
* Get custom templates for a post |
| 336 |
* |
| 337 |
* @param WP_REST_Request $request |
| 338 |
* @return WP_REST_Response |
| 339 |
*/ |
| 340 |
public function get_custom_templates( $request ) { |
| 341 |
$post_id = $request->get_param('post_id'); |
| 342 |
// Verify post exists and user can edit it |
| 343 |
if (!get_post($post_id) || !current_user_can('edit_post', $post_id)) { |
| 344 |
return new \WP_REST_Response(array( |
| 345 |
'success' => false, |
| 346 |
'message' => __('Post not found or insufficient permissions.', 'wp-scheduled-posts') |
| 347 |
), 403); |
| 348 |
} |
| 349 |
|
| 350 |
// Get templates |
| 351 |
$templates = $this->get_simple_templates($post_id); |
| 352 |
$saved_scheduling = get_post_meta($post_id, '_wpsp_social_scheduling', true); |
| 353 |
$scheduling = $this->normalize_scheduling_data($saved_scheduling, get_post_status($post_id)); |
| 354 |
$templates['scheduling'] = $scheduling; |
| 355 |
|
| 356 |
return new \WP_REST_Response(array( |
| 357 |
'success' => true, |
| 358 |
'data' => $templates |
| 359 |
), 200); |
| 360 |
} |
| 361 |
|
| 362 |
/** |
| 363 |
* Save custom template for a post-profile combination (supports batch processing) |
| 364 |
* |
| 365 |
* @param WP_REST_Request $request |
| 366 |
* @return WP_REST_Response |
| 367 |
*/ |
| 368 |
public function save_custom_template( $request ) { |
| 369 |
// Get request params |
| 370 |
$post_id = $request->get_param('post_id'); |
| 371 |
$scheduling_data = $request->get_param('scheduling'); |
| 372 |
|
| 373 |
// Check if this is batch mode (multiple platforms) or single platform mode |
| 374 |
$platforms_data = $request->get_param('platforms'); |
| 375 |
$single_platform = $request->get_param('platform'); |
| 376 |
|
| 377 |
// Verify post exists and user can edit it |
| 378 |
if (!get_post($post_id) || !current_user_can('edit_post', $post_id)) { |
| 379 |
return new \WP_REST_Response(array( |
| 380 |
'success' => false, |
| 381 |
'message' => __('Post not found or insufficient permissions.', 'wp-scheduled-posts') |
| 382 |
), 403); |
| 383 |
} |
| 384 |
|
| 385 |
// Get existing templates |
| 386 |
$templates = $this->get_simple_templates($post_id); |
| 387 |
$validation_errors = []; |
| 388 |
$updated_platforms = []; |
| 389 |
|
| 390 |
if (!empty($platforms_data) && is_array($platforms_data)) { |
| 391 |
// Batch mode - process multiple platforms |
| 392 |
foreach ($platforms_data as $platform_data) { |
| 393 |
$result = $this->process_single_platform_data($platform_data, $templates, $validation_errors); |
| 394 |
if ($result['success']) { |
| 395 |
$updated_platforms[] = $result['platform']; |
| 396 |
} |
| 397 |
} |
| 398 |
} elseif (!empty($single_platform)) { |
| 399 |
// Single platform mode (backward compatibility) |
| 400 |
$platform_data = [ |
| 401 |
'platform' => $single_platform, |
| 402 |
'template' => $request->get_param('template'), |
| 403 |
'profiles' => $request->get_param('profiles'), |
| 404 |
'is_global' => $request->get_param('is_global') |
| 405 |
]; |
| 406 |
|
| 407 |
$result = $this->process_single_platform_data($platform_data, $templates, $validation_errors); |
| 408 |
if ($result['success']) { |
| 409 |
$updated_platforms[] = $result['platform']; |
| 410 |
} |
| 411 |
} else { |
| 412 |
return new \WP_REST_Response(array( |
| 413 |
'success' => false, |
| 414 |
'message' => __('No platform data provided. Use either "platform" for single mode or "platforms" for batch mode.', 'wp-scheduled-posts') |
| 415 |
), 400); |
| 416 |
} |
| 417 |
|
| 418 |
// If there were validation errors, return them |
| 419 |
if (!empty($validation_errors)) { |
| 420 |
return new \WP_REST_Response(array( |
| 421 |
'success' => false, |
| 422 |
'message' => __('Validation errors occurred.', 'wp-scheduled-posts'), |
| 423 |
'errors' => $validation_errors |
| 424 |
), 400); |
| 425 |
} |
| 426 |
|
| 427 |
// Update custom templates post meta |
| 428 |
$template_updated = update_post_meta($post_id, '_wpsp_custom_templates', $templates); |
| 429 |
// global default template. |
| 430 |
update_post_meta($post_id, '_wpsp_enable_custom_social_template', $this->has_any_custom_template($templates)); |
| 431 |
|
| 432 |
// Handle scheduling data |
| 433 |
$scheduling_updated = false; |
| 434 |
if (is_array($scheduling_data)) { |
| 435 |
$normalized_scheduling_data = $this->normalize_scheduling_data($scheduling_data, get_post_status($post_id)); |
| 436 |
$scheduling_updated = update_post_meta($post_id, '_wpsp_social_scheduling', $normalized_scheduling_data); |
| 437 |
$template_updated = true; |
| 438 |
// get status of post from request |
| 439 |
if (get_post_status($post_id) === 'publish') { |
| 440 |
$this->handle_published_post_scheduling($post_id, $normalized_scheduling_data); |
| 441 |
} elseif ( get_post_status($post_id) === 'future') { |
| 442 |
// For scheduled posts, calculate the social media timing based on the post's scheduled publication date |
| 443 |
$this->handle_scheduled_post_scheduling($post_id, $normalized_scheduling_data); |
| 444 |
} |
| 445 |
$scheduling_data = $normalized_scheduling_data; |
| 446 |
} |
| 447 |
|
| 448 |
if ($template_updated !== false || $scheduling_updated !== false) { |
| 449 |
$message = count($updated_platforms) > 1 |
| 450 |
/* translators: %d: Number of social platforms the template and scheduling were saved for */ |
| 451 |
? sprintf(__('Templates and scheduling saved successfully for %d platforms.', 'wp-scheduled-posts'), count($updated_platforms)) |
| 452 |
: __('Template and scheduling saved successfully.', 'wp-scheduled-posts'); |
| 453 |
|
| 454 |
return new \WP_REST_Response(array( |
| 455 |
'success' => true, |
| 456 |
'message' => $message, |
| 457 |
'data' => [ |
| 458 |
'templates' => $templates, |
| 459 |
'scheduling' => $scheduling_data, |
| 460 |
'updated_platforms' => $updated_platforms |
| 461 |
] |
| 462 |
), 200); |
| 463 |
} else { |
| 464 |
return new \WP_REST_Response(array( |
| 465 |
'success' => false, |
| 466 |
'message' => __('Failed to save template and/or scheduling.', 'wp-scheduled-posts') |
| 467 |
), 500); |
| 468 |
} |
| 469 |
} |
| 470 |
|
| 471 |
/** |
| 472 |
* Does one platform entry carry custom template data? |
| 473 |
* |
| 474 |
* get_simple_templates() normalises the meta so every platform is always |
| 475 |
* present, which makes a blank entry — not a missing key — the way "this |
| 476 |
* platform has no custom template" is represented. |
| 477 |
* |
| 478 |
* @param mixed $platform_entry One entry of the `_wpsp_custom_templates` meta. |
| 479 |
* @return bool |
| 480 |
*/ |
| 481 |
private function platform_has_custom_template( $platform_entry ) { |
| 482 |
if (!is_array($platform_entry)) { |
| 483 |
return false; |
| 484 |
} |
| 485 |
|
| 486 |
$tpl = isset($platform_entry['template']) ? trim((string) $platform_entry['template']) : ''; |
| 487 |
$profs = isset($platform_entry['profiles']) && is_array($platform_entry['profiles']) ? $platform_entry['profiles'] : []; |
| 488 |
$global = !empty($platform_entry['is_global']); |
| 489 |
|
| 490 |
return $tpl !== '' || !empty($profs) || $global; |
| 491 |
} |
| 492 |
|
| 493 |
/** |
| 494 |
* Does any platform still carry custom template data? |
| 495 |
* |
| 496 |
* Drives the `_wpsp_enable_custom_social_template` flag, so it has to agree |
| 497 |
* between saving and deleting. |
| 498 |
* |
| 499 |
* @param mixed $templates Value of the `_wpsp_custom_templates` meta. |
| 500 |
* @return bool |
| 501 |
*/ |
| 502 |
private function has_any_custom_template( $templates ) { |
| 503 |
if (!is_array($templates)) { |
| 504 |
return false; |
| 505 |
} |
| 506 |
|
| 507 |
foreach ($templates as $platform_entry) { |
| 508 |
if ($this->platform_has_custom_template($platform_entry)) { |
| 509 |
return true; |
| 510 |
} |
| 511 |
} |
| 512 |
|
| 513 |
return false; |
| 514 |
} |
| 515 |
|
| 516 |
/** |
| 517 |
* Delete the custom template of a single platform on a post |
| 518 |
* |
| 519 |
* The DELETE route has always been registered but the callback was never |
| 520 |
* written, so every request to it fatalled with "call to undefined method". |
| 521 |
* |
| 522 |
* @param WP_REST_Request $request |
| 523 |
* @return WP_REST_Response |
| 524 |
*/ |
| 525 |
public function delete_custom_template( $request ) { |
| 526 |
$post_id = $request->get_param('post_id'); |
| 527 |
$platform = $request->get_param('platform'); |
| 528 |
|
| 529 |
// Verify post exists and user can edit it |
| 530 |
if (!get_post($post_id) || !current_user_can('edit_post', $post_id)) { |
| 531 |
return new \WP_REST_Response(array( |
| 532 |
'success' => false, |
| 533 |
'message' => __('Post not found or insufficient permissions.', 'wp-scheduled-posts') |
| 534 |
), 403); |
| 535 |
} |
| 536 |
|
| 537 |
// Read through get_simple_templates() so legacy formats are migrated and |
| 538 |
// every platform key is present before we touch anything. |
| 539 |
$templates = $this->get_simple_templates($post_id); |
| 540 |
|
| 541 |
$entry = isset($templates[$platform]) ? $templates[$platform] : null; |
| 542 |
|
| 543 |
if (!$this->platform_has_custom_template($entry)) { |
| 544 |
return new \WP_REST_Response(array( |
| 545 |
'success' => false, |
| 546 |
'message' => __('No custom template found for this platform.', 'wp-scheduled-posts') |
| 547 |
), 404); |
| 548 |
} |
| 549 |
|
| 550 |
// Blank the entry rather than unsetting it: get_simple_templates() puts |
| 551 |
// every platform back on the next read, so a removed key would return as |
| 552 |
// a blank one anyway. |
| 553 |
$templates[$platform] = ['template' => '', 'profiles' => [], 'is_global' => false]; |
| 554 |
|
| 555 |
update_post_meta($post_id, '_wpsp_custom_templates', $templates); |
| 556 |
|
| 557 |
// Keep the enable flag in step with what is left. |
| 558 |
update_post_meta($post_id, '_wpsp_enable_custom_social_template', $this->has_any_custom_template($templates)); |
| 559 |
|
| 560 |
return new \WP_REST_Response(array( |
| 561 |
'success' => true, |
| 562 |
/* translators: %s: Social platform label, e.g. Facebook */ |
| 563 |
'message' => sprintf(__('Custom template deleted for %s.', 'wp-scheduled-posts'), $this->platform_label($platform)), |
| 564 |
'data' => [ |
| 565 |
'templates' => $templates, |
| 566 |
'deleted_platform' => $platform |
| 567 |
] |
| 568 |
), 200); |
| 569 |
} |
| 570 |
|
| 571 |
/** |
| 572 |
* Save social settings for a post |
| 573 |
* |
| 574 |
* @param WP_REST_Request $request |
| 575 |
* @return WP_REST_Response |
| 576 |
*/ |
| 577 |
public function save_social_settings( $request ) { |
| 578 |
$post_id = $request->get_param('post_id'); |
| 579 |
$disable_social_share = $request->get_param('disable_social_share'); |
| 580 |
$social_banner_id = $request->get_param('social_banner_id'); |
| 581 |
|
| 582 |
// Verify post exists and user can edit it |
| 583 |
if (!get_post($post_id) || !current_user_can('edit_post', $post_id)) { |
| 584 |
return new \WP_REST_Response(array( |
| 585 |
'success' => false, |
| 586 |
'message' => __('Post not found or insufficient permissions.', 'wp-scheduled-posts') |
| 587 |
), 403); |
| 588 |
} |
| 589 |
|
| 590 |
$updated = false; |
| 591 |
|
| 592 |
// Process disable social share setting |
| 593 |
if ($disable_social_share !== null) { |
| 594 |
update_post_meta($post_id, '_wpscppro_dont_share_socialmedia', $disable_social_share); |
| 595 |
$updated = true; |
| 596 |
} |
| 597 |
|
| 598 |
// Process social banner setting |
| 599 |
// Null means remove, empty string also removes, valid ID updates |
| 600 |
if ($social_banner_id !== null) { |
| 601 |
update_post_meta($post_id, '_wpscppro_custom_social_share_image', $social_banner_id); |
| 602 |
$updated = true; |
| 603 |
} |
| 604 |
|
| 605 |
if ($updated) { |
| 606 |
return new \WP_REST_Response(array( |
| 607 |
'success' => true, |
| 608 |
'message' => __('Social settings saved successfully.', 'wp-scheduled-posts'), |
| 609 |
), 200); |
| 610 |
} |
| 611 |
|
| 612 |
return new \WP_REST_Response(array( |
| 613 |
'success' => true, |
| 614 |
'message' => __('No changes made to social settings.', 'wp-scheduled-posts') |
| 615 |
), 200); |
| 616 |
} |
| 617 |
|
| 618 |
/** |
| 619 |
* Process single platform data for batch or individual operations |
| 620 |
* |
| 621 |
* @param array $platform_data |
| 622 |
* @param array &$templates |
| 623 |
* @param array &$validation_errors |
| 624 |
* @return array |
| 625 |
*/ |
| 626 |
/** |
| 627 |
* Human-readable label for a platform slug (used in validation messages). |
| 628 |
* |
| 629 |
* @param string $platform |
| 630 |
* @return string |
| 631 |
*/ |
| 632 |
private function platform_label($platform) { |
| 633 |
$labels = array( |
| 634 |
'facebook' => 'Facebook', |
| 635 |
'twitter' => 'X (Twitter)', |
| 636 |
'linkedin' => 'LinkedIn', |
| 637 |
'pinterest' => 'Pinterest', |
| 638 |
'instagram' => 'Instagram', |
| 639 |
'medium' => 'Medium', |
| 640 |
'threads' => 'Threads', |
| 641 |
'google_business' => 'Google Business', |
| 642 |
'bluesky' => 'Bluesky', |
| 643 |
'mastodon' => 'Mastodon', |
| 644 |
); |
| 645 |
return isset($labels[$platform]) ? $labels[$platform] : ucfirst($platform); |
| 646 |
} |
| 647 |
|
| 648 |
private function process_single_platform_data($platform_data, &$templates, &$validation_errors) { |
| 649 |
$platform = $platform_data['platform'] ?? ''; |
| 650 |
$template = $platform_data['template'] ?? ''; |
| 651 |
$profiles = $platform_data['profiles'] ?? []; |
| 652 |
$is_global = $platform_data['is_global'] ?? false; |
| 653 |
|
| 654 |
// Validate platform |
| 655 |
$valid_platforms = ['facebook', 'twitter', 'linkedin', 'pinterest', 'instagram', 'medium', 'threads', 'google_business', 'bluesky', 'mastodon']; |
| 656 |
if (!in_array($platform, $valid_platforms)) { |
| 657 |
/* translators: %s: Name of the invalid social media platform */ |
| 658 |
$validation_errors[] = sprintf(__('Invalid platform: %s', 'wp-scheduled-posts'), $platform); |
| 659 |
return ['success' => false, 'platform' => $platform]; |
| 660 |
} |
| 661 |
|
| 662 |
// Skip empty templates and profiles |
| 663 |
if (empty(trim($template)) && empty($profiles)) { |
| 664 |
return ['success' => true, 'platform' => $platform]; // Skip but don't error |
| 665 |
} |
| 666 |
|
| 667 |
// Validate template content if not empty |
| 668 |
if (!empty(trim($template))) { |
| 669 |
$validation_result = $this->validate_template_content($template, $platform); |
| 670 |
if (!$validation_result['valid']) { |
| 671 |
/* translators: 1: Name of the social media platform, 2: Validation error message */ |
| 672 |
$validation_errors[] = sprintf(__('%1$s: %2$s', 'wp-scheduled-posts'), $this->platform_label($platform), $validation_result['message']); |
| 673 |
return ['success' => false, 'platform' => $platform]; |
| 674 |
} |
| 675 |
} |
| 676 |
|
| 677 |
// Save template and profiles for platform |
| 678 |
$templates[$platform] = [ |
| 679 |
'template' => $template, |
| 680 |
'profiles' => is_array($profiles) ? $profiles : [], |
| 681 |
'is_global' => $is_global ? 1 : '' |
| 682 |
]; |
| 683 |
|
| 684 |
return ['success' => true, 'platform' => $platform]; |
| 685 |
} |
| 686 |
|
| 687 |
public function handle_scheduled_post_scheduling($post_id, $scheduling_data, $post = null) { |
| 688 |
if (empty($scheduling_data)) { |
| 689 |
return false; |
| 690 |
} |
| 691 |
|
| 692 |
// Get the post object |
| 693 |
$post = $post ? $post : get_post($post_id); |
| 694 |
if (!$post) { |
| 695 |
return false; |
| 696 |
} |
| 697 |
|
| 698 |
// Calculate the social sharing datetime based on the post's scheduled publication date |
| 699 |
$social_datetime = \WPSP\Helpers\CustomTemplateHelper::get_scheduled_datetime( |
| 700 |
$scheduling_data, |
| 701 |
$post->post_date_gmt |
| 702 |
); |
| 703 |
|
| 704 |
if (!$social_datetime) { |
| 705 |
return false; |
| 706 |
} |
| 707 |
|
| 708 |
// Update scheduling data |
| 709 |
$scheduling_data['datetime'] = $social_datetime; |
| 710 |
$scheduling_data['enabled'] = true; |
| 711 |
$scheduling_data['status'] = 'pending_publication'; |
| 712 |
|
| 713 |
update_post_meta($post_id, '_wpsp_social_scheduling', $scheduling_data); |
| 714 |
|
| 715 |
// Schedule the cron event |
| 716 |
$timestamp = (new \DateTime($social_datetime, new \DateTimeZone('UTC')))->getTimestamp(); |
| 717 |
$hook = 'wpsp_custom_social_template'; |
| 718 |
$args = [intval($post_id)]; |
| 719 |
|
| 720 |
// Remove previously scheduled event if any |
| 721 |
if ($existing = wp_next_scheduled($hook, $args)) { |
| 722 |
wp_unschedule_event($existing, $hook, $args); |
| 723 |
} |
| 724 |
|
| 725 |
wp_schedule_single_event($timestamp, $hook, $args); |
| 726 |
|
| 727 |
return $social_datetime; |
| 728 |
} |
| 729 |
|
| 730 |
public function handle_published_post_scheduling($post_id, $scheduling_data) { |
| 731 |
if (empty($scheduling_data)) { |
| 732 |
return false; |
| 733 |
} |
| 734 |
|
| 735 |
$event_hook = 'wpsp_custom_social_template'; |
| 736 |
// For absolute scheduling on published posts, use current time as base |
| 737 |
$datetime_str = \WPSP\Helpers\CustomTemplateHelper::get_scheduled_datetime($scheduling_data); |
| 738 |
if (!$datetime_str) { |
| 739 |
return; |
| 740 |
} |
| 741 |
|
| 742 |
$datetime_obj = \DateTime::createFromFormat('Y-m-d H:i:s', $datetime_str, new \DateTimeZone('UTC')); |
| 743 |
if (!$datetime_obj) { |
| 744 |
return; |
| 745 |
} |
| 746 |
$args = array(intval($post_id)); |
| 747 |
$timestamp = $datetime_obj->getTimestamp(); |
| 748 |
// Unschedule if an existing event is already scheduled |
| 749 |
$scheduled_timestamp = wp_next_scheduled($event_hook, $args); |
| 750 |
if ($scheduled_timestamp !== false) { |
| 751 |
wp_unschedule_event($scheduled_timestamp, $event_hook, $args); |
| 752 |
} |
| 753 |
// Schedule the new event |
| 754 |
wp_schedule_single_event($timestamp, $event_hook, $args); |
| 755 |
} |
| 756 |
|
| 757 |
/** |
| 758 |
* Validate template content based on platform limits |
| 759 |
* |
| 760 |
* @param string $template |
| 761 |
* @param string $platform |
| 762 |
* @return array |
| 763 |
*/ |
| 764 |
private function validate_template_content( $template, $platform ) { |
| 765 |
// Platform character limits — sourced from the saved settings so this |
| 766 |
// validation always matches the limits shown in the editor UI. |
| 767 |
$limits = Helper::get_social_platform_limits(); |
| 768 |
|
| 769 |
// Check if template is empty |
| 770 |
if (empty(trim($template))) { |
| 771 |
return array( |
| 772 |
'valid' => false, |
| 773 |
'message' => __('Template cannot be empty.', 'wp-scheduled-posts') |
| 774 |
); |
| 775 |
} |
| 776 |
|
| 777 |
// Check character limit for platform. Count characters (not bytes) so |
| 778 |
// emojis and other multi-byte glyphs are not over-counted. |
| 779 |
$limit = isset($limits[$platform]) ? $limits[$platform] : 1000; |
| 780 |
$length = function_exists('mb_strlen') ? mb_strlen($template) : strlen($template); |
| 781 |
if ($length > $limit) { |
| 782 |
return array( |
| 783 |
'valid' => false, |
| 784 |
'message' => sprintf( |
| 785 |
/* translators: 1: Name of the social media platform, 2: Current character count, 3: Maximum allowed character limit */ |
| 786 |
__('Template exceeds character limit for %1$s (%2$d/%3$d characters).', 'wp-scheduled-posts'), |
| 787 |
$this->platform_label($platform), |
| 788 |
$length, |
| 789 |
$limit |
| 790 |
) |
| 791 |
); |
| 792 |
} |
| 793 |
|
| 794 |
// Validate placeholder syntax |
| 795 |
$valid_placeholders = array('{title}', '{content}', '{url}', '{tags}'); |
| 796 |
preg_match_all('/\{[^}]+\}/', $template, $matches); |
| 797 |
if (!empty($matches[0])) { |
| 798 |
foreach ($matches[0] as $placeholder) { |
| 799 |
if (!in_array($placeholder, $valid_placeholders)) { |
| 800 |
return array( |
| 801 |
'valid' => false, |
| 802 |
'message' => sprintf( |
| 803 |
/* translators: 1: Invalid placeholder name, 2: List of valid placeholders */ |
| 804 |
__( 'Invalid placeholder "%1$s". Valid placeholders are: %2$s', 'wp-scheduled-posts' ), |
| 805 |
$placeholder, |
| 806 |
implode( ', ', $valid_placeholders ) |
| 807 |
), |
| 808 |
); |
| 809 |
} |
| 810 |
} |
| 811 |
} |
| 812 |
|
| 813 |
return array( |
| 814 |
'valid' => true, |
| 815 |
'message' => __('Template is valid.', 'wp-scheduled-posts') |
| 816 |
); |
| 817 |
} |
| 818 |
|
| 819 |
/** |
| 820 |
* Get templates with simple platform-based structure |
| 821 |
* |
| 822 |
* @param int $post_id |
| 823 |
* @return array |
| 824 |
*/ |
| 825 |
private function get_simple_templates( $post_id ) { |
| 826 |
$templates = get_post_meta($post_id, '_wpsp_custom_templates', true); |
| 827 |
|
| 828 |
$default_platform_data = ['template' => '', 'profiles' => [], 'is_global' => false]; |
| 829 |
|
| 830 |
// Base structure for all platforms, initialized with default data |
| 831 |
$all_platforms_default = [ |
| 832 |
'facebook' => $default_platform_data, |
| 833 |
'twitter' => $default_platform_data, |
| 834 |
'linkedin' => $default_platform_data, |
| 835 |
'pinterest' => $default_platform_data, |
| 836 |
'instagram' => $default_platform_data, |
| 837 |
'medium' => $default_platform_data, |
| 838 |
'threads' => $default_platform_data, |
| 839 |
'bluesky' => $default_platform_data, |
| 840 |
'mastodon' => $default_platform_data, |
| 841 |
]; |
| 842 |
|
| 843 |
// If no templates or not an array, return the default structure |
| 844 |
if (empty($templates) || !is_array($templates)) { |
| 845 |
return $all_platforms_default; |
| 846 |
} |
| 847 |
|
| 848 |
$adapted_templates = []; |
| 849 |
foreach ($templates as $platform => $platform_data) { |
| 850 |
if (is_string($platform_data)) { |
| 851 |
// Convert old string format to new object format |
| 852 |
$adapted_templates[$platform] = ['template' => $platform_data, 'profiles' => [], 'is_global' => false]; |
| 853 |
} elseif (is_array($platform_data) && (isset($platform_data['template']) || isset($platform_data['profiles']) || isset($platform_data['is_global'])) ) { |
| 854 |
// Already in new format, ensure keys exist |
| 855 |
$adapted_templates[$platform] = [ |
| 856 |
'template' => isset($platform_data['template']) ? $platform_data['template'] : '', |
| 857 |
'profiles' => isset($platform_data['profiles']) && is_array($platform_data['profiles']) ? $platform_data['profiles'] : [], |
| 858 |
'is_global' => isset($platform_data['is_global']) ? $platform_data['is_global'] : false |
| 859 |
]; |
| 860 |
} else { |
| 861 |
// Fallback for unexpected types, use default for this platform |
| 862 |
$adapted_templates[$platform] = $default_platform_data; |
| 863 |
} |
| 864 |
} |
| 865 |
|
| 866 |
// Merge adapted templates with default structure to ensure all platforms are present |
| 867 |
$final_templates = array_merge($all_platforms_default, $adapted_templates); |
| 868 |
|
| 869 |
// Update post meta if a migration occurred (i.e., old string formats were found) |
| 870 |
// This prevents re-saving if the data is already in the new format. |
| 871 |
$current_meta = get_post_meta($post_id, '_wpsp_custom_templates', true); |
| 872 |
if (json_encode($current_meta) !== json_encode($final_templates)) { |
| 873 |
update_post_meta($post_id, '_wpsp_custom_templates', $final_templates); |
| 874 |
} |
| 875 |
|
| 876 |
return $final_templates; |
| 877 |
} |
| 878 |
|
| 879 |
/** |
| 880 |
* Normalize and sanitize scheduling payload before persistence/use. |
| 881 |
* |
| 882 |
* @param mixed $scheduling_data Raw scheduling data. |
| 883 |
* @param string $post_status Current post status. |
| 884 |
* @return array |
| 885 |
*/ |
| 886 |
private function normalize_scheduling_data($scheduling_data, $post_status = 'publish') { |
| 887 |
$is_published = ($post_status === 'publish'); |
| 888 |
|
| 889 |
$defaults = array( |
| 890 |
'enabled' => false, |
| 891 |
'datetime' => null, |
| 892 |
'platforms' => array(), |
| 893 |
'status' => 'template_only', |
| 894 |
'dateOption' => $is_published ? 'today' : 'same_day', |
| 895 |
'timeOption' => $is_published ? 'now' : 'in_1h', |
| 896 |
'customDays' => '', |
| 897 |
'customHours' => '', |
| 898 |
'customDate' => '', |
| 899 |
'customTime' => '', |
| 900 |
'schedulingType' => $is_published ? 'absolute' : 'relative', |
| 901 |
); |
| 902 |
|
| 903 |
if (!is_array($scheduling_data)) { |
| 904 |
return $defaults; |
| 905 |
} |
| 906 |
|
| 907 |
$normalized = wp_parse_args($scheduling_data, $defaults); |
| 908 |
|
| 909 |
$normalized['enabled'] = !empty($normalized['enabled']); |
| 910 |
$normalized['datetime'] = !empty($normalized['datetime']) ? sanitize_text_field($normalized['datetime']) : null; |
| 911 |
$normalized['status'] = !empty($normalized['status']) ? sanitize_text_field($normalized['status']) : $defaults['status']; |
| 912 |
$normalized['dateOption'] = !empty($normalized['dateOption']) ? sanitize_text_field($normalized['dateOption']) : $defaults['dateOption']; |
| 913 |
$normalized['timeOption'] = !empty($normalized['timeOption']) ? sanitize_text_field($normalized['timeOption']) : $defaults['timeOption']; |
| 914 |
$normalized['customDays'] = isset($normalized['customDays']) ? sanitize_text_field((string) $normalized['customDays']) : ''; |
| 915 |
$normalized['customHours'] = isset($normalized['customHours']) ? sanitize_text_field((string) $normalized['customHours']) : ''; |
| 916 |
$normalized['customDate'] = isset($normalized['customDate']) ? sanitize_text_field((string) $normalized['customDate']) : ''; |
| 917 |
$normalized['customTime'] = isset($normalized['customTime']) ? sanitize_text_field((string) $normalized['customTime']) : ''; |
| 918 |
$normalized['schedulingType'] = !empty($normalized['schedulingType']) ? sanitize_text_field($normalized['schedulingType']) : $defaults['schedulingType']; |
| 919 |
|
| 920 |
if (!is_array($normalized['platforms'])) { |
| 921 |
$normalized['platforms'] = array(); |
| 922 |
} else { |
| 923 |
$normalized['platforms'] = array_values(array_filter(array_map('sanitize_text_field', $normalized['platforms']))); |
| 924 |
} |
| 925 |
|
| 926 |
return $normalized; |
| 927 |
} |
| 928 |
|
| 929 |
/** |
| 930 |
* Return an instance of this class. |
| 931 |
* |
| 932 |
* @since 2.6.0 |
| 933 |
* |
| 934 |
* @return object A single instance of this class. |
| 935 |
*/ |
| 936 |
public static function get_instance() |
| 937 |
{ |
| 938 |
// If the single instance hasn't been set, set it now. |
| 939 |
if (null == self::$instance) { |
| 940 |
self::$instance = new self; |
| 941 |
} |
| 942 |
|
| 943 |
return self::$instance; |
| 944 |
} |
| 945 |
} |
| 946 |
|