class.jetpack-geo-location.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. <?php
  2. /**
  3. * Adds support for geo-location features.
  4. *
  5. * All Jetpack sites can support geo-location features. Users can tag posts with geo-location data
  6. * using the UI provided by Calypso. That information will be included in RSS feeds, meta tags during
  7. * wp_head, and in the Geo microformat following post content.
  8. *
  9. * If your theme declares support for "geo-location", you'll also get a small icon and location label
  10. * visible to users at the bottom of single posts and pages.
  11. *
  12. * To declare support in your theme, call `add_theme_support( 'jetpack-geo-location' )`.
  13. *
  14. * Once you've added theme support, you can rely on the standard HTML output generated in the
  15. * the_content_location_display() method of this class. Or, you can use the "geo_location_display"
  16. * filter to generate custom HTML for your particular theme. Your filter function will receive an
  17. * the default HTML as its first argument and an array containing the geo-location information as
  18. * its second argument in the following format:
  19. *
  20. * array(
  21. * 'is_public' => boolean,
  22. * 'latitude' => float,
  23. * 'longitude' => float,
  24. * 'label' => string,
  25. * 'is_populated' => boolean
  26. * )
  27. *
  28. * Add your filter with:
  29. *
  30. * add_filter( 'jetpack_geo_location_display', 'your_filter_function_name', 10, 2);
  31. */
  32. class Jetpack_Geo_Location {
  33. private static $instance;
  34. public static function init() {
  35. if ( is_null( self::$instance ) ) {
  36. self::$instance = new Jetpack_Geo_Location();
  37. }
  38. return self::$instance;
  39. }
  40. /**
  41. * This is mostly just used for testing purposes.
  42. */
  43. public static function reset_instance() {
  44. self::$instance = null;
  45. }
  46. public function __construct() {
  47. add_action( 'init', array( $this, 'wordpress_init' ) );
  48. add_action( 'wp_head', array( $this, 'wp_head' ) );
  49. add_filter( 'the_content', array( $this, 'the_content_microformat' ) );
  50. add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
  51. $this->register_rss_hooks();
  52. }
  53. /**
  54. * Register support for the geo-location feature on pages and posts. Register the meta
  55. * fields managed by this plugin so that they are properly sanitized during save.
  56. */
  57. public function wordpress_init() {
  58. // Only render location label after post content, if the theme claims to support "geo-location".
  59. if ( current_theme_supports( 'jetpack-geo-location' ) ) {
  60. add_filter( 'the_content', array( $this, 'the_content_location_display' ), 15, 1 );
  61. }
  62. add_post_type_support( 'post', 'geo-location' );
  63. add_post_type_support( 'page', 'geo-location' );
  64. register_meta(
  65. 'post',
  66. 'geo_public',
  67. array(
  68. 'sanitize_callback' => array( $this, 'sanitize_public' ),
  69. 'type' => 'boolean',
  70. 'single' => true,
  71. )
  72. );
  73. register_meta(
  74. 'post',
  75. 'geo_latitude',
  76. array(
  77. 'sanitize_callback' => array( $this, 'sanitize_coordinate' ),
  78. 'type' => 'float',
  79. 'single' => true,
  80. )
  81. );
  82. register_meta(
  83. 'post',
  84. 'geo_longitude',
  85. array(
  86. 'sanitize_callback' => array( $this, 'sanitize_coordinate' ),
  87. 'type' => 'float',
  88. 'single' => true,
  89. )
  90. );
  91. register_meta(
  92. 'post',
  93. 'geo_address',
  94. array(
  95. 'sanitize_callback' => 'sanitize_text_field',
  96. 'type' => 'string',
  97. 'single' => true,
  98. )
  99. );
  100. }
  101. /**
  102. * Filter "public" input to always be either 1 or 0.
  103. *
  104. * @param mixed $public
  105. *
  106. * @return int
  107. */
  108. public function sanitize_public( $public ) {
  109. return absint( $public ) ? 1 : 0;
  110. }
  111. /**
  112. * Filter geo coordinates and normalize them to floats with 7 digits of precision.
  113. *
  114. * @param mixed $coordinate
  115. *
  116. * @return float|null
  117. */
  118. public function sanitize_coordinate( $coordinate ) {
  119. if ( ! $coordinate ) {
  120. return null;
  121. }
  122. return round( (float) $coordinate, 7 );
  123. }
  124. /**
  125. * Render geo.position and ICBM meta tags with public geo meta values when rendering
  126. * a single post.
  127. */
  128. public function wp_head() {
  129. if ( ! is_single() ) {
  130. return;
  131. }
  132. $meta_values = $this->get_meta_values( $this->get_post_id() );
  133. if ( ! $meta_values['is_public'] ) {
  134. return;
  135. }
  136. echo "\n<!-- Jetpack Geo-location Tags -->\n";
  137. if ( $meta_values['label'] ) {
  138. printf(
  139. '<meta name="geo.placename" content="%s" />',
  140. esc_attr( $meta_values['label'] )
  141. );
  142. }
  143. printf(
  144. '<meta name="geo.position" content="%s;%s" />' . PHP_EOL,
  145. esc_attr( $meta_values['latitude'] ),
  146. esc_attr( $meta_values['longitude'] )
  147. );
  148. printf(
  149. '<meta name="ICBM" content="%s, %s" />' . PHP_EOL,
  150. esc_attr( $meta_values['latitude'] ),
  151. esc_attr( $meta_values['longitude'] )
  152. );
  153. echo "\n<!-- End Jetpack Geo-location Tags -->\n";
  154. }
  155. /**
  156. * Append public meta values in the Geo microformat (https://en.wikipedia.org/wiki/Geo_(microformat)
  157. * to the supplied content.
  158. *
  159. * Note that we cannot render the microformat in the context of an excerpt because tags are stripped
  160. * in that context, making our microformat data visible.
  161. *
  162. * @param string $content
  163. *
  164. * @return string
  165. */
  166. public function the_content_microformat( $content ) {
  167. if ( is_feed() || $this->is_currently_excerpt_filter() ) {
  168. return $content;
  169. }
  170. $meta_values = $this->get_meta_values( $this->get_post_id() );
  171. if ( ! $meta_values['is_public'] ) {
  172. return $content;
  173. }
  174. $microformat = sprintf(
  175. '<div id="geo-post-%d" class="geo geo-post" style="display: none">',
  176. esc_attr( $this->get_post_id() )
  177. );
  178. $microformat .= sprintf(
  179. '<span class="latitude">%s</span>',
  180. esc_html( $meta_values['latitude'] )
  181. );
  182. $microformat .= sprintf(
  183. '<span class="longitude">%s</span>',
  184. esc_html( $meta_values['longitude'] )
  185. );
  186. $microformat .= '</div>';
  187. return $content . $microformat;
  188. }
  189. /**
  190. * Register a range of hooks for integrating geo data with various feeds.
  191. */
  192. public function register_rss_hooks() {
  193. add_action( 'rss2_ns', array( $this, 'rss_namespace' ) );
  194. add_action( 'atom_ns', array( $this, 'rss_namespace' ) );
  195. add_action( 'rdf_ns', array( $this, 'rss_namespace' ) );
  196. add_action( 'rss_item', array( $this, 'rss_item' ) );
  197. add_action( 'rss2_item', array( $this, 'rss_item' ) );
  198. add_action( 'atom_entry', array( $this, 'rss_item' ) );
  199. add_action( 'rdf_item', array( $this, 'rss_item' ) );
  200. }
  201. /**
  202. * Add the georss namespace during RSS generation.
  203. */
  204. public function rss_namespace() {
  205. echo PHP_EOL . 'xmlns:georss="http://www.georss.org/georss" xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#"' . PHP_EOL;
  206. }
  207. /**
  208. * Output georss data for RSS items, assuming we have data for the currently rendered post and
  209. * that data as marked as public.
  210. */
  211. public function rss_item() {
  212. $meta_values = $this->get_meta_values( $this->get_post_id() );
  213. if ( ! $meta_values['is_public'] ) {
  214. return;
  215. }
  216. printf(
  217. "\t<georss:point>%s %s</georss:point>\n",
  218. ent2ncr( esc_html( $meta_values['latitude'] ) ),
  219. ent2ncr( esc_html( $meta_values['longitude'] ) )
  220. );
  221. printf( "\t\t<geo:lat>%s</geo:lat>\n", ent2ncr( esc_html( $meta_values['latitude'] ) ) );
  222. printf( "\t\t<geo:long>%s</geo:long>\n", ent2ncr( esc_html( $meta_values['longitude'] ) ) );
  223. }
  224. /**
  225. * Enqueue CSS for rendering post flair with geo-location.
  226. */
  227. public function enqueue_scripts() {
  228. wp_enqueue_style( 'dashicons' );
  229. }
  230. /**
  231. * If we're rendering a single post and public geo-location data is available for it,
  232. * include the human-friendly location label in the output.
  233. *
  234. * @param string $content
  235. *
  236. * @return string
  237. */
  238. public function the_content_location_display( $content ) {
  239. if ( ! is_single() ) {
  240. return $content;
  241. }
  242. return $content . $this->get_location_label();
  243. }
  244. /**
  245. * Get the HTML for displaying a label representing the location associated with the
  246. * supplied post ID. If no post ID is given, we'll use the global $post variable, if
  247. * it is available.
  248. *
  249. * @param integer|null $post_id
  250. *
  251. * @return string
  252. */
  253. public function get_location_label( $post_id = null ) {
  254. $meta_values = $this->get_meta_values( $post_id ? $post_id : $this->get_post_id() );
  255. if ( ! $meta_values['is_public'] ) {
  256. return '';
  257. }
  258. // If the location has not been labeled, do not show the location.
  259. if ( ! $meta_values['label'] ) {
  260. return '';
  261. }
  262. $html = '<div class="post-geo-location-label geo-chip">';
  263. $html .= '<span class="dashicons dashicons-location" style="vertical-align: text-top;"></span> ';
  264. $html .= esc_html( $meta_values['label'] );
  265. $html .= '</div>';
  266. /**
  267. * Allow modification or replacement of the default geo-location display HTML.
  268. *
  269. * @module geo-location
  270. *
  271. * @param array $html The default HTML for displaying a geo-location label.
  272. * @param array $geo_data An array containing "latitude", "longitude" and "label".
  273. */
  274. $html = apply_filters( 'jetpack_geo_location_display', $html, $meta_values );
  275. return $html;
  276. }
  277. /**
  278. * Get the ID of the current global post object, if available. Otherwise, return null.
  279. *
  280. * This isolates the access of the global scope to this single method, making it easier to
  281. * safeguard against unexpected missing $post objects in other hook functions.
  282. *
  283. * @return int|null
  284. */
  285. public function get_post_id() {
  286. global $post;
  287. if ( ! isset( $post ) || ! $post || ! is_object( $post ) || ! isset( $post->ID ) ) {
  288. return null;
  289. }
  290. return $post->ID;
  291. }
  292. /**
  293. * This method always returns an array with the following structure:
  294. *
  295. * array(is_public => bool, latitude => float, longitude => float, label => string, is_populated => bool)
  296. *
  297. * So, regardless of whether your post actually has values in postmeta for the geo-location fields,
  298. * you can be sure that you can reference those array keys in calling code without having to juggle
  299. * isset(), array_key_exists(), etc.
  300. *
  301. * Mocking this method during testing can also be useful for testing output and logic in various
  302. * hook functions.
  303. *
  304. * @param integer $post_id
  305. *
  306. * @return array A predictably structured array representing the meta values for the supplied post ID.
  307. */
  308. public function get_meta_values( $post_id ) {
  309. $meta_values = array(
  310. 'is_public' => (bool) $this->sanitize_public( $this->get_meta_value( $post_id, 'public' ) ),
  311. 'latitude' => $this->sanitize_coordinate( $this->get_meta_value( $post_id, 'latitude' ) ),
  312. 'longitude' => $this->sanitize_coordinate( $this->get_meta_value( $post_id, 'longitude' ) ),
  313. 'label' => trim( $this->get_meta_value( $post_id, 'address' ) ),
  314. 'is_populated' => false,
  315. );
  316. if ( $meta_values['latitude'] && $meta_values['longitude'] && $meta_values['label'] ) {
  317. $meta_values['is_populated'] = true;
  318. }
  319. return $meta_values;
  320. }
  321. /**
  322. * This function wraps get_post_meta() to enable us to keep the "geo_" prefix isolated to a single
  323. * location in the code and to assist in mocking during testing.
  324. *
  325. * @param integer $post_id
  326. * @param string $meta_field_name
  327. *
  328. * @return mixed
  329. */
  330. public function get_meta_value( $post_id, $meta_field_name ) {
  331. if ( ! $post_id ) {
  332. return null;
  333. }
  334. return get_post_meta( $post_id, 'geo_' . $meta_field_name, true );
  335. }
  336. /**
  337. * Check to see if the current filter is the get_the_excerpt filter.
  338. *
  339. * Just checking current_filter() here is not adequate because current_filter() only looks
  340. * at the last element in the $wp_current_filter array. In the context of rendering an
  341. * excerpt, however, both get_the_excerpt and the_content are present in that array.
  342. *
  343. * @return bool
  344. */
  345. public function is_currently_excerpt_filter() {
  346. if ( ! isset( $GLOBALS['wp_current_filter'] ) ) {
  347. return false;
  348. }
  349. $current_filters = (array) $GLOBALS['wp_current_filter'];
  350. return in_array( 'get_the_excerpt', $current_filters, true );
  351. }
  352. }
  353. Jetpack_Geo_Location::init();