class.jetpack-search-helpers.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. <?php
  2. /**
  3. * Jetpack Search: Jetpack_Search_Helpers class
  4. *
  5. * @package Jetpack
  6. * @subpackage Jetpack Search
  7. * @since 5.8.0
  8. */
  9. /**
  10. * Various helper functions for reuse throughout the Jetpack Search code.
  11. *
  12. * @since 5.8.0
  13. */
  14. class Jetpack_Search_Helpers {
  15. /**
  16. * The search widget's base ID.
  17. *
  18. * @since 5.8.0
  19. * @var string
  20. */
  21. const FILTER_WIDGET_BASE = 'jetpack-search-filters';
  22. /**
  23. * Create a URL for the current search that doesn't include the "paged" parameter.
  24. *
  25. * @since 5.8.0
  26. *
  27. * @return string The search URL.
  28. */
  29. static function get_search_url() {
  30. $query_args = stripslashes_deep( $_GET );
  31. // Handle the case where a permastruct is being used, such as /search/{$query}
  32. if ( ! isset( $query_args['s'] ) ) {
  33. $query_args['s'] = get_search_query();
  34. }
  35. if ( isset( $query_args['paged'] ) ) {
  36. unset( $query_args['paged'] );
  37. }
  38. $query = http_build_query( $query_args );
  39. return home_url( "?{$query}" );
  40. }
  41. /**
  42. * Wraps add_query_arg() with the URL defaulting to the current search URL.
  43. *
  44. * @see add_query_arg()
  45. *
  46. * @since 5.8.0
  47. *
  48. * @param string|array $key Either a query variable key, or an associative array of query variables.
  49. * @param string $value Optional. A query variable value.
  50. * @param bool|string $url Optional. A URL to act upon. Defaults to the current search URL.
  51. *
  52. * @return string New URL query string (unescaped).
  53. */
  54. static function add_query_arg( $key, $value = false, $url = false ) {
  55. $url = empty( $url ) ? self::get_search_url() : $url;
  56. if ( is_array( $key ) ) {
  57. return add_query_arg( $key, $url );
  58. }
  59. return add_query_arg( $key, $value, $url );
  60. }
  61. /**
  62. * Wraps remove_query_arg() with the URL defaulting to the current search URL.
  63. *
  64. * @see remove_query_arg()
  65. *
  66. * @since 5.8.0
  67. *
  68. * @param string|array $key Query key or keys to remove.
  69. * @param bool|string $query Optional. A URL to act upon. Defaults to the current search URL.
  70. *
  71. * @return string New URL query string (unescaped).
  72. */
  73. static function remove_query_arg( $key, $url = false ) {
  74. $url = empty( $url ) ? self::get_search_url() : $url;
  75. return remove_query_arg( $key, $url );
  76. }
  77. /**
  78. * Returns the name of the search widget's option.
  79. *
  80. * @since 5.8.0
  81. *
  82. * @return string The search widget option name.
  83. */
  84. static function get_widget_option_name() {
  85. return sprintf( 'widget_%s', self::FILTER_WIDGET_BASE );
  86. }
  87. /**
  88. * Returns the search widget instances from the widget's option.
  89. *
  90. * @since 5.8.0
  91. *
  92. * @return array The widget options.
  93. */
  94. static function get_widgets_from_option() {
  95. $widget_options = get_option( self::get_widget_option_name(), array() );
  96. // We don't need this
  97. if ( ! empty( $widget_options ) && isset( $widget_options['_multiwidget'] ) ) {
  98. unset( $widget_options['_multiwidget'] );
  99. }
  100. return $widget_options;
  101. }
  102. /**
  103. * Returns the widget ID (widget base plus the numeric ID).
  104. *
  105. * @param int $number The widget's numeric ID.
  106. *
  107. * @return string The widget's numeric ID prefixed with the search widget base.
  108. */
  109. static function build_widget_id( $number ) {
  110. return sprintf( '%s-%d', self::FILTER_WIDGET_BASE, $number );
  111. }
  112. /**
  113. * Wrapper for is_active_widget() with the other parameters automatically supplied.
  114. *
  115. * @see is_active_widget()
  116. *
  117. * @since 5.8.0
  118. *
  119. * @param int $widget_id Widget ID.
  120. *
  121. * @return bool Whether the widget is active or not.
  122. */
  123. static function is_active_widget( $widget_id ) {
  124. return (bool) is_active_widget( false, $widget_id, self::FILTER_WIDGET_BASE, true );
  125. }
  126. /**
  127. * Returns an array of the filters from all active search widgets.
  128. *
  129. * @since 5.8.0
  130. *
  131. * @return array Active filters.
  132. */
  133. static function get_filters_from_widgets() {
  134. $filters = array();
  135. $widget_options = self::get_widgets_from_option();
  136. if ( empty( $widget_options ) ) {
  137. return $filters;
  138. }
  139. foreach ( (array) $widget_options as $number => $settings ) {
  140. $widget_id = self::build_widget_id( $number );
  141. if ( ! self::is_active_widget( $widget_id ) || empty( $settings['filters'] ) ) {
  142. continue;
  143. }
  144. foreach ( (array) $settings['filters'] as $widget_filter ) {
  145. $widget_filter['widget_id'] = $widget_id;
  146. if ( empty( $widget_filter['name'] ) ) {
  147. $widget_filter['name'] = self::generate_widget_filter_name( $widget_filter );
  148. }
  149. $key = sprintf( '%s_%d', $widget_filter['type'], count( $filters ) );
  150. $filters[ $key ] = $widget_filter;
  151. }
  152. }
  153. return $filters;
  154. }
  155. /**
  156. * Get the localized default label for a date filter.
  157. *
  158. * @since 5.8.0
  159. *
  160. * @param string $type Date type, either year or month.
  161. * @param bool $is_updated Whether the filter was updated or not (adds "Updated" to the end).
  162. *
  163. * @return string The filter label.
  164. */
  165. static function get_date_filter_type_name( $type, $is_updated = false ) {
  166. switch ( $type ) {
  167. case 'year':
  168. $string = ( $is_updated )
  169. ? esc_html_x( 'Year Updated', 'label for filtering posts', 'jetpack' )
  170. : esc_html_x( 'Year', 'label for filtering posts', 'jetpack' );
  171. break;
  172. case 'month':
  173. default:
  174. $string = ( $is_updated )
  175. ? esc_html_x( 'Month Updated', 'label for filtering posts', 'jetpack' )
  176. : esc_html_x( 'Month', 'label for filtering posts', 'jetpack' );
  177. break;
  178. }
  179. return $string;
  180. }
  181. /**
  182. * Creates a default name for a filter. Used when the filter label is blank.
  183. *
  184. * @since 5.8.0
  185. *
  186. * @param array $widget_filter The filter to generate the title for.
  187. *
  188. * @return string The suggested filter name.
  189. */
  190. static function generate_widget_filter_name( $widget_filter ) {
  191. $name = '';
  192. switch ( $widget_filter['type'] ) {
  193. case 'post_type':
  194. $name = _x( 'Post Types', 'label for filtering posts', 'jetpack' );
  195. break;
  196. case 'date_histogram':
  197. $modified_fields = array(
  198. 'post_modified',
  199. 'post_modified_gmt',
  200. );
  201. switch ( $widget_filter['interval'] ) {
  202. case 'year':
  203. $name = self::get_date_filter_type_name(
  204. 'year',
  205. in_array( $widget_filter['field'], $modified_fields )
  206. );
  207. break;
  208. case 'month':
  209. default:
  210. $name = self::get_date_filter_type_name(
  211. 'month',
  212. in_array( $widget_filter['field'], $modified_fields )
  213. );
  214. break;
  215. }
  216. break;
  217. case 'taxonomy':
  218. $tax = get_taxonomy( $widget_filter['taxonomy'] );
  219. if ( ! $tax ) {
  220. break;
  221. }
  222. if ( isset( $tax->label ) ) {
  223. $name = $tax->label;
  224. } elseif ( isset( $tax->labels ) && isset( $tax->labels->name ) ) {
  225. $name = $tax->labels->name;
  226. }
  227. break;
  228. }
  229. return $name;
  230. }
  231. /**
  232. * Whether we should rerun a search in the customizer preview or not.
  233. *
  234. * @since 5.8.0
  235. *
  236. * @return bool
  237. */
  238. static function should_rerun_search_in_customizer_preview() {
  239. // Only update when in a customizer preview and data is being posted.
  240. // Check for $_POST removes an extra update when the customizer loads.
  241. //
  242. // Note: We use $GLOBALS['wp_customize'] here instead of is_customize_preview() to support unit tests.
  243. if ( ! isset( $GLOBALS['wp_customize'] ) || ! $GLOBALS['wp_customize']->is_preview() || empty( $_POST ) ) {
  244. return false;
  245. }
  246. return true;
  247. }
  248. /**
  249. * Since PHP's built-in array_diff() works by comparing the values that are in array 1 to the other arrays,
  250. * if there are less values in array 1, it's possible to get an empty diff where one might be expected.
  251. *
  252. * @since 5.8.0
  253. *
  254. * @param array $array_1
  255. * @param array $array_2
  256. *
  257. * @return array
  258. */
  259. static function array_diff( $array_1, $array_2 ) {
  260. // If the array counts are the same, then the order doesn't matter. If the count of
  261. // $array_1 is higher than $array_2, that's also fine. If the count of $array_2 is higher,
  262. // we need to swap the array order though.
  263. if ( count( $array_1 ) !== count( $array_2 ) && count( $array_2 ) > count( $array_1 ) ) {
  264. $temp = $array_1;
  265. $array_1 = $array_2;
  266. $array_2 = $temp;
  267. }
  268. // Disregard keys
  269. return array_values( array_diff( $array_1, $array_2 ) );
  270. }
  271. /**
  272. * Given the widget instance, will return true when selected post types differ from searchable post types.
  273. *
  274. * @since 5.8.0
  275. *
  276. * @param array $post_types An array of post types.
  277. *
  278. * @return bool
  279. */
  280. static function post_types_differ_searchable( $post_types ) {
  281. if ( empty( $post_types ) ) {
  282. return false;
  283. }
  284. $searchable_post_types = get_post_types( array( 'exclude_from_search' => false ) );
  285. $diff_of_searchable = self::array_diff( $searchable_post_types, (array) $post_types );
  286. return ! empty( $diff_of_searchable );
  287. }
  288. /**
  289. * Given the array of post types, will return true when these differ from the current search query.
  290. *
  291. * @since 5.8.0
  292. *
  293. * @param array $post_types An array of post types.
  294. *
  295. * @return bool
  296. */
  297. static function post_types_differ_query( $post_types ) {
  298. if ( empty( $post_types ) ) {
  299. return false;
  300. }
  301. if ( empty( $_GET['post_type'] ) ) {
  302. $post_types_from_query = array();
  303. } elseif ( is_array( $_GET['post_type'] ) ) {
  304. $post_types_from_query = $_GET['post_type'];
  305. } else {
  306. $post_types_from_query = (array) explode( ',', $_GET['post_type'] );
  307. }
  308. $post_types_from_query = array_map( 'trim', $post_types_from_query );
  309. $diff_query = self::array_diff( (array) $post_types, $post_types_from_query );
  310. return ! empty( $diff_query );
  311. }
  312. /**
  313. * Determine what Tracks value should be used when updating a widget.
  314. *
  315. * @since 5.8.0
  316. *
  317. * @param mixed $old_value The old option value.
  318. * @param mixed $new_value The new option value.
  319. *
  320. * @return array|false False if the widget wasn't updated, otherwise an array of the Tracks action and widget properties.
  321. */
  322. static function get_widget_tracks_value( $old_value, $new_value ) {
  323. $old_value = (array) $old_value;
  324. if ( isset( $old_value['_multiwidget'] ) ) {
  325. unset( $old_value['_multiwidget'] );
  326. }
  327. $new_value = (array) $new_value;
  328. if ( isset( $new_value['_multiwidget'] ) ) {
  329. unset( $new_value['_multiwidget'] );
  330. }
  331. $old_keys = array_keys( $old_value );
  332. $new_keys = array_keys( $new_value );
  333. if ( count( $new_keys ) > count( $old_keys ) ) { // This is the case for a widget being added
  334. $diff = self::array_diff( $new_keys, $old_keys );
  335. $action = 'widget_added';
  336. $widget = empty( $diff ) || ! isset( $new_value[ $diff[0] ] )
  337. ? false
  338. : $new_value[ $diff[0] ];
  339. } elseif ( count( $old_keys ) > count( $new_keys ) ) { // This is the case for a widget being deleted
  340. $diff = self::array_diff( $old_keys, $new_keys );
  341. $action = 'widget_deleted';
  342. $widget = empty( $diff ) || ! isset( $old_value[ $diff[0] ] )
  343. ? false
  344. : $old_value[ $diff[0] ];
  345. } else {
  346. $action = 'widget_updated';
  347. $widget = false;
  348. // This is a bit crazy. Since there can be multiple widgets stored in a single option,
  349. // we need to diff the old and new values to figure out which widget was updated.
  350. foreach ( $new_value as $key => $new_instance ) {
  351. if ( ! isset( $old_value[ $key ] ) ) {
  352. continue;
  353. }
  354. $old_instance = $old_value[ $key ];
  355. // First, let's test the keys of each instance
  356. $diff = self::array_diff( array_keys( $new_instance ), array_keys( $old_instance ) );
  357. if ( ! empty( $diff ) ) {
  358. $widget = $new_instance;
  359. break;
  360. }
  361. // Next, lets's loop over each value and compare it
  362. foreach ( $new_instance as $k => $v ) {
  363. if ( is_scalar( $v ) && (string) $v !== (string) $old_instance[ $k ] ) {
  364. $widget = $new_instance;
  365. break;
  366. }
  367. if ( 'filters' == $k ) {
  368. if ( count( $new_instance['filters'] ) != count( $old_instance['filters'] ) ) {
  369. $widget = $new_instance;
  370. break;
  371. }
  372. foreach ( $v as $filter_key => $new_filter_value ) {
  373. $diff = self::array_diff( $new_filter_value, $old_instance['filters'][ $filter_key ] );
  374. if ( ! empty( $diff ) ) {
  375. $widget = $new_instance;
  376. break;
  377. }
  378. }
  379. }
  380. }
  381. }
  382. }
  383. if ( empty( $action ) || empty( $widget ) ) {
  384. return false;
  385. }
  386. return array(
  387. 'action' => $action,
  388. 'widget' => self::get_widget_properties_for_tracks( $widget ),
  389. );
  390. }
  391. /**
  392. * Creates the widget properties for sending to Tracks.
  393. *
  394. * @since 5.8.0
  395. *
  396. * @param array $widget The widget instance.
  397. *
  398. * @return array The widget properties.
  399. */
  400. static function get_widget_properties_for_tracks( $widget ) {
  401. $sanitized = array();
  402. foreach ( (array) $widget as $key => $value ) {
  403. if ( '_multiwidget' == $key ) {
  404. continue;
  405. }
  406. if ( is_scalar( $value ) ) {
  407. $key = str_replace( '-', '_', sanitize_key( $key ) );
  408. $key = "widget_{$key}";
  409. $sanitized[ $key ] = $value;
  410. }
  411. }
  412. $filters_properties = ! empty( $widget['filters'] )
  413. ? self::get_filter_properties_for_tracks( $widget['filters'] )
  414. : array();
  415. return array_merge( $sanitized, $filters_properties );
  416. }
  417. /**
  418. * Creates the filter properties for sending to Tracks.
  419. *
  420. * @since 5.8.0
  421. *
  422. * @param array $filters An array of filters.
  423. *
  424. * @return array The filter properties.
  425. */
  426. static function get_filter_properties_for_tracks( $filters ) {
  427. if ( empty( $filters ) ) {
  428. return $filters;
  429. }
  430. $filters_properties = array(
  431. 'widget_filter_count' => count( $filters ),
  432. );
  433. foreach ( $filters as $filter ) {
  434. if ( empty( $filter['type'] ) ) {
  435. continue;
  436. }
  437. $key = sprintf( 'widget_filter_type_%s', $filter['type'] );
  438. if ( isset( $filters_properties[ $key ] ) ) {
  439. $filters_properties[ $key ] ++;
  440. } else {
  441. $filters_properties[ $key ] = 1;
  442. }
  443. }
  444. return $filters_properties;
  445. }
  446. /**
  447. * Gets the active post types given a set of filters.
  448. *
  449. * @since 5.8.0
  450. *
  451. * @param array $filters The active filters for the current query.
  452. *
  453. * @return array The active post types.
  454. */
  455. public static function get_active_post_types( $filters ) {
  456. $active_post_types = array();
  457. foreach ( $filters as $item ) {
  458. if ( ( 'post_type' == $item['type'] ) && isset( $item['query_vars']['post_type'] ) ) {
  459. $active_post_types[] = $item['query_vars']['post_type'];
  460. }
  461. }
  462. return $active_post_types;
  463. }
  464. /**
  465. * Sets active to false on all post type buckets.
  466. *
  467. * @since 5.8.0
  468. *
  469. * @param array $filters The available filters for the current query.
  470. *
  471. * @return array The filters for the current query with modified active field.
  472. */
  473. public static function remove_active_from_post_type_buckets( $filters ) {
  474. $modified = $filters;
  475. foreach ( $filters as $key => $filter ) {
  476. if ( 'post_type' === $filter['type'] && ! empty( $filter['buckets'] ) ) {
  477. foreach ( $filter['buckets'] as $k => $bucket ) {
  478. $bucket['active'] = false;
  479. $modified[ $key ]['buckets'][ $k ] = $bucket;
  480. }
  481. }
  482. }
  483. return $modified;
  484. }
  485. /**
  486. * Given a url and an array of post types, will ensure that the post types are properly applied to the URL as args.
  487. *
  488. * @since 5.8.0
  489. *
  490. * @param string $url The URL to add post types to.
  491. * @param array $post_types An array of post types that should be added to the URL.
  492. *
  493. * @return string The URL with added post types.
  494. */
  495. public static function add_post_types_to_url( $url, $post_types ) {
  496. $url = Jetpack_Search_Helpers::remove_query_arg( 'post_type', $url );
  497. if ( empty( $post_types ) ) {
  498. return $url;
  499. }
  500. $url = Jetpack_Search_Helpers::add_query_arg(
  501. 'post_type',
  502. implode( ',', $post_types ),
  503. $url
  504. );
  505. return $url;
  506. }
  507. /**
  508. * Since we provide support for the widget restricting post types by adding the selected post types as
  509. * active filters, if removing a post type filter would result in there no longer be post_type args in the URL,
  510. * we need to be sure to add them back.
  511. *
  512. * @since 5.8.0
  513. *
  514. * @param array $filters An array of possible filters for the current query.
  515. * @param array $post_types The post types to ensure are on the link.
  516. *
  517. * @return array The updated array of filters with post typed added to the remove URLs.
  518. */
  519. public static function ensure_post_types_on_remove_url( $filters, $post_types ) {
  520. $modified = $filters;
  521. foreach ( (array) $filters as $filter_key => $filter ) {
  522. if ( 'post_type' !== $filter['type'] || empty( $filter['buckets'] ) ) {
  523. $modified[ $filter_key ] = $filter;
  524. continue;
  525. }
  526. foreach ( (array) $filter['buckets'] as $bucket_key => $bucket ) {
  527. if ( empty( $bucket['remove_url'] ) ) {
  528. continue;
  529. }
  530. $parsed = wp_parse_url( $bucket['remove_url'] );
  531. if ( ! $parsed ) {
  532. continue;
  533. }
  534. $query = array();
  535. if ( ! empty( $parsed['query'] ) ) {
  536. wp_parse_str( $parsed['query'], $query );
  537. }
  538. if ( empty( $query['post_type'] ) ) {
  539. $modified[ $filter_key ]['buckets'][ $bucket_key ]['remove_url'] = self::add_post_types_to_url(
  540. $bucket['remove_url'],
  541. $post_types
  542. );
  543. }
  544. }
  545. }
  546. return $modified;
  547. }
  548. /**
  549. * Wraps a WordPress filter called "jetpack_search_disable_widget_filters" that allows
  550. * developers to disable filters supplied by the search widget. Useful if filters are
  551. * being defined at the code level.
  552. *
  553. * @since 5.8.0
  554. *
  555. * @return bool
  556. */
  557. public static function are_filters_by_widget_disabled() {
  558. /**
  559. * Allows developers to disable filters being set by widget, in favor of manually
  560. * setting filters via `Jetpack_Search::set_filters()`.
  561. *
  562. * @module search
  563. *
  564. * @since 5.7.0
  565. *
  566. * @param bool false
  567. */
  568. return apply_filters( 'jetpack_search_disable_widget_filters', false );
  569. }
  570. /**
  571. * Returns a boolean for whether the current site has a VIP index.
  572. *
  573. * @since 5.8.0
  574. *
  575. * @return bool
  576. */
  577. public static function site_has_vip_index() {
  578. $has_vip_index = (
  579. Jetpack_Constants::is_defined( 'JETPACK_SEARCH_VIP_INDEX' ) &&
  580. Jetpack_Constants::get_constant( 'JETPACK_SEARCH_VIP_INDEX' )
  581. );
  582. /**
  583. * Allows developers to filter whether the current site has a VIP index.
  584. *
  585. * @module search
  586. *
  587. * @since 5.8.0
  588. *
  589. * @param bool $has_vip_index Whether the current site has a VIP index.
  590. */
  591. return apply_filters( 'jetpack_search_has_vip_index', $has_vip_index );
  592. }
  593. /**
  594. * Returns the maximum posts per page for a search query.
  595. *
  596. * @since 5.8.0
  597. *
  598. * @return int
  599. */
  600. public static function get_max_posts_per_page() {
  601. return self::site_has_vip_index() ? 1000 : 100;
  602. }
  603. /**
  604. * Returns the maximum offset for a search query.
  605. *
  606. * @since 5.8.0
  607. *
  608. * @return int
  609. */
  610. public static function get_max_offset() {
  611. return self::site_has_vip_index() ? 9000 : 1000;
  612. }
  613. }