PluginProbe
Parse.ly / 3.22.0
Parse.ly v3.22.0
3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 3.20.2 3.20.3 All 105 releases
wp-parsely / src / class-parsely.php

class-parsely.php in Parse.ly 3.22.0, at src/class-parsely.php

1,211 lines 33.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Parsely class
4 *
5 * @package Parsely
6 * @since 2.5.0
7 */
8
9 declare(strict_types=1);
10
11 namespace Parsely;
12
13 use Parsely\REST_API\REST_API_Controller;
14 use Parsely\Services\Content_API\Content_API_Service;
15 use Parsely\Services\Suggestions_API\Suggestions_API_Service;
16 use Parsely\UI\Metadata_Renderer;
17 use Parsely\UI\Settings_Page;
18 use Parsely\Utils\Utils;
19 use WP_Post;
20
21 /**
22 * Holds most of the logic for the plugin.
23 *
24 * @since 1.0.0
25 * @since 2.5.0 Moved from plugin root file to this file.
26 *
27 * @phpstan-type Parsely_Options array{
28 * apikey: string,
29 * content_id_prefix: string,
30 * api_secret: string,
31 * use_top_level_cats: bool,
32 * custom_taxonomy_section: string,
33 * cats_as_tags: bool,
34 * content_helper: Parsely_Options_Content_Helper,
35 * headline_testing: Parsely_Options_Headline_Testing,
36 * track_authenticated_users: bool,
37 * lowercase_tags: bool,
38 * force_https_canonicals: bool,
39 * track_post_types: string[],
40 * track_page_types: string[],
41 * track_post_types_as?: array<string, string>,
42 * full_metadata_in_non_posts: bool,
43 * disable_javascript: bool,
44 * disable_amp: bool,
45 * meta_type: string,
46 * logo: string,
47 * metadata_secret: string,
48 * disable_autotrack: bool,
49 * plugin_version: string,
50 * }
51 *
52 * @phpstan-type Parsely_Options_Content_Helper array{
53 * ai_features_enabled: bool,
54 * smart_linking: Parsely_Options_Content_Helper_Feature,
55 * title_suggestions: Parsely_Options_Content_Helper_Feature,
56 * excerpt_suggestions: Parsely_Options_Content_Helper_Feature,
57 * traffic_boost: Parsely_Options_Content_Helper_Feature,
58 * }
59 *
60 * @phpstan-type Parsely_Options_Content_Helper_Feature array{
61 * enabled: bool,
62 * allowed_user_roles: string[],
63 * }
64 *
65 * @phpstan-type Parsely_Options_Headline_Testing array{
66 * enabled: bool,
67 * installation_method: string,
68 * enable_flicker_control: bool,
69 * enable_live_updates: bool,
70 * live_update_timeout: int,
71 * allow_after_content_load: bool,
72 * }
73 *
74 * @phpstan-type WP_HTTP_Request_Args array{
75 * method?: string,
76 * timeout?: float,
77 * blocking?: bool,
78 * headers?: array<string, string>,
79 * body?: string,
80 * data_format?: string,
81 * }
82 *
83 * @phpstan-import-type Metadata_Attributes from Metadata
84 */
85 class Parsely {
86 /**
87 * Declare our constants
88 */
89 public const VERSION = PARSELY_VERSION;
90 public const MENU_SLUG = 'parsely-settings'; // The page param passed to admin.php.
91 public const OPTIONS_KEY = 'parsely'; // The key used to store options in the WP database.
92 public const CAPABILITY = 'manage_options'; // The capability required to administer settings.
93 public const DASHBOARD_BASE_URL = 'https://dash.parsely.com';
94
95 private const PARSELY_CANONICAL_URL_META_KEY = '_parsely_canonical_url';
96
97 /**
98 * The Content API service.
99 *
100 * @var ?Content_API_Service $content_api_service
101 */
102 private $content_api_service;
103
104 /**
105 * The Suggestions API service.
106 *
107 * @var ?Suggestions_API_Service $suggestions_api_service
108 */
109 private $suggestions_api_service;
110
111 /**
112 * The Parse.ly internal REST API controller.
113 *
114 * @var REST_API_Controller|null $rest_api_controller
115 */
116 private $rest_api_controller;
117
118 /**
119 * Declare some class properties
120 *
121 * @var Parsely_Options $option_defaults The defaults we need for the class.
122 */
123 private $option_defaults = array(
124 'apikey' => '',
125 'content_id_prefix' => '',
126 'api_secret' => '',
127 'use_top_level_cats' => false,
128 'custom_taxonomy_section' => 'category',
129 'cats_as_tags' => false,
130 'content_helper' => array(
131 'ai_features_enabled' => true,
132 'smart_linking' => array(
133 'enabled' => true,
134 'allowed_user_roles' => array( 'administrator' ),
135 ),
136 'title_suggestions' => array(
137 'enabled' => true,
138 'allowed_user_roles' => array( 'administrator' ),
139 ),
140 'excerpt_suggestions' => array(
141 'enabled' => true,
142 'allowed_user_roles' => array( 'administrator' ),
143 ),
144 'traffic_boost' => array(
145 'enabled' => true,
146 'allowed_user_roles' => array( 'administrator' ),
147 ),
148 ),
149 'headline_testing' => array(
150 'enabled' => false,
151 'installation_method' => 'one_line',
152 'enable_flicker_control' => false,
153 'enable_live_updates' => false,
154 'live_update_timeout' => 30000,
155 'allow_after_content_load' => false,
156 ),
157 'track_authenticated_users' => false,
158 'lowercase_tags' => true,
159 'force_https_canonicals' => false,
160 'track_post_types' => array(),
161 'track_page_types' => array(),
162 'full_metadata_in_non_posts' => true,
163 'disable_javascript' => false,
164 'disable_amp' => false,
165 'meta_type' => 'json_ld',
166 'logo' => '',
167 'metadata_secret' => '',
168 'disable_autotrack' => false,
169 'plugin_version' => self::VERSION,
170 );
171
172 /**
173 * Declare post types that Parse.ly will process as "posts".
174 *
175 * @since 2.5.0
176 * @var string[]
177 *
178 * @link https://docs.parse.ly/metadata-jsonld/#distinguishing-between-posts-and-non-posts-pages
179 */
180 public const SUPPORTED_JSONLD_POST_TYPES = array(
181 'NewsArticle',
182 'Article',
183 'TechArticle',
184 'BlogPosting',
185 'LiveBlogPosting',
186 'Report',
187 'Review',
188 'CreativeWork',
189 'OpinionNewsArticle',
190 'AnalysisNewsArticle',
191 'BackgroundNewsArticle',
192 'ReviewNewsArticle',
193 'ReportageNewsArticle',
194 'Recipe',
195 'AdvertiserContentArticle',
196 'MedicalWebPage',
197 'PodcastEpisode',
198 );
199
200 /**
201 * Declare post types that Parse.ly will process as "non-posts".
202 *
203 * @since 2.5.0
204 * @var string[]
205 *
206 * @link https://docs.parse.ly/metadata-jsonld/#distinguishing-between-posts-and-non-posts-pages
207 */
208 public const SUPPORTED_JSONLD_NON_POST_TYPES = array(
209 'WebPage',
210 'Event',
211 'Hotel',
212 'Restaurant',
213 'Movie',
214 );
215
216 /**
217 * Declare all supported types (both post and non-post types).
218 *
219 * @since 3.7.0
220 * @var string[]
221 */
222 private static $all_supported_types;
223
224 /**
225 * Returns whether credentials are being managed at the platform level.
226 *
227 * This allows hosting providers to provide a more customized experience for
228 * the plugin by handling credentials automatically.
229 *
230 * @since 3.9.0
231 * @access private
232 * @var bool
233 */
234 public $are_credentials_managed;
235
236 /**
237 * Holds the managed options and their values.
238 *
239 * This allows hosting providers to provide a more customized experience for
240 * the plugin by handling options automatically.
241 *
242 * @since 3.9.0
243 * @access private
244 * @var array<empty>|array<string, bool|string|null>
245 */
246 public $managed_options = array();
247
248 /**
249 * Constructor.
250 */
251 public function __construct() {
252 self::$all_supported_types = array_merge( self::SUPPORTED_JSONLD_POST_TYPES, self::SUPPORTED_JSONLD_NON_POST_TYPES );
253
254 $this->are_credentials_managed = $this->are_credentials_managed();
255 $this->set_managed_options();
256
257 $this->allow_parsely_remote_requests();
258 }
259
260 /**
261 * Gets the allowed post statuses for tracking.
262 *
263 * Uses the `wp_parsely_trackable_statuses` filter to determine which post statuses are allowed to be tracked.
264 *
265 * @since 3.17.0
266 *
267 * @param WP_Post|int|null $post The post object.
268 * @return array<string> The allowed post statuses.
269 */
270 public static function get_trackable_statuses( $post = null ): array {
271 /**
272 * Filters the statuses that are permitted to be tracked.
273 *
274 * By default, the only status tracked is 'publish'. Use this filter if
275 * you have other published content that has a different (custom) status.
276 *
277 * @since 2.5.0
278 * @since 3.17.0 Filter extracted to a separate method.
279 *
280 * @param string[] $trackable_statuses The list of post statuses that are allowed to be tracked.
281 * @param WP_Post|int|null $post Which post object or ID is being checked.
282 */
283 return apply_filters( 'wp_parsely_trackable_statuses', array( 'publish' ), $post );
284 }
285
286 /**
287 * Registers action and filter hook callbacks, and immediately upgrades
288 * options if needed.
289 */
290 public function run(): void {
291 // Run upgrade options if they exist for the version currently defined.
292 $options = $this->get_options();
293 if ( self::VERSION !== $options['plugin_version'] ) {
294 $method = 'upgrade_plugin_to_version_' . str_replace( '.', '_', self::VERSION );
295 if ( method_exists( $this, $method ) ) {
296 /**
297 * Variable.
298 *
299 * @var callable
300 */
301 $callable = array( $this, $method );
302 call_user_func_array( $callable, array( $options ) );
303 }
304
305 // Update our version info.
306 $options['plugin_version'] = self::VERSION;
307 update_option( self::OPTIONS_KEY, $options );
308 }
309
310 // @phpstan-ignore return.void
311 add_action( 'save_post', array( $this, 'call_update_metadata_endpoint' ) );
312 }
313
314 /**
315 * Returns the Content API service.
316 *
317 * This method returns the Content API service, which is used to interact with the Parse.ly Content API.
318 *
319 * @since 3.17.0
320 *
321 * @return Content_API_Service
322 */
323 public function get_content_api(): Content_API_Service {
324 if ( ! isset( $this->content_api_service ) ) {
325 $this->content_api_service = new Content_API_Service( $this );
326 }
327
328 return $this->content_api_service;
329 }
330
331 /**
332 * Returns the Suggestions API service.
333 *
334 * This method returns the Suggestions API service, which is used to interact with the Parse.ly Suggestions API.
335 *
336 * @since 3.17.0
337 *
338 * @return Suggestions_API_Service
339 */
340 public function get_suggestions_api(): Suggestions_API_Service {
341 if ( ! isset( $this->suggestions_api_service ) ) {
342 $this->suggestions_api_service = new Suggestions_API_Service( $this );
343 }
344
345 return $this->suggestions_api_service;
346 }
347
348 /**
349 * Gets the REST API controller.
350 *
351 * If the controller is not set, a new instance is created.
352 *
353 * @since 3.17.0
354 *
355 * @return REST_API_Controller
356 */
357 public function get_rest_api_controller(): REST_API_Controller {
358 if ( ! isset( $this->rest_api_controller ) ) {
359 $this->rest_api_controller = new REST_API_Controller( $this );
360 }
361
362 return $this->rest_api_controller;
363 }
364
365 /**
366 * Gets the full URL of the JavaScript tracker file for the site. If an API
367 * key is not set, return an empty string.
368 *
369 * @since 3.2.0
370 *
371 * @return string
372 */
373 public function get_tracker_url(): string {
374 if ( $this->site_id_is_set() ) {
375 $tracker_url = 'https://cdn.parsely.com/keys/' . $this->get_site_id() . '/p.js';
376 return esc_url( $tracker_url );
377 }
378 return '';
379 }
380
381 /**
382 * Deprecated.
383 * Inserts the code for the <meta name='parsely-page'> parameter within the
384 * head tag.
385 *
386 * @since 3.2.0
387 * @deprecated 3.3.0
388 * @see Metadata_Renderer::render_metadata
389 *
390 * @param string $meta_type `json_ld` or `repeated_metas`.
391 */
392 public function render_metadata( string $meta_type ): void {
393 _deprecated_function( __FUNCTION__, '3.3', 'Metadata_Renderer::render_metadata()' );
394 $metadata_renderer = new Metadata_Renderer( $this );
395 $metadata_renderer->render_metadata( $meta_type );
396 }
397
398 /**
399 * Deprecated.
400 * Insert the code for the <meta name='parsely-page'> parameter within the
401 * head tag.
402 *
403 * @since 3.0.0
404 * @deprecated 3.3.0
405 * @see Metadata_Renderer::render_metadata
406 */
407 public function insert_page_header_metadata(): void {
408 _deprecated_function( __FUNCTION__, '3.3', 'Metadata_Renderer::render_metadata()' );
409 $parsely_options = $this->get_options();
410 $metadata_renderer = new Metadata_Renderer( $this );
411 $metadata_renderer->render_metadata( $parsely_options['meta_type'] );
412 }
413
414 /**
415 * Compares the post_status key against an allowed list.
416 *
417 * By default, only 'publish'ed content includes tracking data.
418 *
419 * @since 2.5.0
420 *
421 * @param int|WP_Post $post Which post object or ID to check.
422 * @return bool Should the post status be tracked for the provided post's post_type.
423 * By default,only 'publish' is allowed.
424 */
425 public static function post_has_trackable_status( $post ): bool {
426 /**
427 * Filters whether the post password check should be skipped when getting
428 * the post trackable status.
429 *
430 * @since 3.0.1
431 *
432 * @param bool $skip True if the password check should be skipped.
433 * @param int|WP_Post $post Which post object or ID is being checked.
434 *
435 * @return bool
436 */
437 $skip_password_check = apply_filters( 'wp_parsely_skip_post_password_check', false, $post );
438 if ( ! $skip_password_check && post_password_required( $post ) ) {
439 return false;
440 }
441
442 $statuses = self::get_trackable_statuses( $post );
443 return in_array( get_post_status( $post ), $statuses, true );
444 }
445
446 /**
447 * Deprecated. Please use the `Metadata` class instead.
448 *
449 * Creates parsely metadata object from post metadata.
450 *
451 * @deprecated 3.3.0
452 * @see \Parsely\Metadata::construct_metadata
453 *
454 * @param array<string, mixed> $parsely_options parsely_options array.
455 * @param WP_Post $post object.
456 * @return Metadata_Attributes
457 */
458 public function construct_parsely_metadata( array $parsely_options, WP_Post $post ) {
459 _deprecated_function( __FUNCTION__, '3.3', 'Metadata::construct_metadata()' );
460 $metadata = new Metadata( $this );
461 return $metadata->construct_metadata( $post );
462 }
463
464 /**
465 * Calls Parse.ly's update metadata endpoint, sending the post's updated
466 * metadata.
467 *
468 * @param int $post_id The ID of the post to update.
469 * @return bool True if the metadata endpoint was called, false otherwise.
470 */
471 public function call_update_metadata_endpoint( int $post_id ): bool {
472 $options = $this->get_options();
473
474 if ( $this->site_id_is_missing() || '' === $options['metadata_secret'] ) {
475 return false;
476 }
477
478 $current_post_type = get_post_type( $post_id );
479 if ( false === $current_post_type ) {
480 return false;
481 }
482
483 $tracked_post_types = array_merge(
484 $options['track_post_types'],
485 $options['track_page_types']
486 );
487
488 // Check that the post's type is trackable.
489 if ( ! in_array( $current_post_type, $tracked_post_types, true ) ) {
490 return false;
491 }
492
493 // Check that the post's status is trackable.
494 if ( ! self::post_has_trackable_status( $post_id ) ) {
495 return false;
496 }
497
498 $post = get_post( $post_id );
499 if ( null === $post ) {
500 return false;
501 }
502
503 // Don't call the endpoint when integration tests are running, but
504 // signal that the above checks have passed.
505 if ( defined( 'INTEGRATION_TESTS_RUNNING' ) ) {
506 return true;
507 }
508
509 $metadata = ( new Metadata( $this ) )->construct_metadata( $post );
510
511 $endpoint_metadata = array(
512 'canonical_url' => $metadata['url'] ?? '',
513 'page_type' => $this->convert_jsonld_to_parsely_type( $metadata['@type'] ?? '' ),
514 'title' => $metadata['headline'] ?? '',
515 'image_url' => $metadata['image']['url'] ?? '',
516 'pub_date_tmsp' => $metadata['datePublished'] ?? '',
517 'section' => $metadata['articleSection'] ?? '',
518 'authors' => $metadata['creator'] ?? '',
519 'tags' => $metadata['keywords'] ?? '',
520 );
521
522 $parsely_api_base_url = Content_API_Service::get_base_url();
523 $parsely_api_endpoint = $parsely_api_base_url . '/metadata/posts';
524 $parsely_metadata_secret = $options['metadata_secret'];
525
526 $headers = array( 'Content-Type' => 'application/json' );
527 $body = wp_json_encode(
528 array(
529 'secret' => $parsely_metadata_secret,
530 'apikey' => $this->get_site_id(),
531 'metadata' => $endpoint_metadata,
532 )
533 );
534
535 /**
536 * POST request options.
537 *
538 * @var WP_HTTP_Request_Args $request_options
539 */
540 $request_options = array(
541 'method' => 'POST',
542 'headers' => $headers,
543 'blocking' => false,
544 'body' => $body,
545 'data_format' => 'body',
546 );
547
548 $response = wp_remote_post( $parsely_api_endpoint, $request_options );
549
550 if ( is_wp_error( $response ) ) {
551 return false;
552 }
553
554 update_post_meta( $post_id, 'parsely_metadata_last_updated', time() );
555
556 return true;
557 }
558
559 /**
560 * Safely returns options for the plugin by assigning defaults contained in
561 * optionDefaults.
562 *
563 * As soon as actual options are saved, they override the defaults. This
564 * prevents us from having to do a lot of isset() checking on variables.
565 *
566 * @return Parsely_Options
567 */
568 public function get_options() {
569 /**
570 * Variable.
571 *
572 * @var Parsely_Options|null
573 */
574 $options = get_option( self::OPTIONS_KEY, null );
575
576 // Existing plugin installation without full metadata option.
577 /* @phpstan-ignore isset.offset, booleanAnd.alwaysFalse */
578 if ( is_array( $options ) && ! isset( $options['full_metadata_in_non_posts'] ) ) {
579 $this->set_default_full_metadata_in_non_posts();
580 }
581
582 // Existing plugin installation without Content Intelligence options.
583 /* @phpstan-ignore isset.offset, booleanAnd.alwaysFalse */
584 if ( is_array( $options ) && ! isset( $options['content_helper'] ) ) {
585 $this->set_default_content_helper_settings_values();
586 }
587
588 // Existing plugin installation that's missing a Content Intelligence
589 // feature option.
590 /* @phpstan-ignore isset.offset */
591 if ( is_array( $options ) && isset( $options['content_helper'] ) ) {
592 /** @var array<string,Parsely_Options_Content_Helper_Feature> $pch_options */
593 $pch_options = $options['content_helper'];
594
595 /** @var array<string,Parsely_Options_Content_Helper_Feature> $pch_options_defaults */
596 $pch_options_defaults = $this->option_defaults['content_helper'];
597
598 if ( count( $pch_options ) !== count( $pch_options_defaults ) ) {
599 $new_keys = array_diff(
600 array_keys( $pch_options_defaults ),
601 array_keys( $pch_options )
602 );
603
604 foreach ( $new_keys as $key ) {
605 $options['content_helper'][ $key ] = $pch_options_defaults[ $key ];
606 }
607 }
608 }
609
610 // New plugin installation that hasn't saved its options yet.
611 if ( ! is_array( $options ) ) {
612 $this->set_default_track_as_values();
613 $this->set_default_full_metadata_in_non_posts();
614 $options = $this->option_defaults;
615 }
616
617 /**
618 * Final options including managed credentials and options.
619 *
620 * @var Parsely_Options
621 */
622 return array_merge(
623 $this->option_defaults,
624 $options,
625 $this->get_managed_credentials(),
626 $this->managed_options
627 );
628 }
629
630 /**
631 * Returns the value of a nested option.
632 *
633 * @since 3.16.0
634 *
635 * @param string $option The option to get.
636 * @param Parsely_Options $options The options to get the value from.
637 * @return mixed The value of the nested option.
638 */
639 public static function get_nested_option_value( $option, $options ) {
640 $keys = explode( '[', str_replace( ']', '', $option ) );
641 $value = $options;
642
643 foreach ( $keys as $key ) {
644 if ( isset( $value[ $key ] ) ) {
645 $value = $value[ $key ];
646 }
647 }
648
649 return $value;
650 }
651
652 /**
653 * Sets the default values for the track_post_types and track_page_types
654 * options.
655 *
656 * @since 3.9.0
657 */
658 public function set_default_track_as_values(): void {
659 $this->option_defaults['track_page_types'] = array();
660 $this->option_defaults['track_post_types'] = array();
661
662 $post_types = get_post_types( array( 'public' => true ) );
663
664 foreach ( $post_types as $post_type ) {
665 if ( ! post_type_supports( $post_type, 'editor' ) ) {
666 continue;
667 }
668
669 if ( is_post_type_hierarchical( $post_type ) ) {
670 $this->option_defaults['track_page_types'][] = $post_type;
671 } else {
672 $this->option_defaults['track_post_types'][] = $post_type;
673 }
674 }
675 }
676
677 /**
678 * Sets the default value for the full_metadata_in_non_posts option.
679 *
680 * @since 3.14.0
681 */
682 public function set_default_full_metadata_in_non_posts(): void {
683 $this->option_defaults['full_metadata_in_non_posts'] = true;
684
685 // Usage of any of these filters will result in the setting being set
686 // to false.
687 $filter_tags = array(
688 'wp_parsely_metadata',
689 'wp_parsely_post_tags',
690 'wp_parsely_permalink',
691 'wp_parsely_post_category',
692 'wp_parsely_pre_authors',
693 'wp_parsely_post_authors',
694 'wp_parsely_custom_taxonomies',
695 'wp_parsely_post_type',
696 );
697
698 foreach ( $filter_tags as $filter_tag ) {
699 if ( has_filter( $filter_tag ) ) {
700 $this->option_defaults['full_metadata_in_non_posts'] = false;
701 break;
702 }
703 }
704 }
705
706 /**
707 * Sets the default values for Content Intelligence options.
708 *
709 * Gives PCH access to all users having the edit_posts capability, to keep
710 * consistent behavior with plugin versions prior to 3.16.0.
711 *
712 * @since 3.16.0
713 */
714 public function set_default_content_helper_settings_values(): void {
715 $this->option_defaults['content_helper'] =
716 Permissions::build_pch_permissions_settings_array(
717 true,
718 array_keys( Permissions::get_user_roles_with_edit_posts_cap() )
719 );
720 }
721
722 /**
723 * Gets the URL of the plugin's settings page.
724 *
725 * @param int|null $_blog_id The Blog ID for the multisite subsite to use
726 * for context (Default null for current).
727 * @return string
728 */
729 public static function get_settings_url( ?int $_blog_id = null ): string {
730 return get_admin_url( $_blog_id, 'admin.php?page=' . self::MENU_SLUG );
731 }
732
733 /**
734 * Returns the URL of the Parse.ly dashboard for a specific page. If a page
735 * is not specified, the home dashboard URL for the specified Site ID is
736 * returned.
737 *
738 * @since 3.7.0
739 *
740 * @param string $site_id The Site ID for which to get the URL.
741 * @param string $page_url Optional. The page for which to get the URL.
742 * @return string The complete dashboard URL.
743 */
744 public static function get_dash_url( string $site_id, string $page_url = '' ): string {
745 $result = trailingslashit( self::DASHBOARD_BASE_URL . '/' . $site_id ) . 'find';
746
747 if ( '' !== $page_url ) {
748 $page_url = self::get_url_with_itm_source( $page_url, null );
749 $result .= '?url=' . rawurlencode( $page_url );
750 }
751
752 return $result;
753 }
754
755 /**
756 * Adds or replaces the itm_source parameter in the URL. Removes the
757 * parameter if the passed value is null or an empty string.
758 *
759 * @since 3.9.0
760 *
761 * @param string $url The URL to modify.
762 * @param string|null $itm_source The value of the itm_source parameter.
763 * @return string The resulting URL.
764 */
765 public static function get_url_with_itm_source( string $url, $itm_source ): string {
766 if ( null === $itm_source || '' === $itm_source ) {
767 return remove_query_arg( 'itm_source', $url );
768 }
769
770 $itm_source = rawurlencode( $itm_source );
771
772 return add_query_arg( 'itm_source', $itm_source, $url );
773 }
774
775 /**
776 * Checks to see if the current user is a member of the current blog.
777 *
778 * @return bool
779 */
780 public function is_blog_member_logged_in(): bool {
781 // Can't use $blog_id here because it futzes with the global $blog_id.
782 $current_blog_id = get_current_blog_id();
783 $current_user_id = get_current_user_id();
784
785 return is_user_member_of_blog( $current_user_id, $current_blog_id );
786 }
787
788 /**
789 * Converts JSON-LD type to respective Parse.ly page type.
790 *
791 * If the JSON-LD type is one of the types Parse.ly supports as a "post",
792 * then "post" will be returned. Otherwise, for "non-posts" and unknown
793 * types, "index" is returned.
794 *
795 * @since 2.5.0
796 *
797 * @see https://docs.parse.ly/metatags/#h-field-description
798 *
799 * @param string $type JSON-LD type.
800 * @return string "post" or "index".
801 */
802 public function convert_jsonld_to_parsely_type( string $type ): string {
803 return in_array( $type, self::SUPPORTED_JSONLD_POST_TYPES, true ) ? 'post' : 'index';
804 }
805
806 /**
807 * Determines if a Site ID is saved in the options.
808 *
809 * @since 2.6.0
810 * @since 3.7.0 renamed from api_key_is_set.
811 *
812 * @return bool True is Site ID is set, false if it is missing.
813 */
814 public function site_id_is_set(): bool {
815 $options = $this->get_options();
816
817 return '' !== $options['apikey'];
818 }
819
820 /**
821 * Determines if a Site ID is not saved in the options.
822 *
823 * @since 2.6.0
824 * @since 3.7.0 renamed from api_key_is_missing.
825 *
826 * @return bool True if Site ID is missing, false if it is set.
827 */
828 public function site_id_is_missing(): bool {
829 return ! $this->site_id_is_set();
830 }
831
832 /**
833 * Gets the Site ID if set.
834 *
835 * @since 2.6.0
836 * @since 3.7.0 renamed from get_site_id.
837 *
838 * @return string Site ID if set, or empty string if not.
839 */
840 public function get_site_id(): string {
841 $options = $this->get_options();
842
843 return $this->site_id_is_set() ? $options['apikey'] : '';
844 }
845
846 /**
847 * Returns whether the API Secret is set in the plugin's options.
848 *
849 * @since 3.4.0
850 *
851 * @return bool True if the API Secret is set, false if not set.
852 */
853 public function api_secret_is_set(): bool {
854 $options = $this->get_options();
855
856 return '' !== $options['api_secret'];
857 }
858
859 /**
860 * Returns the API Secret stored in the plugin's options.
861 *
862 * @since 3.4.0
863 *
864 * @return string The API Secret, empty string if the API secret is not set.
865 */
866 public function get_api_secret(): string {
867 $options = $this->get_options();
868
869 return $this->api_secret_is_set() ? $options['api_secret'] : '';
870 }
871
872 /**
873 * Returns all supported post and non-post types.
874 *
875 * @since 3.7.0
876 *
877 * @return string[] all supported types
878 */
879 public function get_all_supported_types(): array {
880 return self::$all_supported_types;
881 }
882
883 /**
884 * Gets all tracked post types.
885 *
886 * @since 3.7.0
887 *
888 * @return array<string>
889 */
890 public function get_all_track_types(): array {
891 $options = $this->get_options();
892
893 return array_unique( array_merge( $options['track_post_types'], $options['track_page_types'] ) );
894 }
895
896 /**
897 * Gets default options.
898 *
899 * @since 3.8.0
900 *
901 * @return Parsely_Options
902 */
903 public function get_default_options() {
904 return $this->option_defaults;
905 }
906
907 /**
908 * Returns the credentials that are being managed at the platform level.
909 *
910 * @since 3.9.0
911 * @access private
912 *
913 * @return Parsely_Options|array<empty> The managed credentials.
914 */
915 private function get_managed_credentials() {
916 if ( true !== $this->are_credentials_managed ) {
917 return array();
918 }
919
920 $credentials = apply_filters( 'wp_parsely_credentials', array() );
921
922 if ( ! is_array( $credentials ) || 0 === count( $credentials ) ) {
923 return array();
924 }
925
926 $result = array();
927
928 if ( isset( $credentials['site_id'] ) ) {
929 $result['apikey'] = $credentials['site_id'];
930 }
931
932 if ( isset( $credentials['api_secret'] ) ) {
933 $result['api_secret'] = $credentials['api_secret'];
934 }
935
936 if ( isset( $credentials['metadata_secret'] ) ) {
937 $result['metadata_secret'] = $credentials['metadata_secret'];
938 }
939
940 return $result;
941 }
942
943 /**
944 * Returns whether credentials are being managed at the platform level.
945 *
946 * @since 3.9.0
947 * @access private
948 *
949 * @return bool Whether credentials are being managed at the platform level.
950 */
951 private function are_credentials_managed(): bool {
952 $credentials = apply_filters( 'wp_parsely_credentials', array() );
953
954 if ( ! is_array( $credentials ) || 0 === count( $credentials ) ) {
955 return false;
956 }
957
958 return $credentials['is_managed'] ?? false;
959 }
960
961 /**
962 * Sets the values of managed options.
963 *
964 * This function won't accept managing credentials or certain plugin options
965 * that are being managed through other means. For managing credentials,
966 * please use the `wp_parsely_credentials` filter.
967 *
968 * @since 3.9.0
969 * @access private
970 */
971 private function set_managed_options(): void {
972 $managed_options = apply_filters( 'wp_parsely_managed_options', false );
973
974 if ( ! is_array( $managed_options ) ) {
975 return;
976 }
977
978 // Don't allow certain options to be set as managed.
979 unset(
980 $managed_options['apikey'],
981 $managed_options['api_secret'],
982 $managed_options['metadata_secret'],
983 $managed_options['track_post_types'],
984 $managed_options['track_page_types'],
985 $managed_options['plugin_version']
986 );
987
988 if ( 0 === count( $managed_options ) ) {
989 return;
990 }
991
992 /**
993 * Current options.
994 *
995 * @var Parsely_Options $current_options
996 */
997 $current_options = get_option( self::OPTIONS_KEY, array() );
998
999 // Set managed options values.
1000 foreach ( $managed_options as $key => $value ) {
1001 $is_option_valid = isset( $this->option_defaults[ $key ] );
1002
1003 if ( $is_option_valid ) {
1004 if ( null === $value ) {
1005 // When null, the option gets its value from the database.
1006 $this->managed_options[ $key ] =
1007 $current_options[ $key ] ?? $this->option_defaults[ $key ];
1008 } else {
1009 $this->managed_options[ $key ] =
1010 $this->sanitize_managed_option( $key, $value );
1011 }
1012 }
1013 }
1014 }
1015
1016 /**
1017 * Gets the Parse.ly canonical URL for a given post.
1018 *
1019 * @since 3.19.0
1020 *
1021 * @param WP_Post|int $post The post ID or post object.
1022 * @return string The Parse.ly canonical URL.
1023 */
1024 public static function get_canonical_url_from_post( $post ): string {
1025 $post_id = is_int( $post ) ? $post : $post->ID;
1026 $canonical_url = get_post_meta( $post_id, self::PARSELY_CANONICAL_URL_META_KEY, true );
1027
1028 if ( null !== $canonical_url && is_string( $canonical_url ) && '' !== $canonical_url ) {
1029 return self::get_canonical_url( $canonical_url );
1030 }
1031
1032 $permalink = get_permalink( $post );
1033
1034 if ( false === $permalink ) {
1035 return __( 'no permalink', 'wp-parsely' );
1036 }
1037
1038 return self::get_canonical_url( $permalink );
1039 }
1040
1041 /**
1042 * Returns the canonical version of the passed URL.
1043 *
1044 * In this context, the canonical URL is the URL containing the Site ID as
1045 * its domain. If the Site ID differs from the real domain, the
1046 * `wp_parsely_canonical_url_domain` filter can be used to set it.
1047 *
1048 * @since 3.19.0
1049 * @since 3.20.4 Made the domain overridable.
1050 *
1051 * @param string $url The URL to get the canonical URL for.
1052 * @return string The canonical URL.
1053 */
1054 public static function get_canonical_url( string $url ): string {
1055 $canonical_url_domain = apply_filters(
1056 'wp_parsely_canonical_url_domain',
1057 null
1058 );
1059
1060 // Handle domain override.
1061 if ( is_string( $canonical_url_domain ) ) {
1062 // Get the canonical URL domain without protocol, trailing slashes
1063 // or accidental whitespace.
1064 $canonical_url_domain = rtrim( trim( $canonical_url_domain ), '/' );
1065 $canonical_url_domain = preg_replace( '#^https?://#', '', $canonical_url_domain );
1066
1067 if ( is_string( $canonical_url_domain ) && '' !== $canonical_url_domain ) {
1068 $url_domain = (string) wp_parse_url( $url, PHP_URL_HOST );
1069 return str_replace( $url_domain, $canonical_url_domain, $url );
1070 }
1071 }
1072
1073 $site_id = \Parsely\get_parsely()->get_site_id();
1074 $home_url_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
1075
1076 if ( $home_url_host === $site_id ) {
1077 // URL does not need to be modified.
1078 return $url;
1079 }
1080
1081 // Return the URL with the Site ID as the domain.
1082 return str_replace( $home_url_host, $site_id, $url );
1083 }
1084
1085 /**
1086 * Sets the Parse.ly canonical URL for a post.
1087 *
1088 * @since 3.19.0
1089 *
1090 * @param WP_Post|int $post The post object or post ID.
1091 * @param string $url The canonical URL.
1092 * @return bool True if the canonical URL was set, false otherwise.
1093 */
1094 public static function set_canonical_url( $post, string $url ): bool {
1095 $post_id = is_int( $post ) ? $post : $post->ID;
1096 $canonical_url = self::get_canonical_url( $url );
1097
1098 return false !== update_post_meta(
1099 $post_id,
1100 self::PARSELY_CANONICAL_URL_META_KEY,
1101 sanitize_url( $canonical_url, array( 'http', 'https' ) )
1102 );
1103 }
1104
1105 /**
1106 * Sanitizes the value of the passed managed option.
1107 *
1108 * @since 3.9.0
1109 * @access private
1110 *
1111 * @param string $option_id The option's ID.
1112 * @param bool|string $value The option's value.
1113 * @return bool|string The sanitized option value.
1114 */
1115 private function sanitize_managed_option( string $option_id, $value ) {
1116 $option_value_type = gettype( $this->option_defaults[ $option_id ] );
1117
1118 if ( 'boolean' === $option_value_type && ! is_bool( $value ) ) {
1119 _doing_it_wrong(
1120 __FUNCTION__,
1121 esc_html(
1122 sprintf( /* translators: 1: Option ID */
1123 __( 'The value of the managed option `%1$s` must be of type `boolean`.', 'wp-parsely' ),
1124 $option_id
1125 )
1126 ),
1127 ''
1128 );
1129
1130 return false;
1131 }
1132
1133 if ( 'string' === $option_value_type ) {
1134 if ( ! is_string( $value ) ) {
1135 _doing_it_wrong(
1136 __FUNCTION__,
1137 esc_html(
1138 sprintf( /* translators: 1: Option ID */
1139 __( 'The value of the managed option `%1$s` must be of type `string`.', 'wp-parsely' ),
1140 $option_id
1141 )
1142 ),
1143 ''
1144 );
1145
1146 $value = strval( $value );
1147 }
1148
1149 // String options that are restricted to specific values.
1150 $restricted_value_options = array(
1151 'custom_taxonomy_section' => Settings_Page::get_section_taxonomies(),
1152 'meta_type' => array( 'json_ld', 'repeated_metas' ),
1153 );
1154
1155 // Verify that the above values are respected.
1156 foreach ( $restricted_value_options as $option_key => $valid_values ) {
1157 if ( $option_id === $option_key ) {
1158 if ( ! in_array( $value, $valid_values, true ) ) {
1159 _doing_it_wrong(
1160 __FUNCTION__,
1161 esc_html(
1162 sprintf( /* translators: 1: Option value 2: Option ID */
1163 __( 'The value `%1$s` is not allowed for the managed option `%2$s`.', 'wp-parsely' ),
1164 $value,
1165 $option_id
1166 )
1167 ),
1168 ''
1169 );
1170
1171 $value = $this->option_defaults[ $option_id ];
1172 }
1173 }
1174 }
1175 }
1176
1177 return $value;
1178 }
1179
1180 /**
1181 * Allows remote requests to Parse.ly.
1182 *
1183 * This is needed for environments, such as wp-now, that block remote requests.
1184 *
1185 * @since 3.13.0
1186 * @access private
1187 */
1188 private function allow_parsely_remote_requests(): void {
1189 $allowed_urls = array(
1190 self::DASHBOARD_BASE_URL,
1191 Content_API_Service::get_base_url(),
1192 Suggestions_API_Service::get_base_url(),
1193 );
1194
1195 add_filter(
1196 'http_request_host_is_external',
1197 function ( bool $external, string $host, string $url ) use ( $allowed_urls ) {
1198 // Check if the URL matches any URLs on the allowed list.
1199 foreach ( $allowed_urls as $allowed_url ) {
1200 if ( Utils::str_starts_with( $url, $allowed_url ) ) {
1201 return true;
1202 }
1203 }
1204 return $external;
1205 },
1206 10,
1207 3
1208 );
1209 }
1210 }
1211