PluginProbe
SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher / trunk
SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher vtrunk
5.3.3 5.3.2 5.3.1 5.3.0 5.1.3 5.1.4 5.1.5 5.1.6 5.1.7 5.1.8 5.1.9 5.2.0 5.2.1 5.2.10 5.2.11 5.2.12 5.2.13 5.2.14 5.2.15 5.2.16 5.2.17 5.2.18 5.2.2 5.2.3 5.2.4 All 117 releases
wp-scheduled-posts / includes / API / CustomSocialTemplates.php

CustomSocialTemplates.php in SchedulePress – Auto Post & Publish, Auto Social Share, Schedule Posts with Editorial Calendar & Missed Schedule Post Publisher trunk, at includes/API/CustomSocialTemplates.php

946 lines 37.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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