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

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