PluginProbe
Parse.ly / 3.23.7
Parse.ly v3.23.7
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.23.7, at src/class-parsely.php

1,222 lines 33.5 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
377 /**
378 * Filters the URL of the Parse.ly tracker script.
379 *
380 * The filtered value gets sanitized with {@see esc_url_raw()}.
381 *
382 * @since 3.23.0
383 *
384 * @param string $tracker_url The URL of the tracker script.
385 */
386 return esc_url_raw( apply_filters( 'wp_parsely_tracker_url', $tracker_url ) );
387 }
388
389 return '';
390 }
391
392 /**
393 * Deprecated.
394 * Inserts the code for the <meta name='parsely-page'> parameter within the
395 * head tag.
396 *
397 * @since 3.2.0
398 * @deprecated 3.3.0
399 * @see Metadata_Renderer::render_metadata
400 *
401 * @param string $meta_type `json_ld` or `repeated_metas`.
402 */
403 public function render_metadata( string $meta_type ): void {
404 _deprecated_function( __FUNCTION__, '3.3', 'Metadata_Renderer::render_metadata()' );
405 $metadata_renderer = new Metadata_Renderer( $this );
406 $metadata_renderer->render_metadata( $meta_type );
407 }
408
409 /**
410 * Deprecated.
411 * Insert the code for the <meta name='parsely-page'> parameter within the
412 * head tag.
413 *
414 * @since 3.0.0
415 * @deprecated 3.3.0
416 * @see Metadata_Renderer::render_metadata
417 */
418 public function insert_page_header_metadata(): void {
419 _deprecated_function( __FUNCTION__, '3.3', 'Metadata_Renderer::render_metadata()' );
420 $parsely_options = $this->get_options();
421 $metadata_renderer = new Metadata_Renderer( $this );
422 $metadata_renderer->render_metadata( $parsely_options['meta_type'] );
423 }
424
425 /**
426 * Compares the post_status key against an allowed list.
427 *
428 * By default, only 'publish'ed content includes tracking data.
429 *
430 * @since 2.5.0
431 *
432 * @param int|WP_Post $post Which post object or ID to check.
433 * @return bool Should the post status be tracked for the provided post's post_type.
434 * By default,only 'publish' is allowed.
435 */
436 public static function post_has_trackable_status( $post ): bool {
437 /**
438 * Filters whether the post password check should be skipped when getting
439 * the post trackable status.
440 *
441 * @since 3.0.1
442 *
443 * @param bool $skip True if the password check should be skipped.
444 * @param int|WP_Post $post Which post object or ID is being checked.
445 *
446 * @return bool
447 */
448 $skip_password_check = apply_filters( 'wp_parsely_skip_post_password_check', false, $post );
449 if ( ! $skip_password_check && post_password_required( $post ) ) {
450 return false;
451 }
452
453 $statuses = self::get_trackable_statuses( $post );
454 return in_array( get_post_status( $post ), $statuses, true );
455 }
456
457 /**
458 * Deprecated. Please use the `Metadata` class instead.
459 *
460 * Creates parsely metadata object from post metadata.
461 *
462 * @deprecated 3.3.0
463 * @see \Parsely\Metadata::construct_metadata
464 *
465 * @param array<string, mixed> $parsely_options parsely_options array.
466 * @param WP_Post $post object.
467 * @return Metadata_Attributes
468 */
469 public function construct_parsely_metadata( array $parsely_options, WP_Post $post ) {
470 _deprecated_function( __FUNCTION__, '3.3', 'Metadata::construct_metadata()' );
471 $metadata = new Metadata( $this );
472 return $metadata->construct_metadata( $post );
473 }
474
475 /**
476 * Calls Parse.ly's update metadata endpoint, sending the post's updated
477 * metadata.
478 *
479 * @param int $post_id The ID of the post to update.
480 * @return bool True if the metadata endpoint was called, false otherwise.
481 */
482 public function call_update_metadata_endpoint( int $post_id ): bool {
483 $options = $this->get_options();
484
485 if ( $this->site_id_is_missing() || '' === $options['metadata_secret'] ) {
486 return false;
487 }
488
489 $current_post_type = get_post_type( $post_id );
490 if ( false === $current_post_type ) {
491 return false;
492 }
493
494 $tracked_post_types = array_merge(
495 $options['track_post_types'],
496 $options['track_page_types']
497 );
498
499 // Check that the post's type is trackable.
500 if ( ! in_array( $current_post_type, $tracked_post_types, true ) ) {
501 return false;
502 }
503
504 // Check that the post's status is trackable.
505 if ( ! self::post_has_trackable_status( $post_id ) ) {
506 return false;
507 }
508
509 $post = get_post( $post_id );
510 if ( null === $post ) {
511 return false;
512 }
513
514 // Don't call the endpoint when integration tests are running, but
515 // signal that the above checks have passed.
516 if ( defined( 'INTEGRATION_TESTS_RUNNING' ) ) {
517 return true;
518 }
519
520 $metadata = ( new Metadata( $this ) )->construct_metadata( $post );
521
522 $endpoint_metadata = array(
523 'canonical_url' => $metadata['url'] ?? '',
524 'page_type' => $this->convert_jsonld_to_parsely_type( $metadata['@type'] ?? '' ),
525 'title' => $metadata['headline'] ?? '',
526 'image_url' => $metadata['image']['url'] ?? '',
527 'pub_date_tmsp' => $metadata['datePublished'] ?? '',
528 'section' => $metadata['articleSection'] ?? '',
529 'authors' => $metadata['creator'] ?? '',
530 'tags' => $metadata['keywords'] ?? '',
531 );
532
533 $parsely_api_base_url = Content_API_Service::get_base_url();
534 $parsely_api_endpoint = $parsely_api_base_url . '/metadata/posts';
535 $parsely_metadata_secret = $options['metadata_secret'];
536
537 $headers = array( 'Content-Type' => 'application/json' );
538 $body = wp_json_encode(
539 array(
540 'secret' => $parsely_metadata_secret,
541 'apikey' => $this->get_site_id(),
542 'metadata' => $endpoint_metadata,
543 )
544 );
545
546 /**
547 * POST request options.
548 *
549 * @var WP_HTTP_Request_Args $request_options
550 */
551 $request_options = array(
552 'method' => 'POST',
553 'headers' => $headers,
554 'blocking' => false,
555 'body' => $body,
556 'data_format' => 'body',
557 );
558
559 $response = wp_remote_post( $parsely_api_endpoint, $request_options );
560
561 if ( is_wp_error( $response ) ) {
562 return false;
563 }
564
565 update_post_meta( $post_id, 'parsely_metadata_last_updated', time() );
566
567 return true;
568 }
569
570 /**
571 * Safely returns options for the plugin by assigning defaults contained in
572 * optionDefaults.
573 *
574 * As soon as actual options are saved, they override the defaults. This
575 * prevents us from having to do a lot of isset() checking on variables.
576 *
577 * @return Parsely_Options
578 */
579 public function get_options() {
580 /**
581 * Variable.
582 *
583 * @var Parsely_Options|null
584 */
585 $options = get_option( self::OPTIONS_KEY, null );
586
587 // Existing plugin installation without full metadata option.
588 /* @phpstan-ignore isset.offset, booleanAnd.alwaysFalse */
589 if ( is_array( $options ) && ! isset( $options['full_metadata_in_non_posts'] ) ) {
590 $this->set_default_full_metadata_in_non_posts();
591 }
592
593 // Existing plugin installation without Content Intelligence options.
594 /* @phpstan-ignore isset.offset, booleanAnd.alwaysFalse */
595 if ( is_array( $options ) && ! isset( $options['content_helper'] ) ) {
596 $this->set_default_content_helper_settings_values();
597 }
598
599 // Existing plugin installation that's missing a Content Intelligence
600 // feature option.
601 /* @phpstan-ignore isset.offset */
602 if ( is_array( $options ) && isset( $options['content_helper'] ) ) {
603 /** @var array<string,Parsely_Options_Content_Helper_Feature> $pch_options */
604 $pch_options = $options['content_helper'];
605
606 /** @var array<string,Parsely_Options_Content_Helper_Feature> $pch_options_defaults */
607 $pch_options_defaults = $this->option_defaults['content_helper'];
608
609 if ( count( $pch_options ) !== count( $pch_options_defaults ) ) {
610 $new_keys = array_diff(
611 array_keys( $pch_options_defaults ),
612 array_keys( $pch_options )
613 );
614
615 foreach ( $new_keys as $key ) {
616 $options['content_helper'][ $key ] = $pch_options_defaults[ $key ];
617 }
618 }
619 }
620
621 // New plugin installation that hasn't saved its options yet.
622 if ( ! is_array( $options ) ) {
623 $this->set_default_track_as_values();
624 $this->set_default_full_metadata_in_non_posts();
625 $options = $this->option_defaults;
626 }
627
628 /**
629 * Final options including managed credentials and options.
630 *
631 * @var Parsely_Options
632 */
633 return array_merge(
634 $this->option_defaults,
635 $options,
636 $this->get_managed_credentials(),
637 $this->managed_options
638 );
639 }
640
641 /**
642 * Returns the value of a nested option.
643 *
644 * @since 3.16.0
645 *
646 * @param string $option The option to get.
647 * @param Parsely_Options $options The options to get the value from.
648 * @return mixed The value of the nested option.
649 */
650 public static function get_nested_option_value( $option, $options ) {
651 $keys = explode( '[', str_replace( ']', '', $option ) );
652 $value = $options;
653
654 foreach ( $keys as $key ) {
655 if ( isset( $value[ $key ] ) ) {
656 $value = $value[ $key ];
657 }
658 }
659
660 return $value;
661 }
662
663 /**
664 * Sets the default values for the track_post_types and track_page_types
665 * options.
666 *
667 * @since 3.9.0
668 */
669 public function set_default_track_as_values(): void {
670 $this->option_defaults['track_page_types'] = array();
671 $this->option_defaults['track_post_types'] = array();
672
673 $post_types = get_post_types( array( 'public' => true ) );
674
675 foreach ( $post_types as $post_type ) {
676 if ( ! post_type_supports( $post_type, 'editor' ) ) {
677 continue;
678 }
679
680 if ( is_post_type_hierarchical( $post_type ) ) {
681 $this->option_defaults['track_page_types'][] = $post_type;
682 } else {
683 $this->option_defaults['track_post_types'][] = $post_type;
684 }
685 }
686 }
687
688 /**
689 * Sets the default value for the full_metadata_in_non_posts option.
690 *
691 * @since 3.14.0
692 */
693 public function set_default_full_metadata_in_non_posts(): void {
694 $this->option_defaults['full_metadata_in_non_posts'] = true;
695
696 // Usage of any of these filters will result in the setting being set
697 // to false.
698 $filter_tags = array(
699 'wp_parsely_metadata',
700 'wp_parsely_post_tags',
701 'wp_parsely_permalink',
702 'wp_parsely_post_category',
703 'wp_parsely_pre_authors',
704 'wp_parsely_post_authors',
705 'wp_parsely_custom_taxonomies',
706 'wp_parsely_post_type',
707 );
708
709 foreach ( $filter_tags as $filter_tag ) {
710 if ( has_filter( $filter_tag ) ) {
711 $this->option_defaults['full_metadata_in_non_posts'] = false;
712 break;
713 }
714 }
715 }
716
717 /**
718 * Sets the default values for Content Intelligence options.
719 *
720 * Gives PCH access to all users having the edit_posts capability, to keep
721 * consistent behavior with plugin versions prior to 3.16.0.
722 *
723 * @since 3.16.0
724 */
725 public function set_default_content_helper_settings_values(): void {
726 $this->option_defaults['content_helper'] =
727 Permissions::build_pch_permissions_settings_array(
728 true,
729 array_keys( Permissions::get_user_roles_with_edit_posts_cap() )
730 );
731 }
732
733 /**
734 * Gets the URL of the plugin's settings page.
735 *
736 * @param int|null $_blog_id The Blog ID for the multisite subsite to use
737 * for context (Default null for current).
738 * @return string
739 */
740 public static function get_settings_url( ?int $_blog_id = null ): string {
741 return get_admin_url( $_blog_id, 'admin.php?page=' . self::MENU_SLUG );
742 }
743
744 /**
745 * Returns the URL of the Parse.ly dashboard for a specific page. If a page
746 * is not specified, the home dashboard URL for the specified Site ID is
747 * returned.
748 *
749 * @since 3.7.0
750 *
751 * @param string $site_id The Site ID for which to get the URL.
752 * @param string $page_url Optional. The page for which to get the URL.
753 * @return string The complete dashboard URL.
754 */
755 public static function get_dash_url( string $site_id, string $page_url = '' ): string {
756 $result = trailingslashit( self::DASHBOARD_BASE_URL . '/' . $site_id ) . 'find';
757
758 if ( '' !== $page_url ) {
759 $page_url = self::get_url_with_itm_source( $page_url, null );
760 $result .= '?url=' . rawurlencode( $page_url );
761 }
762
763 return $result;
764 }
765
766 /**
767 * Adds or replaces the itm_source parameter in the URL. Removes the
768 * parameter if the passed value is null or an empty string.
769 *
770 * @since 3.9.0
771 *
772 * @param string $url The URL to modify.
773 * @param string|null $itm_source The value of the itm_source parameter.
774 * @return string The resulting URL.
775 */
776 public static function get_url_with_itm_source( string $url, $itm_source ): string {
777 if ( null === $itm_source || '' === $itm_source ) {
778 return remove_query_arg( 'itm_source', $url );
779 }
780
781 $itm_source = rawurlencode( $itm_source );
782
783 return add_query_arg( 'itm_source', $itm_source, $url );
784 }
785
786 /**
787 * Checks to see if the current user is a member of the current blog.
788 *
789 * @return bool
790 */
791 public function is_blog_member_logged_in(): bool {
792 // Can't use $blog_id here because it futzes with the global $blog_id.
793 $current_blog_id = get_current_blog_id();
794 $current_user_id = get_current_user_id();
795
796 return is_user_member_of_blog( $current_user_id, $current_blog_id );
797 }
798
799 /**
800 * Converts JSON-LD type to respective Parse.ly page type.
801 *
802 * If the JSON-LD type is one of the types Parse.ly supports as a "post",
803 * then "post" will be returned. Otherwise, for "non-posts" and unknown
804 * types, "index" is returned.
805 *
806 * @since 2.5.0
807 *
808 * @see https://docs.parse.ly/metatags/#h-field-description
809 *
810 * @param string $type JSON-LD type.
811 * @return string "post" or "index".
812 */
813 public function convert_jsonld_to_parsely_type( string $type ): string {
814 return in_array( $type, self::SUPPORTED_JSONLD_POST_TYPES, true ) ? 'post' : 'index';
815 }
816
817 /**
818 * Determines if a Site ID is saved in the options.
819 *
820 * @since 2.6.0
821 * @since 3.7.0 renamed from api_key_is_set.
822 *
823 * @return bool True is Site ID is set, false if it is missing.
824 */
825 public function site_id_is_set(): bool {
826 $options = $this->get_options();
827
828 return '' !== $options['apikey'];
829 }
830
831 /**
832 * Determines if a Site ID is not saved in the options.
833 *
834 * @since 2.6.0
835 * @since 3.7.0 renamed from api_key_is_missing.
836 *
837 * @return bool True if Site ID is missing, false if it is set.
838 */
839 public function site_id_is_missing(): bool {
840 return ! $this->site_id_is_set();
841 }
842
843 /**
844 * Gets the Site ID if set.
845 *
846 * @since 2.6.0
847 * @since 3.7.0 renamed from get_site_id.
848 *
849 * @return string Site ID if set, or empty string if not.
850 */
851 public function get_site_id(): string {
852 $options = $this->get_options();
853
854 return $this->site_id_is_set() ? $options['apikey'] : '';
855 }
856
857 /**
858 * Returns whether the API Secret is set in the plugin's options.
859 *
860 * @since 3.4.0
861 *
862 * @return bool True if the API Secret is set, false if not set.
863 */
864 public function api_secret_is_set(): bool {
865 $options = $this->get_options();
866
867 return '' !== $options['api_secret'];
868 }
869
870 /**
871 * Returns the API Secret stored in the plugin's options.
872 *
873 * @since 3.4.0
874 *
875 * @return string The API Secret, empty string if the API secret is not set.
876 */
877 public function get_api_secret(): string {
878 $options = $this->get_options();
879
880 return $this->api_secret_is_set() ? $options['api_secret'] : '';
881 }
882
883 /**
884 * Returns all supported post and non-post types.
885 *
886 * @since 3.7.0
887 *
888 * @return string[] all supported types
889 */
890 public function get_all_supported_types(): array {
891 return self::$all_supported_types;
892 }
893
894 /**
895 * Gets all tracked post types.
896 *
897 * @since 3.7.0
898 *
899 * @return array<string>
900 */
901 public function get_all_track_types(): array {
902 $options = $this->get_options();
903
904 return array_unique( array_merge( $options['track_post_types'], $options['track_page_types'] ) );
905 }
906
907 /**
908 * Gets default options.
909 *
910 * @since 3.8.0
911 *
912 * @return Parsely_Options
913 */
914 public function get_default_options() {
915 return $this->option_defaults;
916 }
917
918 /**
919 * Returns the credentials that are being managed at the platform level.
920 *
921 * @since 3.9.0
922 * @access private
923 *
924 * @return Parsely_Options|array<empty> The managed credentials.
925 */
926 private function get_managed_credentials() {
927 if ( true !== $this->are_credentials_managed ) {
928 return array();
929 }
930
931 $credentials = apply_filters( 'wp_parsely_credentials', array() );
932
933 if ( ! is_array( $credentials ) || 0 === count( $credentials ) ) {
934 return array();
935 }
936
937 $result = array();
938
939 if ( isset( $credentials['site_id'] ) ) {
940 $result['apikey'] = $credentials['site_id'];
941 }
942
943 if ( isset( $credentials['api_secret'] ) ) {
944 $result['api_secret'] = $credentials['api_secret'];
945 }
946
947 if ( isset( $credentials['metadata_secret'] ) ) {
948 $result['metadata_secret'] = $credentials['metadata_secret'];
949 }
950
951 return $result;
952 }
953
954 /**
955 * Returns whether credentials are being managed at the platform level.
956 *
957 * @since 3.9.0
958 * @access private
959 *
960 * @return bool Whether credentials are being managed at the platform level.
961 */
962 private function are_credentials_managed(): bool {
963 $credentials = apply_filters( 'wp_parsely_credentials', array() );
964
965 if ( ! is_array( $credentials ) || 0 === count( $credentials ) ) {
966 return false;
967 }
968
969 return $credentials['is_managed'] ?? false;
970 }
971
972 /**
973 * Sets the values of managed options.
974 *
975 * This function won't accept managing credentials or certain plugin options
976 * that are being managed through other means. For managing credentials,
977 * please use the `wp_parsely_credentials` filter.
978 *
979 * @since 3.9.0
980 * @access private
981 */
982 private function set_managed_options(): void {
983 $managed_options = apply_filters( 'wp_parsely_managed_options', false );
984
985 if ( ! is_array( $managed_options ) ) {
986 return;
987 }
988
989 // Don't allow certain options to be set as managed.
990 unset(
991 $managed_options['apikey'],
992 $managed_options['api_secret'],
993 $managed_options['metadata_secret'],
994 $managed_options['track_post_types'],
995 $managed_options['track_page_types'],
996 $managed_options['plugin_version']
997 );
998
999 if ( 0 === count( $managed_options ) ) {
1000 return;
1001 }
1002
1003 /**
1004 * Current options.
1005 *
1006 * @var Parsely_Options $current_options
1007 */
1008 $current_options = get_option( self::OPTIONS_KEY, array() );
1009
1010 // Set managed options values.
1011 foreach ( $managed_options as $key => $value ) {
1012 $is_option_valid = isset( $this->option_defaults[ $key ] );
1013
1014 if ( $is_option_valid ) {
1015 if ( null === $value ) {
1016 // When null, the option gets its value from the database.
1017 $this->managed_options[ $key ] =
1018 $current_options[ $key ] ?? $this->option_defaults[ $key ];
1019 } else {
1020 $this->managed_options[ $key ] =
1021 $this->sanitize_managed_option( $key, $value );
1022 }
1023 }
1024 }
1025 }
1026
1027 /**
1028 * Gets the Parse.ly canonical URL for a given post.
1029 *
1030 * @since 3.19.0
1031 *
1032 * @param WP_Post|int $post The post ID or post object.
1033 * @return string The Parse.ly canonical URL.
1034 */
1035 public static function get_canonical_url_from_post( $post ): string {
1036 $post_id = is_int( $post ) ? $post : $post->ID;
1037 $canonical_url = get_post_meta( $post_id, self::PARSELY_CANONICAL_URL_META_KEY, true );
1038
1039 if ( null !== $canonical_url && is_string( $canonical_url ) && '' !== $canonical_url ) {
1040 return self::get_canonical_url( $canonical_url );
1041 }
1042
1043 $permalink = get_permalink( $post );
1044
1045 if ( false === $permalink ) {
1046 return __( 'no permalink', 'wp-parsely' );
1047 }
1048
1049 return self::get_canonical_url( $permalink );
1050 }
1051
1052 /**
1053 * Returns the canonical version of the passed URL.
1054 *
1055 * In this context, the canonical URL is the URL containing the Site ID as
1056 * its domain. If the Site ID differs from the real domain, the
1057 * `wp_parsely_canonical_url_domain` filter can be used to set it.
1058 *
1059 * @since 3.19.0
1060 * @since 3.20.4 Made the domain overridable.
1061 *
1062 * @param string $url The URL to get the canonical URL for.
1063 * @return string The canonical URL.
1064 */
1065 public static function get_canonical_url( string $url ): string {
1066 $canonical_url_domain = apply_filters(
1067 'wp_parsely_canonical_url_domain',
1068 null
1069 );
1070
1071 // Handle domain override.
1072 if ( is_string( $canonical_url_domain ) ) {
1073 // Get the canonical URL domain without protocol, trailing slashes
1074 // or accidental whitespace.
1075 $canonical_url_domain = rtrim( trim( $canonical_url_domain ), '/' );
1076 $canonical_url_domain = preg_replace( '#^https?://#', '', $canonical_url_domain );
1077
1078 if ( is_string( $canonical_url_domain ) && '' !== $canonical_url_domain ) {
1079 $url_domain = (string) wp_parse_url( $url, PHP_URL_HOST );
1080 return str_replace( $url_domain, $canonical_url_domain, $url );
1081 }
1082 }
1083
1084 $site_id = \Parsely\get_parsely()->get_site_id();
1085 $home_url_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
1086
1087 if ( $home_url_host === $site_id ) {
1088 // URL does not need to be modified.
1089 return $url;
1090 }
1091
1092 // Return the URL with the Site ID as the domain.
1093 return str_replace( $home_url_host, $site_id, $url );
1094 }
1095
1096 /**
1097 * Sets the Parse.ly canonical URL for a post.
1098 *
1099 * @since 3.19.0
1100 *
1101 * @param WP_Post|int $post The post object or post ID.
1102 * @param string $url The canonical URL.
1103 * @return bool True if the canonical URL was set, false otherwise.
1104 */
1105 public static function set_canonical_url( $post, string $url ): bool {
1106 $post_id = is_int( $post ) ? $post : $post->ID;
1107 $canonical_url = self::get_canonical_url( $url );
1108
1109 return false !== update_post_meta(
1110 $post_id,
1111 self::PARSELY_CANONICAL_URL_META_KEY,
1112 sanitize_url( $canonical_url, array( 'http', 'https' ) )
1113 );
1114 }
1115
1116 /**
1117 * Sanitizes the value of the passed managed option.
1118 *
1119 * @since 3.9.0
1120 * @access private
1121 *
1122 * @param string $option_id The option's ID.
1123 * @param bool|string $value The option's value.
1124 * @return bool|string The sanitized option value.
1125 */
1126 private function sanitize_managed_option( string $option_id, $value ) {
1127 $option_value_type = gettype( $this->option_defaults[ $option_id ] );
1128
1129 if ( 'boolean' === $option_value_type && ! is_bool( $value ) ) {
1130 _doing_it_wrong(
1131 __FUNCTION__,
1132 esc_html(
1133 sprintf( /* translators: 1: Option ID */
1134 __( 'The value of the managed option `%1$s` must be of type `boolean`.', 'wp-parsely' ),
1135 $option_id
1136 )
1137 ),
1138 ''
1139 );
1140
1141 return false;
1142 }
1143
1144 if ( 'string' === $option_value_type ) {
1145 if ( ! is_string( $value ) ) {
1146 _doing_it_wrong(
1147 __FUNCTION__,
1148 esc_html(
1149 sprintf( /* translators: 1: Option ID */
1150 __( 'The value of the managed option `%1$s` must be of type `string`.', 'wp-parsely' ),
1151 $option_id
1152 )
1153 ),
1154 ''
1155 );
1156
1157 $value = strval( $value );
1158 }
1159
1160 // String options that are restricted to specific values.
1161 $restricted_value_options = array(
1162 'custom_taxonomy_section' => Settings_Page::get_section_taxonomies(),
1163 'meta_type' => array( 'json_ld', 'repeated_metas' ),
1164 );
1165
1166 // Verify that the above values are respected.
1167 foreach ( $restricted_value_options as $option_key => $valid_values ) {
1168 if ( $option_id === $option_key ) {
1169 if ( ! in_array( $value, $valid_values, true ) ) {
1170 _doing_it_wrong(
1171 __FUNCTION__,
1172 esc_html(
1173 sprintf( /* translators: 1: Option value 2: Option ID */
1174 __( 'The value `%1$s` is not allowed for the managed option `%2$s`.', 'wp-parsely' ),
1175 $value,
1176 $option_id
1177 )
1178 ),
1179 ''
1180 );
1181
1182 $value = $this->option_defaults[ $option_id ];
1183 }
1184 }
1185 }
1186 }
1187
1188 return $value;
1189 }
1190
1191 /**
1192 * Allows remote requests to Parse.ly.
1193 *
1194 * This is needed for environments, such as wp-now, that block remote requests.
1195 *
1196 * @since 3.13.0
1197 * @access private
1198 */
1199 private function allow_parsely_remote_requests(): void {
1200 $allowed_urls = array(
1201 self::DASHBOARD_BASE_URL,
1202 Content_API_Service::get_base_url(),
1203 Suggestions_API_Service::get_base_url(),
1204 );
1205
1206 add_filter(
1207 'http_request_host_is_external',
1208 function ( bool $external, string $host, string $url ) use ( $allowed_urls ) {
1209 // Check if the URL matches any URLs on the allowed list.
1210 foreach ( $allowed_urls as $allowed_url ) {
1211 if ( Utils::str_starts_with( $url, $allowed_url ) ) {
1212 return true;
1213 }
1214 }
1215 return $external;
1216 },
1217 10,
1218 3
1219 );
1220 }
1221 }
1222