PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
3.24.2 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 All 106 releases
wp-parsely / src / class-parsely.php

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

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