class-wp-plugin-install-list-table.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <?php
  2. /**
  3. * List Table API: WP_Plugin_Install_List_Table class
  4. *
  5. * @package WordPress
  6. * @subpackage Administration
  7. * @since 3.1.0
  8. */
  9. /**
  10. * Core class used to implement displaying plugins to install in a list table.
  11. *
  12. * @since 3.1.0
  13. * @access private
  14. *
  15. * @see WP_List_Table
  16. */
  17. class WP_Plugin_Install_List_Table extends WP_List_Table {
  18. public $order = 'ASC';
  19. public $orderby = null;
  20. public $groups = array();
  21. private $error;
  22. /**
  23. *
  24. * @return bool
  25. */
  26. public function ajax_user_can() {
  27. return current_user_can('install_plugins');
  28. }
  29. /**
  30. * Return the list of known plugins.
  31. *
  32. * Uses the transient data from the updates API to determine the known
  33. * installed plugins.
  34. *
  35. * @since 4.9.0
  36. * @access protected
  37. *
  38. * @return array
  39. */
  40. protected function get_installed_plugins() {
  41. $plugins = array();
  42. $plugin_info = get_site_transient( 'update_plugins' );
  43. if ( isset( $plugin_info->no_update ) ) {
  44. foreach ( $plugin_info->no_update as $plugin ) {
  45. $plugin->upgrade = false;
  46. $plugins[ $plugin->slug ] = $plugin;
  47. }
  48. }
  49. if ( isset( $plugin_info->response ) ) {
  50. foreach ( $plugin_info->response as $plugin ) {
  51. $plugin->upgrade = true;
  52. $plugins[ $plugin->slug ] = $plugin;
  53. }
  54. }
  55. return $plugins;
  56. }
  57. /**
  58. * Return a list of slugs of installed plugins, if known.
  59. *
  60. * Uses the transient data from the updates API to determine the slugs of
  61. * known installed plugins. This might be better elsewhere, perhaps even
  62. * within get_plugins().
  63. *
  64. * @since 4.0.0
  65. *
  66. * @return array
  67. */
  68. protected function get_installed_plugin_slugs() {
  69. return array_keys( $this->get_installed_plugins() );
  70. }
  71. /**
  72. *
  73. * @global array $tabs
  74. * @global string $tab
  75. * @global int $paged
  76. * @global string $type
  77. * @global string $term
  78. */
  79. public function prepare_items() {
  80. include( ABSPATH . 'wp-admin/includes/plugin-install.php' );
  81. global $tabs, $tab, $paged, $type, $term;
  82. wp_reset_vars( array( 'tab' ) );
  83. $paged = $this->get_pagenum();
  84. $per_page = 30;
  85. // These are the tabs which are shown on the page
  86. $tabs = array();
  87. if ( 'search' === $tab ) {
  88. $tabs['search'] = __( 'Search Results' );
  89. }
  90. if ( $tab === 'beta' || false !== strpos( get_bloginfo( 'version' ), '-' ) ) {
  91. $tabs['beta'] = _x( 'Beta Testing', 'Plugin Installer' );
  92. }
  93. $tabs['featured'] = _x( 'Featured', 'Plugin Installer' );
  94. $tabs['popular'] = _x( 'Popular', 'Plugin Installer' );
  95. $tabs['recommended'] = _x( 'Recommended', 'Plugin Installer' );
  96. $tabs['favorites'] = _x( 'Favorites', 'Plugin Installer' );
  97. if ( current_user_can( 'upload_plugins' ) ) {
  98. // No longer a real tab. Here for filter compatibility.
  99. // Gets skipped in get_views().
  100. $tabs['upload'] = __( 'Upload Plugin' );
  101. }
  102. $nonmenu_tabs = array( 'plugin-information' ); // Valid actions to perform which do not have a Menu item.
  103. /**
  104. * Filters the tabs shown on the Plugin Install screen.
  105. *
  106. * @since 2.7.0
  107. *
  108. * @param array $tabs The tabs shown on the Plugin Install screen. Defaults include 'featured', 'popular',
  109. * 'recommended', 'favorites', and 'upload'.
  110. */
  111. $tabs = apply_filters( 'install_plugins_tabs', $tabs );
  112. /**
  113. * Filters tabs not associated with a menu item on the Plugin Install screen.
  114. *
  115. * @since 2.7.0
  116. *
  117. * @param array $nonmenu_tabs The tabs that don't have a Menu item on the Plugin Install screen.
  118. */
  119. $nonmenu_tabs = apply_filters( 'install_plugins_nonmenu_tabs', $nonmenu_tabs );
  120. // If a non-valid menu tab has been selected, And it's not a non-menu action.
  121. if ( empty( $tab ) || ( !isset( $tabs[ $tab ] ) && !in_array( $tab, (array) $nonmenu_tabs ) ) )
  122. $tab = key( $tabs );
  123. $installed_plugins = $this->get_installed_plugins();
  124. $args = array(
  125. 'page' => $paged,
  126. 'per_page' => $per_page,
  127. 'fields' => array(
  128. 'last_updated' => true,
  129. 'icons' => true,
  130. 'active_installs' => true
  131. ),
  132. // Send the locale and installed plugin slugs to the API so it can provide context-sensitive results.
  133. 'locale' => get_user_locale(),
  134. 'installed_plugins' => array_keys( $installed_plugins ),
  135. );
  136. switch ( $tab ) {
  137. case 'search':
  138. $type = isset( $_REQUEST['type'] ) ? wp_unslash( $_REQUEST['type'] ) : 'term';
  139. $term = isset( $_REQUEST['s'] ) ? wp_unslash( $_REQUEST['s'] ) : '';
  140. switch ( $type ) {
  141. case 'tag':
  142. $args['tag'] = sanitize_title_with_dashes( $term );
  143. break;
  144. case 'term':
  145. $args['search'] = $term;
  146. break;
  147. case 'author':
  148. $args['author'] = $term;
  149. break;
  150. }
  151. break;
  152. case 'featured':
  153. $args['fields']['group'] = true;
  154. $this->orderby = 'group';
  155. // No break!
  156. case 'popular':
  157. case 'new':
  158. case 'beta':
  159. case 'recommended':
  160. $args['browse'] = $tab;
  161. break;
  162. case 'favorites':
  163. $action = 'save_wporg_username_' . get_current_user_id();
  164. if ( isset( $_GET['_wpnonce'] ) && wp_verify_nonce( wp_unslash( $_GET['_wpnonce'] ), $action ) ) {
  165. $user = isset( $_GET['user'] ) ? wp_unslash( $_GET['user'] ) : get_user_option( 'wporg_favorites' );
  166. update_user_meta( get_current_user_id(), 'wporg_favorites', $user );
  167. } else {
  168. $user = get_user_option( 'wporg_favorites' );
  169. }
  170. if ( $user )
  171. $args['user'] = $user;
  172. else
  173. $args = false;
  174. add_action( 'install_plugins_favorites', 'install_plugins_favorites_form', 9, 0 );
  175. break;
  176. default:
  177. $args = false;
  178. break;
  179. }
  180. /**
  181. * Filters API request arguments for each Plugin Install screen tab.
  182. *
  183. * The dynamic portion of the hook name, `$tab`, refers to the plugin install tabs.
  184. * Default tabs include 'featured', 'popular', 'recommended', 'favorites', and 'upload'.
  185. *
  186. * @since 3.7.0
  187. *
  188. * @param array|bool $args Plugin Install API arguments.
  189. */
  190. $args = apply_filters( "install_plugins_table_api_args_{$tab}", $args );
  191. if ( !$args )
  192. return;
  193. $api = plugins_api( 'query_plugins', $args );
  194. if ( is_wp_error( $api ) ) {
  195. $this->error = $api;
  196. return;
  197. }
  198. $this->items = $api->plugins;
  199. if ( $this->orderby ) {
  200. uasort( $this->items, array( $this, 'order_callback' ) );
  201. }
  202. $this->set_pagination_args( array(
  203. 'total_items' => $api->info['results'],
  204. 'per_page' => $args['per_page'],
  205. ) );
  206. if ( isset( $api->info['groups'] ) ) {
  207. $this->groups = $api->info['groups'];
  208. }
  209. if ( $installed_plugins ) {
  210. $js_plugins = array_fill_keys(
  211. array( 'all', 'search', 'active', 'inactive', 'recently_activated', 'mustuse', 'dropins' ),
  212. array()
  213. );
  214. $js_plugins['all'] = array_values( wp_list_pluck( $installed_plugins, 'plugin' ) );
  215. $upgrade_plugins = wp_filter_object_list( $installed_plugins, array( 'upgrade' => true ), 'and', 'plugin' );
  216. if ( $upgrade_plugins ) {
  217. $js_plugins['upgrade'] = array_values( $upgrade_plugins );
  218. }
  219. wp_localize_script( 'updates', '_wpUpdatesItemCounts', array(
  220. 'plugins' => $js_plugins,
  221. 'totals' => wp_get_update_data(),
  222. ) );
  223. }
  224. }
  225. /**
  226. */
  227. public function no_items() {
  228. if ( isset( $this->error ) ) { ?>
  229. <div class="inline error"><p><?php echo $this->error->get_error_message(); ?></p>
  230. <p class="hide-if-no-js"><button class="button try-again"><?php _e( 'Try Again' ); ?></button></p>
  231. </div>
  232. <?php } else { ?>
  233. <div class="no-plugin-results"><?php _e( 'No plugins found. Try a different search.' ); ?></div>
  234. <?php
  235. }
  236. }
  237. /**
  238. *
  239. * @global array $tabs
  240. * @global string $tab
  241. *
  242. * @return array
  243. */
  244. protected function get_views() {
  245. global $tabs, $tab;
  246. $display_tabs = array();
  247. foreach ( (array) $tabs as $action => $text ) {
  248. $current_link_attributes = ( $action === $tab ) ? ' class="current" aria-current="page"' : '';
  249. $href = self_admin_url('plugin-install.php?tab=' . $action);
  250. $display_tabs['plugin-install-'.$action] = "<a href='$href'$current_link_attributes>$text</a>";
  251. }
  252. // No longer a real tab.
  253. unset( $display_tabs['plugin-install-upload'] );
  254. return $display_tabs;
  255. }
  256. /**
  257. * Override parent views so we can use the filter bar display.
  258. */
  259. public function views() {
  260. $views = $this->get_views();
  261. /** This filter is documented in wp-admin/inclues/class-wp-list-table.php */
  262. $views = apply_filters( "views_{$this->screen->id}", $views );
  263. $this->screen->render_screen_reader_content( 'heading_views' );
  264. ?>
  265. <div class="wp-filter">
  266. <ul class="filter-links">
  267. <?php
  268. if ( ! empty( $views ) ) {
  269. foreach ( $views as $class => $view ) {
  270. $views[ $class ] = "\t<li class='$class'>$view";
  271. }
  272. echo implode( " </li>\n", $views ) . "</li>\n";
  273. }
  274. ?>
  275. </ul>
  276. <?php install_search_form(); ?>
  277. </div>
  278. <?php
  279. }
  280. /**
  281. * Override the parent display() so we can provide a different container.
  282. */
  283. public function display() {
  284. $singular = $this->_args['singular'];
  285. $data_attr = '';
  286. if ( $singular ) {
  287. $data_attr = " data-wp-lists='list:$singular'";
  288. }
  289. $this->display_tablenav( 'top' );
  290. ?>
  291. <div class="wp-list-table <?php echo implode( ' ', $this->get_table_classes() ); ?>">
  292. <?php
  293. $this->screen->render_screen_reader_content( 'heading_list' );
  294. ?>
  295. <div id="the-list"<?php echo $data_attr; ?>>
  296. <?php $this->display_rows_or_placeholder(); ?>
  297. </div>
  298. </div>
  299. <?php
  300. $this->display_tablenav( 'bottom' );
  301. }
  302. /**
  303. * @global string $tab
  304. *
  305. * @param string $which
  306. */
  307. protected function display_tablenav( $which ) {
  308. if ( $GLOBALS['tab'] === 'featured' ) {
  309. return;
  310. }
  311. if ( 'top' === $which ) {
  312. wp_referer_field();
  313. ?>
  314. <div class="tablenav top">
  315. <div class="alignleft actions">
  316. <?php
  317. /**
  318. * Fires before the Plugin Install table header pagination is displayed.
  319. *
  320. * @since 2.7.0
  321. */
  322. do_action( 'install_plugins_table_header' ); ?>
  323. </div>
  324. <?php $this->pagination( $which ); ?>
  325. <br class="clear" />
  326. </div>
  327. <?php } else { ?>
  328. <div class="tablenav bottom">
  329. <?php $this->pagination( $which ); ?>
  330. <br class="clear" />
  331. </div>
  332. <?php
  333. }
  334. }
  335. /**
  336. * @return array
  337. */
  338. protected function get_table_classes() {
  339. return array( 'widefat', $this->_args['plural'] );
  340. }
  341. /**
  342. * @return array
  343. */
  344. public function get_columns() {
  345. return array();
  346. }
  347. /**
  348. * @param object $plugin_a
  349. * @param object $plugin_b
  350. * @return int
  351. */
  352. private function order_callback( $plugin_a, $plugin_b ) {
  353. $orderby = $this->orderby;
  354. if ( ! isset( $plugin_a->$orderby, $plugin_b->$orderby ) ) {
  355. return 0;
  356. }
  357. $a = $plugin_a->$orderby;
  358. $b = $plugin_b->$orderby;
  359. if ( $a == $b ) {
  360. return 0;
  361. }
  362. if ( 'DESC' === $this->order ) {
  363. return ( $a < $b ) ? 1 : -1;
  364. } else {
  365. return ( $a < $b ) ? -1 : 1;
  366. }
  367. }
  368. public function display_rows() {
  369. $plugins_allowedtags = array(
  370. 'a' => array( 'href' => array(),'title' => array(), 'target' => array() ),
  371. 'abbr' => array( 'title' => array() ),'acronym' => array( 'title' => array() ),
  372. 'code' => array(), 'pre' => array(), 'em' => array(),'strong' => array(),
  373. 'ul' => array(), 'ol' => array(), 'li' => array(), 'p' => array(), 'br' => array()
  374. );
  375. $plugins_group_titles = array(
  376. 'Performance' => _x( 'Performance', 'Plugin installer group title' ),
  377. 'Social' => _x( 'Social', 'Plugin installer group title' ),
  378. 'Tools' => _x( 'Tools', 'Plugin installer group title' ),
  379. );
  380. $group = null;
  381. foreach ( (array) $this->items as $plugin ) {
  382. if ( is_object( $plugin ) ) {
  383. $plugin = (array) $plugin;
  384. }
  385. // Display the group heading if there is one
  386. if ( isset( $plugin['group'] ) && $plugin['group'] != $group ) {
  387. if ( isset( $this->groups[ $plugin['group'] ] ) ) {
  388. $group_name = $this->groups[ $plugin['group'] ];
  389. if ( isset( $plugins_group_titles[ $group_name ] ) ) {
  390. $group_name = $plugins_group_titles[ $group_name ];
  391. }
  392. } else {
  393. $group_name = $plugin['group'];
  394. }
  395. // Starting a new group, close off the divs of the last one
  396. if ( ! empty( $group ) ) {
  397. echo '</div></div>';
  398. }
  399. echo '<div class="plugin-group"><h3>' . esc_html( $group_name ) . '</h3>';
  400. // needs an extra wrapping div for nth-child selectors to work
  401. echo '<div class="plugin-items">';
  402. $group = $plugin['group'];
  403. }
  404. $title = wp_kses( $plugin['name'], $plugins_allowedtags );
  405. // Remove any HTML from the description.
  406. $description = strip_tags( $plugin['short_description'] );
  407. $version = wp_kses( $plugin['version'], $plugins_allowedtags );
  408. $name = strip_tags( $title . ' ' . $version );
  409. $author = wp_kses( $plugin['author'], $plugins_allowedtags );
  410. if ( ! empty( $author ) ) {
  411. $author = ' <cite>' . sprintf( __( 'By %s' ), $author ) . '</cite>';
  412. }
  413. $action_links = array();
  414. if ( current_user_can( 'install_plugins' ) || current_user_can( 'update_plugins' ) ) {
  415. $status = install_plugin_install_status( $plugin );
  416. switch ( $status['status'] ) {
  417. case 'install':
  418. if ( $status['url'] ) {
  419. /* translators: 1: Plugin name and version. */
  420. $action_links[] = '<a class="install-now button" data-slug="' . esc_attr( $plugin['slug'] ) . '" href="' . esc_url( $status['url'] ) . '" aria-label="' . esc_attr( sprintf( __( 'Install %s now' ), $name ) ) . '" data-name="' . esc_attr( $name ) . '">' . __( 'Install Now' ) . '</a>';
  421. }
  422. break;
  423. case 'update_available':
  424. if ( $status['url'] ) {
  425. /* translators: 1: Plugin name and version */
  426. $action_links[] = '<a class="update-now button aria-button-if-js" data-plugin="' . esc_attr( $status['file'] ) . '" data-slug="' . esc_attr( $plugin['slug'] ) . '" href="' . esc_url( $status['url'] ) . '" aria-label="' . esc_attr( sprintf( __( 'Update %s now' ), $name ) ) . '" data-name="' . esc_attr( $name ) . '">' . __( 'Update Now' ) . '</a>';
  427. }
  428. break;
  429. case 'latest_installed':
  430. case 'newer_installed':
  431. if ( is_plugin_active( $status['file'] ) ) {
  432. $action_links[] = '<button type="button" class="button button-disabled" disabled="disabled">' . _x( 'Active', 'plugin' ) . '</button>';
  433. } elseif ( current_user_can( 'activate_plugin', $status['file'] ) ) {
  434. $button_text = __( 'Activate' );
  435. /* translators: %s: Plugin name */
  436. $button_label = _x( 'Activate %s', 'plugin' );
  437. $activate_url = add_query_arg( array(
  438. '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $status['file'] ),
  439. 'action' => 'activate',
  440. 'plugin' => $status['file'],
  441. ), network_admin_url( 'plugins.php' ) );
  442. if ( is_network_admin() ) {
  443. $button_text = __( 'Network Activate' );
  444. /* translators: %s: Plugin name */
  445. $button_label = _x( 'Network Activate %s', 'plugin' );
  446. $activate_url = add_query_arg( array( 'networkwide' => 1 ), $activate_url );
  447. }
  448. $action_links[] = sprintf(
  449. '<a href="%1$s" class="button activate-now" aria-label="%2$s">%3$s</a>',
  450. esc_url( $activate_url ),
  451. esc_attr( sprintf( $button_label, $plugin['name'] ) ),
  452. $button_text
  453. );
  454. } else {
  455. $action_links[] = '<button type="button" class="button button-disabled" disabled="disabled">' . _x( 'Installed', 'plugin' ) . '</button>';
  456. }
  457. break;
  458. }
  459. }
  460. $details_link = self_admin_url( 'plugin-install.php?tab=plugin-information&amp;plugin=' . $plugin['slug'] .
  461. '&amp;TB_iframe=true&amp;width=600&amp;height=550' );
  462. /* translators: 1: Plugin name and version. */
  463. $action_links[] = '<a href="' . esc_url( $details_link ) . '" class="thickbox open-plugin-details-modal" aria-label="' . esc_attr( sprintf( __( 'More information about %s' ), $name ) ) . '" data-title="' . esc_attr( $name ) . '">' . __( 'More Details' ) . '</a>';
  464. if ( !empty( $plugin['icons']['svg'] ) ) {
  465. $plugin_icon_url = $plugin['icons']['svg'];
  466. } elseif ( !empty( $plugin['icons']['2x'] ) ) {
  467. $plugin_icon_url = $plugin['icons']['2x'];
  468. } elseif ( !empty( $plugin['icons']['1x'] ) ) {
  469. $plugin_icon_url = $plugin['icons']['1x'];
  470. } else {
  471. $plugin_icon_url = $plugin['icons']['default'];
  472. }
  473. /**
  474. * Filters the install action links for a plugin.
  475. *
  476. * @since 2.7.0
  477. *
  478. * @param array $action_links An array of plugin action hyperlinks. Defaults are links to Details and Install Now.
  479. * @param array $plugin The plugin currently being listed.
  480. */
  481. $action_links = apply_filters( 'plugin_install_action_links', $action_links, $plugin );
  482. $last_updated_timestamp = strtotime( $plugin['last_updated'] );
  483. ?>
  484. <div class="plugin-card plugin-card-<?php echo sanitize_html_class( $plugin['slug'] ); ?>">
  485. <div class="plugin-card-top">
  486. <div class="name column-name">
  487. <h3>
  488. <a href="<?php echo esc_url( $details_link ); ?>" class="thickbox open-plugin-details-modal">
  489. <?php echo $title; ?>
  490. <img src="<?php echo esc_attr( $plugin_icon_url ) ?>" class="plugin-icon" alt="">
  491. </a>
  492. </h3>
  493. </div>
  494. <div class="action-links">
  495. <?php
  496. if ( $action_links ) {
  497. echo '<ul class="plugin-action-buttons"><li>' . implode( '</li><li>', $action_links ) . '</li></ul>';
  498. }
  499. ?>
  500. </div>
  501. <div class="desc column-description">
  502. <p><?php echo $description; ?></p>
  503. <p class="authors"><?php echo $author; ?></p>
  504. </div>
  505. </div>
  506. <div class="plugin-card-bottom">
  507. <div class="vers column-rating">
  508. <?php wp_star_rating( array( 'rating' => $plugin['rating'], 'type' => 'percent', 'number' => $plugin['num_ratings'] ) ); ?>
  509. <span class="num-ratings" aria-hidden="true">(<?php echo number_format_i18n( $plugin['num_ratings'] ); ?>)</span>
  510. </div>
  511. <div class="column-updated">
  512. <strong><?php _e( 'Last Updated:' ); ?></strong> <?php printf( __( '%s ago' ), human_time_diff( $last_updated_timestamp ) ); ?>
  513. </div>
  514. <div class="column-downloaded">
  515. <?php
  516. if ( $plugin['active_installs'] >= 1000000 ) {
  517. $active_installs_text = _x( '1+ Million', 'Active plugin installations' );
  518. } elseif ( 0 == $plugin['active_installs'] ) {
  519. $active_installs_text = _x( 'Less Than 10', 'Active plugin installations' );
  520. } else {
  521. $active_installs_text = number_format_i18n( $plugin['active_installs'] ) . '+';
  522. }
  523. printf( __( '%s Active Installations' ), $active_installs_text );
  524. ?>
  525. </div>
  526. <div class="column-compatibility">
  527. <?php
  528. $wp_version = get_bloginfo( 'version' );
  529. if ( ! empty( $plugin['tested'] ) && version_compare( substr( $wp_version, 0, strlen( $plugin['tested'] ) ), $plugin['tested'], '>' ) ) {
  530. echo '<span class="compatibility-untested">' . __( 'Untested with your version of WordPress' ) . '</span>';
  531. } elseif ( ! empty( $plugin['requires'] ) && version_compare( substr( $wp_version, 0, strlen( $plugin['requires'] ) ), $plugin['requires'], '<' ) ) {
  532. echo '<span class="compatibility-incompatible">' . __( '<strong>Incompatible</strong> with your version of WordPress' ) . '</span>';
  533. } else {
  534. echo '<span class="compatibility-compatible">' . __( '<strong>Compatible</strong> with your version of WordPress' ) . '</span>';
  535. }
  536. ?>
  537. </div>
  538. </div>
  539. </div>
  540. <?php
  541. }
  542. // Close off the group divs of the last one
  543. if ( ! empty( $group ) ) {
  544. echo '</div></div>';
  545. }
  546. }
  547. }