PluginProbe
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages / 3.2.3
Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages v3.2.3
3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 2.2.0 2.2.1 2.2.2 2.2.3 2.2.4 2.2.5 2.2.6 2.2.7 2.2.8 2.2.9 2.3.0 2.3.1 2.3.2 2.3.3 All 194 releases
convertkit / includes / class-convertkit-broadcasts-importer.php

class-convertkit-broadcasts-importer.php in Kit (formerly ConvertKit) – Email Newsletter, Email Marketing, Membership, Subscribers and Landing Pages 3.2.3, at includes/class-convertkit-broadcasts-importer.php

659 lines 20.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ConvertKit Broadcasts Importer class.
4 *
5 * @package ConvertKit
6 * @author ConvertKit
7 */
8
9 /**
10 * Class to publish WordPress Posts based on ConvertKit Broadcasts,
11 * if the Broadcasts functionality is enabled in the Plugin's settings.
12 *
13 * @since 2.2.9
14 */
15 class ConvertKit_Broadcasts_Importer {
16
17 /**
18 * Holds the Broadcasts Settings class.
19 *
20 * @since 2.2.9
21 *
22 * @var bool|ConvertKit_Settings_Broadcasts
23 */
24 private $broadcasts_settings = false;
25
26 /**
27 * Holds the Media Library class.
28 *
29 * @since 2.6.3
30 *
31 * @var bool|ConvertKit_Media_Library
32 */
33 private $media_library = false;
34
35 /**
36 * Holds the Settings class.
37 *
38 * @since 2.6.4
39 *
40 * @var bool|ConvertKit_Settings
41 */
42 private $settings = false;
43
44 /**
45 * Holds the Logging class.
46 *
47 * @since 2.6.4
48 *
49 * @var bool|ConvertKit_Log
50 */
51 private $log = false;
52
53 /**
54 * Constructor. Registers actions and filters to output ConvertKit Forms and Landing Pages
55 * on the frontend web site.
56 *
57 * @since 2.2.9
58 */
59 public function __construct() {
60
61 // Initialize required classes.
62 $this->broadcasts_settings = new ConvertKit_Settings_Broadcasts();
63 $this->media_library = new ConvertKit_Media_Library();
64 $this->settings = new ConvertKit_Settings();
65
66 // Create WordPress Posts when the ConvertKit Posts Resource is refreshed.
67 add_action( 'convertkit_resource_refreshed_posts', array( $this, 'refresh' ) );
68
69 }
70
71 /**
72 * When the list of broadcasts is refreshed by the resource class, iterate through
73 * each Broadcast, to check if a Post exists in WordPress for it.
74 *
75 * If not, creates the WordPress Post.
76 *
77 * @since 2.2.9
78 *
79 * @param array $broadcasts Broadcasts.
80 */
81 public function refresh( $broadcasts ) {
82
83 // Bail if Broadcasts to Posts are disabled.
84 if ( ! $this->broadcasts_settings->enabled() ) {
85 return;
86 }
87
88 // Bail if no Broadcasts exist.
89 if ( ! count( $broadcasts ) ) {
90 return;
91 }
92
93 foreach ( $broadcasts as $broadcast_id => $broadcast ) {
94 // If a WordPress Post exists for this Broadcast ID, we previously imported it - skip it.
95 if ( $this->broadcast_exists_as_post( $broadcast_id ) ) {
96 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . ' already exists as a WordPress Post. Skipping...' );
97 continue;
98 }
99
100 // Skip if the published_at date is older than the 'Earliest Date' setting.
101 if ( strtotime( $broadcast['published_at'] ) < strtotime( $this->broadcasts_settings->published_at_min_date() ) ) {
102 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . ' published_at date is before ' . $this->broadcasts_settings->published_at_min_date() . '. Skipping...' );
103 continue;
104 }
105
106 // Import the broadcast.
107 $this->import_broadcast(
108 $broadcast_id,
109 $this->broadcasts_settings->post_status(),
110 $this->broadcasts_settings->author_id(),
111 $this->broadcasts_settings->category_id(),
112 $this->broadcasts_settings->import_thumbnail(),
113 $this->broadcasts_settings->import_images(),
114 $this->broadcasts_settings->no_styles()
115 );
116 }
117
118 }
119
120
121
122 /**
123 * Imports the given Kit Broadcast ID to a new WordPress Post.
124 *
125 * @since 2.6.4
126 *
127 * @param int $broadcast_id Broadcast ID.
128 * @param string $post_status WordPress Post Status to save Post as (publish,draft etc).
129 * @param int $author_id WordPress User ID to assign as the author of the Post.
130 * @param bool|int $category_id WordPress Category to assign to the Post.
131 * @param bool $import_thumbnail Store Broadcast's thumbnail as the WordPress Post's Featured Image.
132 * @param bool $import_images Store Broadcast's inline images in the Media Library.
133 * @param bool $disable_styles Remove CSS styles and layout elements from the Broadcast content.
134 *
135 * @return WP_Error|int
136 */
137 public function import_broadcast( $broadcast_id, $post_status = 'publish', $author_id = 1, $category_id = false, $import_thumbnail = false, $import_images = false, $disable_styles = false ) {
138
139 // Bail if the Plugin Access Token has not been configured.
140 if ( ! $this->settings->has_access_and_refresh_token() ) {
141 return new WP_Error(
142 'convertkit_broadcasts_importer_error',
143 __( 'No Access Token specified in Plugin Settings', 'convertkit' )
144 );
145 }
146
147 // Initialize the API.
148 $api = new ConvertKit_API_V4(
149 CONVERTKIT_OAUTH_CLIENT_ID,
150 CONVERTKIT_OAUTH_CLIENT_REDIRECT_URI,
151 $this->settings->get_access_token(),
152 $this->settings->get_refresh_token(),
153 $this->settings->debug_enabled(),
154 'broadcasts_importer'
155 );
156
157 // Check that we're using the ConvertKit WordPress Libraries 1.3.8 or higher.
158 // If another ConvertKit Plugin is active and out of date, its libraries might
159 // be loaded that don't have this method.
160 if ( ! method_exists( $api, 'get_post' ) ) { // @phpstan-ignore-line Older WordPress Libraries won't have this function.
161 return new WP_Error(
162 'convertkit_broadcasts_importer_error',
163 __( 'Kit WordPress Libraries 1.3.7 or older detected, missing the `get_post` method.', 'convertkit' )
164 );
165 }
166
167 // Fetch Broadcast's content.
168 // We need to query wordpress/posts/{id} to fetch the full Broadcast information and content.
169 $broadcast = $api->get_post( $broadcast_id );
170
171 // Unset API class.
172 unset( $api );
173
174 // Bail if an error occured fetching the Broadcast.
175 if ( is_wp_error( $broadcast ) ) {
176 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Error fetching from API: ' . $broadcast->get_error_message() );
177 return $broadcast;
178 }
179
180 // Create Post as a draft, without content or a Featured Image.
181 // This gives us a Post ID we can then use if we need to import
182 // the Featured Image and/or Broadcast images to the Media Library,
183 // storing them against the Post ID just created.
184 $post_id = wp_insert_post(
185 $this->build_post_args(
186 $broadcast,
187 $author_id,
188 $category_id
189 ),
190 true
191 );
192
193 // Bail if an error occured.
194 if ( is_wp_error( $post_id ) ) {
195 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Error on wp_insert_post(): ' . $post_id->get_error_message() );
196 return $post_id;
197 }
198
199 // Parse the Broadcast's content, storing it in the Post.
200 $post_id = wp_update_post(
201 array(
202 'ID' => $post_id,
203 'post_content' => $this->parse_broadcast_content(
204 $post_id,
205 $broadcast['content'],
206 $broadcast['title'],
207 $import_images,
208 $disable_styles
209 ),
210 ),
211 true
212 );
213
214 // Bail if an error occured updating the Post.
215 if ( is_wp_error( $post_id ) ) {
216 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Error on wp_update_post() when adding Broadcast content: ' . $post_id->get_error_message() );
217 return $post_id;
218 }
219
220 // If a Product is specified, apply it as the Restrict Content setting.
221 if ( $broadcast['is_paid'] && $broadcast['product_id'] ) {
222 // Fetch Post's settings.
223 $convertkit_post = new ConvertKit_Post( $post_id );
224 $meta = $convertkit_post->get();
225
226 // Define Restrict Content setting.
227 $meta['restrict_content'] = 'product_' . $broadcast['product_id'];
228
229 // Save Post's settings.
230 $convertkit_post->save( $meta );
231 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Set Restrict Content = ' . $broadcast['product_id'] );
232 }
233
234 // If the Import Thumbnail setting is enabled, and the Broadcast has an image, save it to the Media Library and link it to the Post.
235 if ( $import_thumbnail ) {
236 $result = $this->add_broadcast_image_to_post( $broadcast, $post_id );
237
238 if ( is_wp_error( $result ) ) {
239 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Error on add_broadcast_image_to_post(): ' . $result->get_error_message() );
240 }
241 } else {
242 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Skipping thumbnail.' );
243 }
244
245 // Transition the Post to the defined Post Status in the settings, now that the image has been added to it.
246 $post_id = wp_update_post(
247 array(
248 'ID' => $post_id,
249 'post_status' => $post_status,
250 ),
251 true
252 );
253
254 // Maybe log if an error occured updating the Post to the publish status.
255 if ( is_wp_error( $post_id ) ) {
256 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Error on wp_update_post() when transitioning post status from draft to publish: ' . $post_id->get_error_message() );
257 return $post_id;
258 }
259
260 // Import successful.
261 $this->maybe_log( 'ConvertKit_Broadcasts_Importer::refresh(): Broadcast #' . $broadcast_id . '. Added as Post ID #' . $post_id );
262 return $post_id;
263
264 }
265
266 /**
267 * Logs the given message if debug logging is enabled
268 * in the Plugin's settings.
269 *
270 * @since 2.6.4
271 *
272 * @param string $message Log message.
273 */
274 private function maybe_log( $message ) {
275
276 // Don't log if debugging is not enabled.
277 if ( ! $this->settings->debug_enabled() ) {
278 return;
279 }
280
281 // Initialize logging class, if not yet initialized.
282 if ( ! $this->log ) {
283 $this->log = new ConvertKit_Log( CONVERTKIT_PLUGIN_PATH );
284 }
285
286 $this->log->add( $message );
287
288 }
289
290 /**
291 * Helper method to determine if the given ConvertKit Broadcast already exists
292 * as a WordPress Post.
293 *
294 * @since 2.2.7
295 *
296 * @param int $broadcast_id ConvertKit Broadcast ID.
297 * @return bool Broadcast exists as a WordPress Post
298 */
299 private function broadcast_exists_as_post( $broadcast_id ) {
300
301 $posts = new WP_Query(
302 array(
303 'post_type' => 'post',
304 'post_status' => 'any',
305 'meta_query' => array(
306 array(
307 'key' => '_convertkit_broadcast_id',
308 'value' => $broadcast_id,
309 ),
310 ),
311 'fields' => 'ids',
312 'update_post_cache' => false,
313 )
314 );
315
316 if ( ! $posts->post_count ) {
317 return false;
318 }
319
320 return true;
321
322 }
323
324 /**
325 * Defines the wp_insert_post() compatible arguments for importing the given ConvertKit
326 * Broadcast to a new WordPress Post.
327 *
328 * @since 2.2.9
329 *
330 * @param array $broadcast Broadcast.
331 * @param int $author_id WordPress User to assign as the author of the Post.
332 * @param bool|int $category_id Category ID.
333 * @return array wp_insert_post() compatible arguments.
334 */
335 private function build_post_args( $broadcast, $author_id, $category_id = false ) {
336
337 // Define array for the wp_insert_post() compatible arguments.
338 $post_args = array(
339 'post_type' => 'post',
340 'post_title' => $broadcast['title'],
341 'post_excerpt' => ( ! is_null( $broadcast['description'] ) ? $broadcast['description'] : '' ),
342 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', strtotime( $broadcast['published_at'] ) ),
343 'post_author' => $author_id,
344 'post_name' => $this->generate_permalink( $broadcast['title'] ),
345 );
346
347 // If a Category was supplied, assign the Post to the given Category ID when created.
348 if ( $category_id ) {
349 $post_args['post_category'] = array( $category_id );
350 }
351
352 /**
353 * Define the wp_insert_post() compatible arguments for importing a ConvertKit Broadcast
354 * to a new WordPress Post.
355 *
356 * @since 2.2.9
357 *
358 * @param array $post_args Post arguments.
359 * @param array $broadcast Broadcast.
360 */
361 $post_args = apply_filters( 'convertkit_broadcasts_build_post_args', $post_args, $broadcast );
362
363 // Deliberate: force the Post Status = draft. This will be changed to scheduled or publish after
364 // any image is imported to the Post's Featured Image. So many plugins wrongly publish a Post
365 // and then import the Featured Image, which breaks a lot of social media sharing Plugins.
366 $post_args['post_status'] = 'draft';
367
368 // Deliberate: ensure the Broadcast ID is always defined.
369 if ( ! array_key_exists( 'meta_input', $post_args ) ) {
370 $post_args['meta_input'] = array();
371 }
372 $post_args['meta_input']['_convertkit_broadcast_id'] = $broadcast['id'];
373
374 return $post_args;
375
376 }
377
378 /**
379 * Removes emojis from the given string.
380 *
381 * @since 2.8.2
382 *
383 * @param string $title Broadcast Title.
384 * @return string
385 */
386 public function generate_permalink( $title ) {
387
388 // Remove emojis.
389 $title = preg_replace( '/[^\p{L}\p{N}\p{P}\s]+/u', '', $title );
390
391 // Return the Permalink.
392 return sanitize_title( $title );
393
394 }
395
396 /**
397 * Parses the given Broadcast's content, removing unnecessary HTML tags and styles.
398 *
399 * If 'Import Images' is enabled in the Plugin settings, imports images to the
400 * Media Library, replacing the <img> `src` with the WordPress Media Library
401 * Image URL.
402 *
403 * @since 2.2.9
404 *
405 * @param int $post_id WordPress Post ID.
406 * @param string $broadcast_content Broadcast Content.
407 * @param string $broadcast_title Broadcast Title.
408 * @param bool $import_images Import images to Media Library.
409 * @param bool $disable_styles Disable CSS styles in content.
410 * @return string Parsed Content.
411 */
412 private function parse_broadcast_content( $post_id, $broadcast_content, $broadcast_title = '', $import_images = false, $disable_styles = false ) {
413
414 $content = $broadcast_content;
415
416 // Load the content into the parser.
417 $parser = new ConvertKit_HTML_Parser( $content );
418
419 // Remove certain elements and their contents, as we never want these to be included in the WordPress Post.
420 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
421
422 // Remove open tracking.
423 foreach ( $parser->xpath->query( '//img[@src="https://preview.convertkit-mail2.com/open"]' ) as $node ) {
424 $node->parentNode->removeChild( $node );
425 }
426
427 // Remove blank contenteditable table cells.
428 foreach ( $parser->xpath->query( '//td[@contenteditable="false"]' ) as $node ) {
429 $node->parentNode->removeChild( $node );
430 }
431
432 // Remove <style> elements and their contents.
433 foreach ( $parser->xpath->query( '//style' ) as $node ) {
434 $node->parentNode->removeChild( $node );
435 }
436
437 // Remove ck-hide-in-public-posts and their contents.
438 // This includes the unsubscribe section.
439 foreach ( $parser->xpath->query( '//div[contains(@class, "ck-hide-in-public-posts")]' ) as $node ) {
440 $node->parentNode->removeChild( $node );
441 }
442
443 // Remove ck-poll, as interacting with these results in an error.
444 foreach ( $parser->xpath->query( '//table[contains(@class, "ck-poll")]' ) as $node ) {
445 $node->parentNode->removeChild( $node );
446 }
447
448 // If a H1 through H6 heading matches the Broadcast's title, remove it from the content.
449 // The Broadcast's title will always display as the WordPress Post title.
450 for ( $i = 1; $i <= 6; $i++ ) {
451 foreach ( $parser->xpath->query( '//h' . $i ) as $node ) {
452 if ( $node->textContent === $broadcast_title ) {
453 $node->parentNode->removeChild( $node );
454 }
455 }
456 }
457 // phpcs:enable
458
459 // If the Import Images setting is enabled, iterate through all images within the Broadcast, importing them and changing their
460 // URLs to the WordPress Media Library hosted versions.
461 if ( $import_images ) {
462 foreach ( $parser->xpath->query( '//img' ) as $node ) {
463 $image = array(
464 'src' => $node->getAttribute( 'src' ), // @phpstan-ignore-line
465 'alt' => $node->getAttribute( 'alt' ), // @phpstan-ignore-line
466 );
467
468 // Skip if this image isn't served from https://embed.filekitcdn.com, as it isn't
469 // a user uploaded image to the Broadcast.
470 if ( strpos( $image['src'], 'https://embed.filekitcdn.com' ) === false ) {
471 continue;
472 }
473
474 // Import Image into the Media Library.
475 $image_id = $this->media_library->import_remote_image(
476 $image['src'],
477 $post_id,
478 $image['alt']
479 );
480
481 // If the image could not be imported, serve the original CDN version.
482 if ( is_wp_error( $image_id ) ) {
483 continue;
484 }
485
486 // Get image URL from Media Library.
487 $image_url = wp_get_attachment_image_src(
488 $image_id,
489 'full'
490 );
491
492 // Replace this image's `src` attribute with the Media Library Image URL.
493 $node->setAttribute( 'src', $image_url[0] ); // @phpstan-ignore-line
494 }
495 }
496
497 // Save HTML to a string.
498 $content = $parser->get_body_html();
499
500 // Return content with permitted HTML tags and inline styles included/excluded, depending on the setting.
501 $content = $this->get_permitted_html( $content, $disable_styles );
502
503 /**
504 * Parses the given Broadcast's content, removing unnecessary HTML tags and styles.
505 *
506 * @since 2.2.9
507 *
508 * @param string $content Parsed Content.
509 * @param int $post_id WordPress Post ID.
510 * @param string $broadcast_content Broadcast Content.
511 * @param string $broadcast_title Broadcast Title.
512 * @param bool $import_images Import images to Media Library.
513 * @param bool $disable_styles Disable CSS styles in content.
514 */
515 $content = apply_filters( 'convertkit_broadcasts_parse_broadcast_content', $content, $post_id, $broadcast_content, $broadcast_title, $import_images, $disable_styles );
516
517 return $content;
518
519 }
520
521 /**
522 * Returns the given content containing only the permitted HTML tags,
523 * cleans up empty div elements and removes multiple newlines.
524 *
525 * Content contained within non-permitted HTML tags is returned, without
526 * the HTML tags wrapping the content.
527 *
528 * @since 2.4.0
529 *
530 * @param string $content HTML Content.
531 * @param bool $disable_styles Disable styles.
532 * @return string HTML Content
533 */
534 public function get_permitted_html( $content, $disable_styles = false ) {
535
536 // For PHP 7.4 and lower compatibility, convert permitted HTML tags array to a string
537 // for use in strip_tags().
538 $permitted_html_tags_string = '<' . implode( '><', $this->permitted_html_tags( $disable_styles ) ) . '>';
539
540 // Remove other tags, retaining inner contents.
541 // For HTML broadcasts, this will remove e.g. <html>, <head> and <body> tags.
542 $content = strip_tags( $content, $permitted_html_tags_string );
543
544 // If Disable Styles is enabled, remove inline styles and class attributes from remaining HTML elements.
545 if ( $disable_styles ) {
546 $content = preg_replace( '/(<[^>]+) style=".*?"/i', '$1', $content );
547 $content = preg_replace( '/(<[^>]+) class=".*?"/i', '$1', $content );
548 }
549
550 // Remove empty div elements, which may remain because they contained HTML comments only, which were removed above.
551 $content = str_replace( '<div></div>', '', $content );
552
553 // Remove leading and trailing newlines and spaces, so WordPress doesn't convert these to blank paragraph tags.
554 $content = preg_replace( "/(^[\r\n]*|[\r\n]+)[\s\t]*[\r\n]+/", "\n", $content );
555 $content = trim( $content );
556
557 return $content;
558
559 }
560
561 /**
562 * Returns an array of permitted HTML tags to retain in the imported Broadcast.
563 *
564 * @since 2.2.9
565 *
566 * @param bool $disable_styles Disable styles.
567 * @return array
568 */
569 private function permitted_html_tags( $disable_styles = false ) {
570
571 // Define HTML tags to retain in the content.
572 $permitted_html_tags = array(
573 'h1',
574 'h2',
575 'h3',
576 'p',
577 'ul',
578 'ol',
579 'li',
580 'blockquote',
581 'strong',
582 'em',
583 'u',
584 's',
585 'a',
586 'img',
587 'figure',
588 'figcaption',
589 'br',
590 'style', // Deliberate; we'll use DOMDocument to remove inline styles and their contents.
591 );
592
593 // If Disable Styles is false, include layout tags.
594 if ( ! $disable_styles ) {
595 $permitted_html_tags = array_merge(
596 $permitted_html_tags,
597 array(
598 'span',
599 'div',
600 'table',
601 'tbody',
602 'tr',
603 'td',
604 )
605 );
606 }
607
608 /**
609 * Define the HTML tags to retain in the Broadcast Content.
610 *
611 * @since 2.2.9
612 *
613 * @param array $permitted_html_tags Permitted HTML Tags.
614 */
615 $permitted_html_tags = apply_filters( 'convertkit_broadcasts_parse_broadcast_content_permitted_html_tags', $permitted_html_tags );
616
617 // Return.
618 return $permitted_html_tags;
619
620 }
621
622 /**
623 * Imports the broadcast's thumbnail_url image to the WordPress Media Library,
624 * assigning it as the WordPress Post's featured image.
625 *
626 * @since 2.2.9
627 *
628 * @param array $broadcast ConvertKit Broadcast.
629 * @param int $post_id Post ID.
630 * @return WP_Error|bool|int
631 */
632 private function add_broadcast_image_to_post( $broadcast, $post_id ) {
633
634 // Bail if no image specified.
635 if ( empty( $broadcast['thumbnail_url'] ) ) {
636 return false;
637 }
638
639 // Import Image into the Media Library.
640 $image_id = $this->media_library->import_remote_image(
641 $broadcast['thumbnail_url'],
642 $post_id,
643 $broadcast['thumbnail_alt']
644 );
645
646 // Bail if an error occured.
647 if ( is_wp_error( $image_id ) ) {
648 return $image_id;
649 }
650
651 // Assign the imported Media Library image as the Post's Featured Image.
652 update_post_meta( $post_id, '_thumbnail_id', $image_id );
653
654 return $image_id;
655
656 }
657
658 }
659