PluginProbe
Edit Flow / 0.11.0
Edit Flow v0.11.0
0.11.1 0.11.0 0.7.2 0.7.3 0.7.4 0.7.5 0.7.6 0.8 0.8.1 0.8.2 0.9 0.9.1 0.9.2 0.9.3 0.9.4 0.9.5 0.9.6 0.9.7 0.9.8 0.9.9 trunk 0.1.5 0.10.0 0.10.1 0.10.2 All 44 releases
edit-flow / modules / calendar / calendar.php

calendar.php in Edit Flow 0.11.0, at modules/calendar/calendar.php

2,154 lines 83.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Calendar module for Edit Flow.
4 *
5 * This class displays an editorial calendar for viewing upcoming and past content at a glance.
6 *
7 * @package EditFlow
8 */
9
10 if ( ! class_exists( 'EF_Calendar' ) ) {
11
12 /**
13 * Calendar module class.
14 *
15 * Displays an editorial calendar for viewing upcoming and past content at a glance.
16 */
17 class EF_Calendar extends EF_Module {
18
19 // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
20 const usermeta_key_prefix = 'ef_calendar_';
21 // phpcs:ignore Generic.NamingConventions.UpperCaseConstantName.ClassConstantNotUpperCase
22 const screen_id = 'dashboard_page_calendar';
23
24 /**
25 * Module instance.
26 *
27 * @var object
28 */
29 public $module;
30
31 /**
32 * Start date for the calendar view.
33 *
34 * @var string
35 */
36 public $start_date = '';
37
38 /**
39 * Current week number.
40 *
41 * @var int
42 */
43 public $current_week = 1;
44
45 /**
46 * Default number of weeks to show per screen.
47 *
48 * @var int
49 */
50 public $total_weeks = 6;
51
52 /**
53 * Counter of hidden posts per date square.
54 *
55 * @var int
56 */
57 public $hidden = 0;
58
59 /**
60 * Total number of posts to be shown per square before 'more' link.
61 *
62 * @var int
63 */
64 public $max_visible_posts_per_date = 4;
65
66 /**
67 * Cache for post dates.
68 *
69 * @var array
70 */
71 private $post_date_cache = array();
72
73 /**
74 * Maximum weeks to show.
75 *
76 * @var int
77 */
78 private int $max_weeks;
79
80 /**
81 * Capability required to create posts.
82 *
83 * @var string
84 */
85 private string $create_post_cap;
86
87 /**
88 * Calendar published statuses.
89 *
90 * Same as other components but without the future status.
91 *
92 * @var array
93 */
94 public $published_statuses = array(
95 'publish',
96 'private',
97 );
98
99 /**
100 * Construct the EF_Calendar class
101 */
102 public function __construct() {
103 $this->max_weeks = 12;
104
105 $this->module_url = $this->get_module_url( __FILE__ );
106 // Register the module with Edit Flow.
107 $args = array(
108 'title' => __( 'Calendar', 'edit-flow' ),
109 /* translators: %s: URL to the calendar page */
110 'short_description' => sprintf( __( 'View upcoming content in a <a href="%s">customizable calendar</a>.', 'edit-flow' ), admin_url( 'index.php?page=calendar' ) ),
111 'extended_description' => __( 'Edit Flow’s calendar lets you see your posts over a customizable date range. Filter by status or click on the post title to see its details. Drag and drop posts between days to change their publication date.', 'edit-flow' ),
112 'module_url' => $this->module_url,
113 'img_url' => $this->module_url . 'lib/calendar_s128.png',
114 'slug' => 'calendar',
115 'post_type_support' => 'ef_calendar',
116 'default_options' => array(
117 'enabled' => 'on',
118 'post_types' => array(
119 'post' => 'on',
120 'page' => 'off',
121 ),
122 'quick_create_post_type' => 'post',
123 'ics_subscription' => 'off',
124 'ics_secret_key' => '',
125 ),
126 'messages' => array(
127 'post-date-updated' => __( 'Post date updated.', 'edit-flow' ),
128 'update-error' => __( 'There was an error updating the post. Please try again.', 'edit-flow' ),
129 /* translators: %s: URL to the published post */
130 'published-post-ajax' => __( "Updating the post date dynamically doesn't work for published content. Please <a href='%s'>edit the post</a>.", 'edit-flow' ),
131 'key-regenerated' => __( 'Your iCal feed URL has been regenerated. Re-copy it from Screen Options on the Calendar.', 'edit-flow' ),
132 ),
133 'configure_page_cb' => 'print_configure_view',
134 'configure_link_text' => __( 'Calendar Options', 'edit-flow' ),
135 'settings_help_tab' => array(
136 'id' => 'ef-calendar-overview',
137 'title' => __( 'Overview', 'edit-flow' ),
138 // phpcs:ignore WordPress.WP.I18n.NoHtmlWrappedStrings -- HTML is intentional for help tab content.
139 'content' => __( '<p>The calendar is a convenient week-by-week or month-by-month view into your content. Quickly see which stories are on track to being published on time, and which will need extra effort.</p>', 'edit-flow' ),
140 ),
141 'settings_help_sidebar' => __( '<p><strong>For more information:</strong></p><p><a href="https://editflow.org/features/calendar/">Calendar Documentation</a></p><p><a href="https://wordpress.org/support/plugin/edit-flow/">Edit Flow Forum</a></p><p><a href="https://github.com/Automattic/Edit-Flow">Edit Flow on GitHub</a></p>', 'edit-flow' ),
142 );
143 $this->module = EditFlow()->register_module( 'calendar', $args );
144 }
145
146 /**
147 * Initialize all of our methods and such. Only runs if the module is active
148 *
149 * @uses add_action()
150 */
151 public function init() {
152
153 // .ics calendar subscriptions.
154 add_action( 'wp_ajax_ef_calendar_ics_subscription', array( $this, 'handle_ics_subscription' ) );
155 add_action( 'wp_ajax_nopriv_ef_calendar_ics_subscription', array( $this, 'handle_ics_subscription' ) );
156
157 // Check whether the user should have the ability to view the calendar.
158 $view_calendar_cap = 'ef_view_calendar';
159 $view_calendar_cap = apply_filters( 'ef_view_calendar_cap', $view_calendar_cap );
160 if ( ! current_user_can( $view_calendar_cap ) ) {
161 return false;
162 }
163
164 // Define the create-post capability from the configured quick-create post type,
165 // rather than a generic 'edit_posts', so the check matches the post type actually
166 // being created. Falls back to 'edit_posts' if the type isn't registered yet.
167 $quick_create_type = $this->module->options->quick_create_post_type;
168 $quick_create_type_obj = get_post_type_object( $quick_create_type );
169 $create_post_cap = ( $quick_create_type_obj && ! empty( $quick_create_type_obj->cap->create_posts ) ) ? $quick_create_type_obj->cap->create_posts : 'edit_posts';
170 $this->create_post_cap = apply_filters( 'ef_calendar_create_post_cap', $create_post_cap );
171
172 add_action( 'admin_init', array( $this, 'add_screen_options_panel' ) );
173 add_action( 'admin_init', array( $this, 'handle_save_screen_options' ) );
174
175 add_action( 'admin_init', array( $this, 'register_settings' ) );
176 add_action( 'admin_menu', array( $this, 'action_admin_menu' ) );
177 add_action( 'admin_print_styles', array( $this, 'add_admin_styles' ) );
178 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
179
180 // Ajax manipulation for the calendar.
181 add_action( 'wp_ajax_ef_calendar_drag_and_drop', array( $this, 'handle_ajax_drag_and_drop' ) );
182
183 // Ajax insert post placeholder for a specific date.
184 add_action( 'wp_ajax_ef_insert_post', array( $this, 'handle_ajax_insert_post' ) );
185
186 // Update metadata.
187 add_action( 'wp_ajax_ef_calendar_update_metadata', array( $this, 'handle_ajax_update_metadata' ) );
188
189 // Action to regenerate the calendar feed secret.
190 add_action( 'admin_init', array( $this, 'handle_regenerate_calendar_feed_secret' ) );
191
192 // Hacks to fix deficiencies in core.
193 add_action( 'pre_post_update', array( $this, 'fix_post_date_on_update_part_one' ), 10, 2 );
194 add_action( 'post_updated', array( $this, 'fix_post_date_on_update_part_two' ), 10, 3 );
195 }
196
197 /**
198 * Load the capabilities onto users the first time the module is run
199 *
200 * @since 0.7
201 */
202 public function install() {
203
204 // Add necessary capabilities to allow management of calendar.
205 // Adds view_calendar capability from administrator to contributor.
206 $calendar_roles = array(
207 'administrator' => array( 'ef_view_calendar' ),
208 'editor' => array( 'ef_view_calendar' ),
209 'author' => array( 'ef_view_calendar' ),
210 'contributor' => array( 'ef_view_calendar' ),
211 );
212
213 foreach ( $calendar_roles as $role => $caps ) {
214 $this->add_caps_to_role( $role, $caps );
215 }
216 }
217
218 /**
219 * Upgrade our data in case we need to.
220 *
221 * @since 0.7
222 *
223 * @param string $previous_version Previous plugin version.
224 */
225 public function upgrade( $previous_version ) {
226 global $edit_flow;
227
228 // Upgrade path to v0.7.
229 if ( version_compare( $previous_version, '0.7', '<' ) ) {
230 // Migrate whether the calendar was enabled or not and clean up old option.
231 $enabled = get_option( 'edit_flow_calendar_enabled' );
232 if ( $enabled ) {
233 $enabled = 'on';
234 } else {
235 $enabled = 'off';
236 }
237 $edit_flow->update_module_option( $this->module->name, 'enabled', $enabled );
238 delete_option( 'edit_flow_calendar_enabled' );
239
240 // Technically we've run this code before so we don't want to auto-install new data.
241 $edit_flow->update_module_option( $this->module->name, 'loaded_once', true );
242 }
243 }
244
245 /**
246 * Add the calendar link underneath the "Dashboard"
247 *
248 * @uses add_submenu_page
249 */
250 public function action_admin_menu() {
251 add_submenu_page( 'index.php', __( 'Calendar', 'edit-flow' ), __( 'Calendar', 'edit-flow' ), apply_filters( 'ef_view_calendar_cap', 'ef_view_calendar' ), $this->module->slug, array( $this, 'view_calendar' ) );
252 }
253
254 /**
255 * Add any necessary CSS to the WordPress admin
256 *
257 * @uses wp_enqueue_style()
258 */
259 public function add_admin_styles() {
260 global $pagenow;
261 // Only load calendar styles on the calendar page.
262 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking page name, not processing data.
263 if ( 'index.php' === $pagenow && isset( $_GET['page'] ) && 'calendar' === $_GET['page'] ) {
264 wp_enqueue_style( 'edit-flow-calendar-css', $this->module_url . 'lib/calendar.css', false, EDIT_FLOW_VERSION );
265
266 $asset_file = EDIT_FLOW_ROOT . '/build/calendar-react.asset.php';
267 $asset = file_exists( $asset_file ) ? require $asset_file : [
268 'dependencies' => [],
269 'version' => EDIT_FLOW_VERSION,
270 ];
271
272 wp_enqueue_style(
273 'edit-flow-calendar-react-css',
274 EDIT_FLOW_URL . 'build/calendar-react.css',
275 [ 'wp-components' ],
276 $asset['version']
277 );
278 }
279 }
280
281 /**
282 * Add any necessary JS to the WordPress admin
283 *
284 * @since 0.7
285 * @uses wp_enqueue_script()
286 */
287 public function enqueue_admin_scripts() {
288 global $pagenow;
289
290 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking page name, not processing data.
291 if ( 'index.php' === $pagenow && isset( $_GET['page'] ) && 'calendar' === $_GET['page'] ) {
292 $this->enqueue_datepicker_resources();
293
294 /**
295 * Powering the new React interface.
296 * Must be enqueued first because it registers the 'edit-flow/calendar' data store
297 * that calendar.js depends on for drag-and-drop functionality.
298 */
299 $asset_file = EDIT_FLOW_ROOT . '/build/calendar-react.asset.php';
300 $asset = file_exists( $asset_file ) ? require $asset_file : [
301 'dependencies' => [],
302 'version' => EDIT_FLOW_VERSION,
303 ];
304
305 wp_enqueue_script(
306 'edit-flow-calendar-react-js',
307 EDIT_FLOW_URL . 'build/calendar-react.js',
308 $asset['dependencies'],
309 $asset['version'],
310 true
311 );
312
313 $js_libraries = array(
314 'jquery',
315 'jquery-ui-core',
316 'jquery-ui-sortable',
317 'jquery-ui-draggable',
318 'jquery-ui-droppable',
319 'wp-data',
320 'edit-flow-calendar-react-js', // Required for the 'edit-flow/calendar' data store.
321 );
322 foreach ( $js_libraries as $js_library ) {
323 wp_enqueue_script( $js_library );
324 }
325 wp_enqueue_script( 'edit-flow-calendar-js', $this->module_url . 'lib/calendar.js', $js_libraries, EDIT_FLOW_VERSION, true );
326
327 $ef_cal_js_params = array( 'can_add_posts' => current_user_can( $this->create_post_cap ) ? 'true' : 'false' );
328 wp_localize_script( 'edit-flow-calendar-js', 'ef_calendar_params', $ef_cal_js_params );
329
330 wp_add_inline_script(
331 'edit-flow-calendar-react-js',
332 'var EF_CALENDAR = ' . wp_json_encode( $this->get_calendar_frontend_config() ),
333 'before'
334 );
335 }
336 }
337
338 /**
339 * Prepare the options that need to appear in Screen Options
340 *
341 * @since 0.7
342 */
343 public function generate_screen_options() {
344
345 $output = '';
346
347 $current_user = wp_get_current_user();
348 $args = array(
349 'action' => 'ef_calendar_ics_subscription',
350 'user' => $current_user->user_login,
351 'user_key' => $this->get_user_ics_secret( $current_user->ID ),
352 );
353 $subscription_link = add_query_arg( $args, admin_url( 'admin-ajax.php' ) );
354 $output .= '<br />';
355 $output .= __( 'Subscribe in iCal or Google Calendar', 'edit-flow' );
356 $output .= ':<br /><input type="text" size="100" value="' . esc_attr( $subscription_link ) . '" />';
357
358 return $output;
359 }
360
361 /**
362 * Get the current user's personal .ics feed secret, creating one on first use.
363 *
364 * The secret is stored per user and is independently revocable, so a leaked feed URL
365 * exposes only that user's calendar view and can be rotated without affecting anyone else.
366 *
367 * @param int $user_id The user to fetch the secret for.
368 * @return string The per-user feed secret.
369 */
370 private function get_user_ics_secret( $user_id ) {
371 $meta_key = self::usermeta_key_prefix . 'ics_secret';
372 $secret = (string) $this->get_user_meta( $user_id, $meta_key, true );
373 if ( '' === $secret ) {
374 $secret = wp_generate_password( 32, false );
375 $this->update_user_meta( $user_id, $meta_key, $secret );
376 }
377 return $secret;
378 }
379
380 /**
381 * Add module options to the screen panel
382 *
383 * @since 0.8.3
384 */
385 public function add_screen_options_panel() {
386 require_once EDIT_FLOW_ROOT . '/common/php/screen-options.php';
387 if ( 'on' == $this->module->options->ics_subscription ) {
388 add_screen_options_panel( self::usermeta_key_prefix . 'screen_options', __( 'Calendar Options', 'edit-flow' ), array( $this, 'generate_screen_options' ), self::screen_id, false, true );
389 }
390 }
391
392 /**
393 * Handle the request to save the screen options
394 *
395 * @since 0.7
396 */
397 public function handle_save_screen_options() {
398 // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified below.
399
400 // Only handle screen options submissions from the current screen.
401 if ( ! isset( $_POST['screen-options-apply'] ) ) {
402 return;
403 }
404
405 // phpcs:enable WordPress.Security.NonceVerification.Missing
406
407 // Nonce check.
408 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
409 if ( ! isset( $_POST[ '_wpnonce-' . self::usermeta_key_prefix . 'screen_options' ] ) || ! wp_verify_nonce( $_POST[ '_wpnonce-' . self::usermeta_key_prefix . 'screen_options' ], 'save_settings-' . self::usermeta_key_prefix . 'screen_options' ) ) {
410 wp_die( esc_html( $this->module->messages['nonce-failed'] ) );
411 }
412
413 // Get the current screen options.
414 $screen_options = $this->get_screen_options();
415
416 // Save the screen options.
417 $current_user = wp_get_current_user();
418 $this->update_user_meta( $current_user->ID, self::usermeta_key_prefix . 'screen_options', $screen_options );
419
420 // Redirect after we're complete.
421 $redirect_to = menu_page_url( $this->module->slug, false );
422 // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Redirect URL is constructed internally.
423 wp_redirect( $redirect_to );
424 exit;
425 }
426
427 /**
428 * Handle an AJAX request from the calendar to update a post's timestamp.
429 * Notes:
430 * - For Post Time, if the post is unpublished, the change sets the publication timestamp
431 * - If the post was published or scheduled for the future, the change will change the timestamp. 'publish' posts
432 * will become scheduled if moved past today and 'future' posts will be published if moved before today
433 * - Need to respect user permissions. Editors can move all, authors can move their own, and contributors can't move at all
434 *
435 * @since 0.7
436 */
437 public function handle_ajax_drag_and_drop() {
438 global $wpdb;
439
440 // Nonce check.
441 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'ef-calendar-modify' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
442 $this->print_ajax_response( 'error', $this->module->messages['nonce-failed'] );
443 }
444
445 if ( ! isset( $_POST['post_id'] ) ) {
446 $this->print_ajax_response( 'error', $this->module->messages['missing-post'] );
447 }
448
449 // Check that we got a proper post.
450 $post_id = (int) $_POST['post_id'];
451 $post = get_post( $post_id );
452 if ( ! $post ) {
453 $this->print_ajax_response( 'error', $this->module->messages['missing-post'] );
454 }
455
456 // Check that the user can modify the post.
457 if ( ! $this->current_user_can_modify_post( $post ) ) {
458 $this->print_ajax_response( 'error', $this->module->messages['invalid-permissions'] );
459 }
460
461 // Check that it's not yet published.
462 if ( in_array( $post->post_status, $this->published_statuses ) ) {
463 $this->print_ajax_response( 'error', sprintf( $this->module->messages['published-post-ajax'], get_edit_post_link( $post_id ) ) );
464 }
465
466 if ( ! isset( $_POST['next_date'] ) ) {
467 $this->print_ajax_response( 'error', __( 'Missing new date.', 'edit-flow' ) );
468 }
469
470 // Check that the new date passed is a valid one.
471 $next_date_full = strtotime( $_POST['next_date'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Used with strtotime() for date parsing only.
472 if ( ! $next_date_full ) {
473 $this->print_ajax_response( 'error', __( 'Something is wrong with the format for the new date.', 'edit-flow' ) );
474 }
475
476 // Persist the old hourstamp because we can't manipulate the exact time on the calendar.
477 // Bump the last modified timestamps too.
478 $existing_time = date( 'H:i:s', strtotime( $post->post_date ) );
479 $existing_time_gmt = date( 'H:i:s', strtotime( $post->post_date_gmt ) );
480 $new_values = array(
481 'post_date' => date( 'Y-m-d', $next_date_full ) . ' ' . $existing_time,
482 'post_modified' => current_time( 'mysql' ),
483 'post_modified_gmt' => current_time( 'mysql', 1 ),
484 );
485
486 // By default, changing a post on the calendar won't set the timestamp.
487 // If the user desires that to be the behaviour, they can set the result of this filter to 'true'.
488 // With how WordPress works internally, setting 'post_date_gmt' will set the timestamp.
489 if ( apply_filters( 'ef_calendar_allow_ajax_to_set_timestamp', false ) ) {
490 $new_values['post_date_gmt'] = date( 'Y-m-d', $next_date_full ) . ' ' . $existing_time_gmt;
491 }
492
493 // We have to do SQL unfortunately because of core bugginess.
494 // Note to those reading this: bug Nacin to allow us to finish the custom status API.
495 // See http://core.trac.wordpress.org/ticket/18362.
496 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Core workaround for custom status API limitations.
497 $response = $wpdb->update( $wpdb->posts, $new_values, array( 'ID' => $post->ID ) );
498 clean_post_cache( $post->ID );
499
500 if ( ! $response ) {
501 $this->print_ajax_response( 'error', $this->module->messages['update-error'] );
502 }
503
504 $this->print_ajax_response( 'success', $this->module->messages['post-date-updated'] );
505 }
506
507 /**
508 * After checking that the request is valid, do an .ics file
509 *
510 * @since 0.8
511 */
512 public function handle_ics_subscription() {
513
514 // Only do .ics subscriptions when the option is active.
515 if ( 'on' != $this->module->options->ics_subscription ) {
516 wp_die(); // @todo Return accepted response value.
517 }
518
519 // Confirm all of the arguments are present.
520 if ( ! isset( $_GET['user'], $_GET['user_key'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public feed with secret key validation.
521 wp_die(); // @todo Return an error response.
522 }
523
524 // Resolve the feed user and validate their personal, per-user secret. The comparison
525 // runs unconditionally against a real-or-dummy secret to limit username enumeration
526 // via timing (best-effort: get_user_by() itself is not constant time). user_can() and
527 // the query below resolve against the current blog, the desired multisite behaviour.
528 $login = sanitize_user( wp_unslash( $_GET['user'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public feed validated by per-user secret below.
529 $user_key = sanitize_text_field( wp_unslash( $_GET['user_key'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public feed validated by per-user secret below.
530
531 $feed_user = get_user_by( 'login', $login );
532 $view_cap = apply_filters( 'ef_view_calendar_cap', 'ef_view_calendar' );
533
534 $stored_secret = ( $feed_user && user_can( $feed_user, $view_cap ) )
535 ? (string) $this->get_user_meta( $feed_user->ID, self::usermeta_key_prefix . 'ics_secret', true )
536 : '';
537 $known_secret = '' !== $stored_secret ? $stored_secret : str_repeat( '*', 32 );
538
539 if ( ! hash_equals( $known_secret, $user_key ) || '' === $stored_secret ) {
540 wp_die( esc_html( $this->module->messages['nonce-failed'] ) );
541 }
542
543 // Run the feed as the resolved user so the read scoping below applies to them.
544 wp_set_current_user( $feed_user->ID );
545
546 // Set up the post data to be printed. In this public feed we never honour caller-
547 // supplied author/post_status filters: they are the disclosure levers. The feed is
548 // scoped to the resolved user's own readable posts in get_calendar_posts_for_week().
549 $post_query_args = array();
550 $calendar_filters = $this->calendar_filters();
551 $disallowed_filters = array( 'author', 'post_status' );
552 foreach ( $calendar_filters as $filter ) {
553 if ( in_array( $filter, $disallowed_filters, true ) ) {
554 continue;
555 }
556 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Public feed validated by per-user secret; sanitized by sanitize_filter().
557 if ( isset( $_GET[ $filter ] ) ) {
558 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Public feed validated by per-user secret; sanitized by sanitize_filter().
559 $value = $this->sanitize_filter( $filter, $_GET[ $filter ] );
560 if ( false !== $value ) {
561 $post_query_args[ $filter ] = $value;
562 }
563 }
564 }
565
566 // Set the start date for the posts_where filter.
567 // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Used for date calculation in calendar context.
568 $this->start_date = apply_filters( 'ef_calendar_ics_subscription_start_date', $this->get_beginning_of_week( date( 'Y-m-d', current_time( 'timestamp' ) ) ) );
569
570 $this->total_weeks = apply_filters( 'ef_calendar_total_weeks', $this->total_weeks, 'ics_subscription' );
571
572 $formatted_posts = array();
573 for ( $current_week = 1; $current_week <= $this->total_weeks; $current_week++ ) {
574 // We need to set the object variable for our posts_where filter.
575 $this->current_week = $current_week;
576 $week_posts = $this->get_calendar_posts_for_week( $post_query_args, 'ics_subscription' );
577 foreach ( $week_posts as $date => $day_posts ) {
578 foreach ( $day_posts as $num => $post ) {
579 $start_date = self::ics_format_time( $post->post_date );
580 $end_date = self::ics_format_time( $post->post_date, 5 * MINUTE_IN_SECONDS );
581 $last_modified = self::ics_format_time( $post->post_modified );
582 $post_status_obj = get_post_status_object( get_post_status( $post->ID ) );
583 // Remove the convert chars and wptexturize filters from the title.
584 remove_filter( 'the_title', 'convert_chars' );
585 remove_filter( 'the_title', 'wptexturize' );
586
587 $formatted_post = array(
588 'BEGIN' => 'VEVENT',
589 'UID' => $post->guid,
590 'SUMMARY' => $this->do_ics_escaping( apply_filters( 'the_title', $post->post_title ) ) . ' - ' . $this->do_ics_escaping( $post_status_obj->label ),
591 'DTSTART' => $start_date,
592 'DTEND' => $end_date,
593 'LAST-MODIFIED' => $last_modified,
594 'URL' => get_post_permalink( $post->ID ),
595 );
596
597 // Description should include everything visible in the calendar popup.
598 $information_fields = $this->get_post_information_fields( $post );
599 $formatted_post['DESCRIPTION'] = '';
600 if ( ! empty( $information_fields ) ) {
601 foreach ( $information_fields as $key => $values ) {
602 $formatted_post['DESCRIPTION'] .= $this->do_ics_escaping( $values['label'] ) . ': ' . $this->do_ics_escaping( $values['value'] ) . '\n';
603 }
604 $formatted_post['DESCRIPTION'] = rtrim( $formatted_post['DESCRIPTION'] );
605 }
606
607 $formatted_post['END'] = 'VEVENT';
608
609 // @todo Auto format any field longer than 75 bytes.
610
611 $formatted_posts[] = $formatted_post;
612 }
613 }
614 }
615
616 // Other template data.
617 $header = array(
618 'BEGIN' => 'VCALENDAR',
619 'VERSION' => '2.0',
620 'PRODID' => '-//Edit Flow//Edit Flow ' . EDIT_FLOW_VERSION . '//EN',
621 );
622
623 $footer = array(
624 'END' => 'VCALENDAR',
625 );
626
627 // Render the .ics template and set the content type.
628 header( 'Content-type: text/calendar' );
629 foreach ( array( $header, $formatted_posts, $footer ) as $section ) {
630 foreach ( $section as $key => $value ) {
631 /**
632 * This is output to text/calendar content-type
633 */
634 if ( is_string( $value ) ) {
635 echo $this->do_ics_line_folding( $key . ':' . $value ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
636 } else {
637 foreach ( $value as $k => $v ) {
638 echo $this->do_ics_line_folding( $k . ':' . $v ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
639 }
640 }
641 }
642 }
643 wp_die();
644 }
645
646 /**
647 * Perform line folding according to RFC 5545.
648 *
649 * @param string $line The line without trailing CRLF.
650 * @return string The line after line-folding with all necessary CRLF.
651 */
652 public function do_ics_line_folding( $line ) {
653 $len = mb_strlen( $line );
654 if ( $len <= 75 ) {
655 return $line . "\r\n";
656 }
657
658 $chunks = array();
659 $start = 0;
660 while ( true ) {
661 $chunk = mb_substr( $line, $start, 75 );
662 $chunk_len = mb_strlen( $chunk );
663 $start += $chunk_len;
664 if ( $start < $len ) {
665 $chunks[] = $chunk . "\r\n ";
666 } else {
667 $chunks[] = $chunk . "\r\n";
668 return implode( '', $chunks );
669 }
670 }
671 }
672
673 /**
674 * Perform the encoding necessary for ICS feed text per RFC 5545, section 3.3.11.
675 *
676 * The backslash must be escaped first, otherwise the backslashes introduced
677 * by the subsequent replacements would themselves be escaped a second time.
678 *
679 * @param string $text The string that needs to be escaped.
680 * @return string The string after escaping for ICS.
681 * @since 0.8
682 */
683 public function do_ics_escaping( $text ) {
684 $text = str_replace( '\\', '\\\\', $text );
685 $text = str_replace( array( "\r\n", "\r", "\n" ), '\n', $text );
686 $text = str_replace( ';', '\;', $text );
687 $text = str_replace( ',', '\,', $text );
688 return $text;
689 }
690
691 /**
692 * Convert a time string into a `.ics` formatted time string with the proper GMT offset.
693 *
694 * @param string $time_string Any time string that `strtotime()` can understand.
695 * @param int $offset_in_seconds Allows to offset the timestamp generated from $time_string.
696 *
697 * @return string|false
698 */
699 public static function ics_format_time( $time_string, $offset_in_seconds = 0 ) {
700
701 // Timestamp it.
702 $timestamp = strtotime( $time_string );
703
704 if ( ! $timestamp ) {
705 return false;
706 }
707
708 // Subtract GMT Offset to return to UTC+0.
709 $timestamp -= get_option( 'gmt_offset' ) * HOUR_IN_SECONDS;
710
711 // Add manual offset.
712 $timestamp += $offset_in_seconds;
713
714 // \T and \Z are escaped for literal T and Z characters
715 return date( 'Ymd\THis\Z', $timestamp );
716 }
717
718 /**
719 * Handle a request to regenerate the calendar feed secret
720 *
721 * @since 0.8
722 */
723 public function handle_regenerate_calendar_feed_secret() {
724
725 if ( ! isset( $_GET['action'] ) || 'ef_calendar_regenerate_calendar_feed_secret' != $_GET['action'] ) {
726 return;
727 }
728
729 // Any calendar-capable user may rotate their own feed token (per-user revocation).
730 $view_cap = apply_filters( 'ef_view_calendar_cap', 'ef_view_calendar' );
731 if ( ! current_user_can( $view_cap ) ) {
732 wp_die( esc_html( $this->module->messages['invalid-permissions'] ) );
733 }
734
735 if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'ef-regenerate-ics-key' ) ) {
736 wp_die( esc_html( $this->module->messages['nonce-failed'] ) );
737 }
738
739 // Mint a fresh secret for the current user only; other users' feed URLs are unaffected.
740 $this->update_user_meta( get_current_user_id(), self::usermeta_key_prefix . 'ics_secret', wp_generate_password( 32, false ) );
741
742 wp_safe_redirect( add_query_arg( 'message', 'key-regenerated', menu_page_url( $this->module->settings_slug, false ) ) );
743 exit;
744 }
745
746 /**
747 * Get a user's screen options
748 *
749 * @since 0.7
750 * @uses get_user_meta()
751 *
752 * @return array $screen_options The screen options values
753 */
754 public function get_screen_options() {
755
756 /**
757 * `num_weeks` has been moved to a filter and out of screen options, it's maintained here for legacy purposes
758 *
759 * @deprecated `num_weeks`
760 */
761 $defaults = array(
762 'num_weeks' => (int) $this->total_weeks,
763 );
764 $current_user = wp_get_current_user();
765 $screen_options = $this->get_user_meta( $current_user->ID, self::usermeta_key_prefix . 'screen_options', true );
766 $screen_options = array_merge( (array) $defaults, (array) $screen_options );
767
768 return $screen_options;
769 }
770
771 /**
772 * Get the user's filters for calendar, either with $_GET or from saved
773 *
774 * @uses get_user_meta()
775 * @return array $filters All of the set or saved calendar filters
776 */
777 public function get_filters() {
778 $current_user = wp_get_current_user();
779 $filters = array();
780 $old_filters = $this->get_user_meta( $current_user->ID, self::usermeta_key_prefix . 'filters', true );
781
782 /**
783 * To support legacy screen option for num_weeks
784 */
785 $screen_options = $this->get_user_meta( $current_user->ID, self::usermeta_key_prefix . 'screen_options', true );
786
787 $default_filters = array(
788 'post_status' => '',
789 'cpt' => '',
790 'cat' => '',
791 'author' => '',
792 'num_weeks' => $this->total_weeks,
793 // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Used for date calculation in calendar context.
794 'start_date' => date( 'Y-m-d', current_time( 'timestamp' ) ),
795 );
796 $old_filters = array_merge( $default_filters, isset( $screen_options['num_weeks'] ) ? array( 'num_weeks' => $screen_options['num_weeks'] ) : array(), (array) $old_filters );
797
798 // Sanitize and validate any newly added filters.
799 foreach ( $old_filters as $key => $old_value ) {
800 if ( isset( $_GET[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter values are sanitized below and stored per user.
801 $new_value = $this->sanitize_filter( $key, $_GET[ $key ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Filter values are sanitized by sanitize_filter().
802 if ( false !== $new_value ) {
803 $filters[ $key ] = $new_value;
804 continue;
805 }
806 }
807 $filters[ $key ] = $old_value;
808 }
809
810 // Set the start date as the beginning of the week, according to blog settings.
811 $filters['start_date'] = $this->get_beginning_of_week( $filters['start_date'] );
812
813 $filters = apply_filters( 'ef_calendar_filter_values', $filters, $old_filters );
814
815 $this->update_user_meta( $current_user->ID, self::usermeta_key_prefix . 'filters', $filters );
816
817 return $filters;
818 }
819
820 /**
821 * Build all of the HTML for the calendar view.
822 */
823 public function view_calendar() {
824 $supported_post_types = $this->get_post_types_for_module( $this->module );
825
826 // Get filters either from $_GET or from user settings.
827 $filters = $this->get_filters();
828
829 // Total number of weeks to display on the calendar. Run it through a filter in case we want to override the
830 // user's standard.
831 $this->total_weeks = apply_filters( 'ef_calendar_total_weeks', $filters['num_weeks'], 'dashboard' );
832
833 $dotw = array(
834 'Sat',
835 'Sun',
836 );
837 $dotw = apply_filters( 'ef_calendar_weekend_days', $dotw );
838
839 // For generating the WP Query objects later on.
840 $post_query_args = array(
841 'post_status' => $filters['post_status'],
842 'post_type' => $filters['cpt'],
843 'cat' => $filters['cat'],
844 'author' => $filters['author'],
845 );
846 $this->start_date = $filters['start_date'];
847
848 // We use this later to label posts if they need labeling.
849 if ( count( $supported_post_types ) > 1 ) {
850 $all_post_types = get_post_types( null, 'objects' );
851 }
852 $dates = array();
853 $heading_date = $filters['start_date'];
854 for ( $i = 0; $i < 7; $i++ ) {
855 $dates[ $i ] = $heading_date;
856 $heading_date = date( 'Y-m-d', strtotime( '+1 day', strtotime( $heading_date ) ) );
857 }
858
859 // We sort by post statuses, eventually.
860 $post_statuses = $this->get_calendar_post_stati();
861 ?>
862 <div class="wrap">
863 <div id="ef-calendar-title"><!-- Calendar Title -->
864 <?php echo '<img src="' . esc_url( $this->module->img_url ) . '" class="module-icon icon32" />'; ?>
865 <h2><?php esc_html_e( 'Calendar', 'edit-flow' ); ?>&nbsp;<span class="time-range"><?php $this->calendar_time_range(); ?></span></h2>
866 </div><!-- /Calendar Title -->
867
868 <?php
869 // Handle posts that have been trashed or untrashed.
870 // phpcs:disable WordPress.Security.NonceVerification.Recommended -- These GET params are set by WordPress core's trash/untrash actions.
871 if ( isset( $_GET['trashed'] ) || isset( $_GET['untrashed'] ) ) {
872 echo '<div id="trashed-message" class="updated"><p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
873 if ( isset( $_GET['trashed'] ) && (int) $_GET['trashed'] ) {
874 $trashed_count = (int) $_GET['trashed'];
875 /* translators: %d: number of posts trashed */
876 echo esc_html( sprintf( _n( '%d post moved to the trash.', '%d posts moved to the trash.', $trashed_count, 'edit-flow' ), number_format_i18n( $trashed_count ) ) );
877
878 // Only build an Undo link from strictly-numeric ids; a
879 // user-crafted value must not be able to inject extra
880 // query arguments into the resulting URL.
881 $ids_raw = isset( $_GET['ids'] ) ? sanitize_text_field( wp_unslash( $_GET['ids'] ) ) : '';
882 $pid_list = array_values( array_filter( array_map( 'absint', explode( ',', $ids_raw ) ) ) );
883 if ( ! empty( $pid_list ) ) {
884 $post_type = get_post_type( $pid_list[0] );
885 if ( $post_type && post_type_exists( $post_type ) ) {
886 $undo_url = add_query_arg(
887 array(
888 'post_type' => $post_type,
889 'doaction' => 'undo',
890 'action' => 'untrash',
891 'ids' => implode( ',', $pid_list ),
892 ),
893 admin_url( 'edit.php' )
894 );
895 echo ' <a href="' . esc_url( wp_nonce_url( $undo_url, 'bulk-posts' ) ) . '">' . esc_html__( 'Undo', 'edit-flow' ) . '</a><br />';
896 }
897 }
898 unset( $_GET['trashed'] );
899 }
900 if ( isset( $_GET['untrashed'] ) && (int) $_GET['untrashed'] ) {
901 $untrashed_count = (int) $_GET['untrashed'];
902 /* translators: %d: number of posts restored */
903 echo esc_html( sprintf( _n( '%d post restored from the Trash.', '%d posts restored from the Trash.', $untrashed_count, 'edit-flow' ), number_format_i18n( $untrashed_count ) ) );
904 unset( $_GET['untrashed'] );
905 }
906 echo '</p></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
907 // phpcs:enable WordPress.Security.NonceVerification.Recommended
908 }
909 ?>
910
911 <div id="ef-calendar-navigation-mount"></div> <!-- Mount point for React -->
912
913 <div id="ef-calendar-wrap"><!-- Calendar Wrapper -->
914
915 <?php
916 $table_classes = array();
917 // CSS doesn't like our classes to start with numbers.
918 if ( 1 == $this->total_weeks ) {
919 $table_classes[] = 'one-week-showing';
920 } elseif ( 2 == $this->total_weeks ) {
921 $table_classes[] = 'two-weeks-showing';
922 } elseif ( 3 == $this->total_weeks ) {
923 $table_classes[] = 'three-weeks-showing';
924 }
925
926 $table_classes = apply_filters( 'ef_calendar_table_classes', $table_classes );
927 ?>
928 <table id="ef-calendar-view" class="<?php echo esc_attr( implode( ' ', $table_classes ) ); ?>">
929 <thead>
930 <tr class="calendar-heading">
931 <?php echo $this->get_time_period_header( $dates ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
932 </tr>
933 </thead>
934 <tbody>
935
936 <?php
937 $current_month = date_i18n( 'F', strtotime( $filters['start_date'] ) );
938 for ( $current_week = 1; $current_week <= $this->total_weeks; $current_week++ ) :
939 // We need to set the object variable for our posts_where filter.
940 $this->current_week = $current_week;
941 $week_posts = $this->get_calendar_posts_for_week( $post_query_args );
942 $date_format = 'Y-m-d';
943 $week_single_date = $this->get_beginning_of_week( $filters['start_date'], $date_format, $current_week );
944 $week_dates = array();
945 $split_month = false;
946 for ( $i = 0; $i < 7; $i++ ) {
947 $week_dates[ $i ] = $week_single_date;
948 $single_date_month = date_i18n( 'F', strtotime( $week_single_date ) );
949 if ( $single_date_month != $current_month ) {
950 $split_month = $single_date_month;
951 $current_month = $single_date_month;
952 }
953 $week_single_date = date( 'Y-m-d', strtotime( '+1 day', strtotime( $week_single_date ) ) );
954 }
955 ?>
956 <?php if ( $split_month ) : ?>
957 <tr class="month-marker">
958 <?php
959 foreach ( $week_dates as $key => $week_single_date ) {
960 if ( date_i18n( 'F', strtotime( $week_single_date ) ) != $split_month && date_i18n( 'F', strtotime( '+1 day', strtotime( $week_single_date ) ) ) == $split_month ) {
961 $previous_month = date_i18n( 'F', strtotime( $week_single_date ) );
962 echo '<td class="month-marker-previous">' . esc_html( $previous_month ) . '</td>';
963 } elseif ( date_i18n( 'F', strtotime( $week_single_date ) ) == $split_month && date_i18n( 'F', strtotime( '-1 day', strtotime( $week_single_date ) ) ) != $split_month ) {
964 echo '<td class="month-marker-current">' . esc_html( $split_month ) . '</td>';
965 } else {
966 echo '<td class="month-marker-empty"></td>';
967 }
968 }
969 ?>
970 </tr>
971 <?php endif; ?>
972
973 <tr class="week-unit">
974 <?php foreach ( $week_dates as $day_num => $week_single_date ) : ?>
975 <?php
976 // Sort all of the day's posts by post status order.
977 if ( ! empty( $week_posts[ $week_single_date ] ) ) {
978 $week_posts_by_status = array();
979 foreach ( $post_statuses as $post_status ) {
980 $week_posts_by_status[ $post_status->name ] = array();
981 }
982 // These statuses aren't handled by custom statuses or post statuses.
983 $week_posts_by_status['private'] = array();
984 $week_posts_by_status['publish'] = array();
985 $week_posts_by_status['future'] = array();
986 foreach ( $week_posts[ $week_single_date ] as $num => $post ) {
987 $week_posts_by_status[ $post->post_status ][ $num ] = $post;
988 }
989 unset( $week_posts[ $week_single_date ] );
990 foreach ( $week_posts_by_status as $status ) {
991 foreach ( $status as $num => $post ) {
992 $week_posts[ $week_single_date ][] = $post;
993 }
994 }
995 }
996
997 $td_classes = array(
998 'day-unit',
999 );
1000 $day_name = date( 'D', strtotime( $week_single_date ) );
1001
1002 if ( in_array( $day_name, $dotw ) ) {
1003 $td_classes[] = 'weekend-day';
1004 }
1005
1006 // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Used for date comparison in calendar display.
1007 if ( date( 'Y-m-d', current_time( 'timestamp' ) ) == $week_single_date ) {
1008 $td_classes[] = 'today';
1009 }
1010
1011 // Last day of the week.
1012 if ( 6 == $day_num ) {
1013 $td_classes[] = 'last-day';
1014 }
1015
1016 $td_classes = apply_filters( 'ef_calendar_table_td_classes', $td_classes, $week_single_date );
1017 // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Used for date comparison in calendar display.
1018 $is_today = date( 'Y-m-d', current_time( 'timestamp' ) ) == $week_single_date;
1019 ?>
1020 <td class="<?php echo esc_attr( implode( ' ', $td_classes ) ); ?>" id="date-<?php echo esc_attr( $week_single_date ); ?>">
1021 <button class='schedule-new-post-button'>+</button>
1022 <?php if ( $is_today ) : ?>
1023 <div class="day-unit-today"><?php esc_html_e( 'Today', 'edit-flow' ); ?></div>
1024 <?php endif; ?>
1025 <div class="day-unit-label"><?php echo esc_html( date( 'j', strtotime( $week_single_date ) ) ); ?></div>
1026 <ul class="post-list">
1027 <?php
1028 $this->hidden = 0;
1029 if ( ! empty( $week_posts[ $week_single_date ] ) ) {
1030 $week_posts[ $week_single_date ] = apply_filters( 'ef_calendar_posts_for_week', $week_posts[ $week_single_date ], $week_single_date );
1031
1032 foreach ( $week_posts[ $week_single_date ] as $num => $post ) {
1033 $output = apply_filters( 'ef_pre_calendar_single_date_item_html', '', $this, $num, $post, $week_single_date );
1034 if ( ! $output ) {
1035 $output = $this->generate_post_li_html( $post, $week_single_date, $num );
1036 }
1037 echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1038 }
1039 }
1040 ?>
1041 </ul>
1042 <?php if ( $this->hidden ) : ?>
1043 <a class="show-more" href="#"><?php /* translators: %d = number of posts to show */ printf( esc_html__( 'Show %d more', 'edit-flow' ), absint( $this->hidden ) ); ?></a>
1044 <?php endif; ?>
1045
1046 <?php
1047 if ( current_user_can( $this->create_post_cap ) ) :
1048 $date_formatted = date( 'D, M jS, Y', strtotime( $week_single_date ) );
1049 ?>
1050
1051 <form method="POST" class="post-insert-dialog">
1052 <?php /* translators: %1$s = post type name, %2$s = date */ ?>
1053 <h1><?php printf( esc_html__( 'Schedule a %1$s for %2$s', 'edit-flow' ), esc_html( $this->get_quick_create_post_type_name() ), esc_html( $date_formatted ) ); ?></h1>
1054 <?php /* translators: %s = post type name */ ?>
1055 <input type="text" class="post-insert-dialog-post-title" name="post-insert-dialog-post-title" placeholder="<?php echo esc_attr( sprintf( _x( '%s Title', 'post type name', 'edit-flow' ), $this->get_quick_create_post_type_name() ) ); ?>">
1056 <input type="hidden" class="post-insert-dialog-post-date" name="post-insert-dialog-post-title" value="<?php echo esc_attr( $week_single_date ); ?>">
1057 <div class="post-insert-dialog-controls">
1058 <input type="submit" class="button left" value="<?php /* translators: %s = post type name */ echo esc_attr( sprintf( _x( 'Create %s', 'post type name', 'edit-flow' ), $this->get_quick_create_post_type_name() ) ); ?>">
1059 <a class="post-insert-dialog-edit-post-link" href="#"><?php /* translators: %s = post type name */ echo esc_html( sprintf( _x( 'Edit %s', 'post type name', 'edit-flow' ), $this->get_quick_create_post_type_name() ) ); ?>&nbsp;&raquo;</a>
1060 </div>
1061 <div class="spinner">&nbsp;</div>
1062 </form>
1063 <?php endif; ?>
1064
1065 </td>
1066 <?php endforeach; ?>
1067 </tr>
1068
1069 <?php endfor; ?>
1070
1071 </tbody>
1072 </table><!-- /Week Wrapper -->
1073 <?php
1074 // Nonce field for AJAX actions.
1075 wp_nonce_field( 'ef-calendar-modify', 'ef-calendar-modify' );
1076 ?>
1077
1078 <div class="clear"></div>
1079 </div><!-- /Calendar Wrapper -->
1080
1081 </div>
1082
1083 <?php
1084 }
1085
1086 /**
1087 * Generates the HTML for a single post item in the calendar.
1088 *
1089 * @param object $post The WordPress post in question.
1090 * @param string $post_date The date of the post.
1091 * @param int $num The index of the post.
1092 *
1093 * @return string HTML for a single post item.
1094 */
1095 public function generate_post_li_html( $post, $post_date, $num = 0 ) {
1096
1097 ob_start();
1098 $post_id = $post->ID;
1099 $status_object = get_post_status_object( get_post_status( $post_id ) );
1100
1101 $post_classes = array(
1102 'day-item',
1103 'custom-status-' . $post->post_status,
1104 );
1105 // Only allow the user to drag the post if they have permissions to
1106 // or if it's in an approved post status
1107 // This is checked on the ajax request too.
1108 if ( $this->current_user_can_modify_post( $post ) && ! in_array( $post->post_status, $this->published_statuses ) ) {
1109 $post_classes[] = 'sortable';
1110 }
1111
1112 if ( in_array( $post->post_status, $this->published_statuses ) ) {
1113 $post_classes[] = 'is-published';
1114 }
1115
1116 // Hide posts over a certain number to prevent clutter, unless user is only viewing 1 or 2 weeks.
1117 $max_visible_posts = apply_filters( 'ef_calendar_max_visible_posts_per_date', $this->max_visible_posts_per_date );
1118
1119 if ( $num >= $max_visible_posts && $this->total_weeks > 2 ) {
1120 $post_classes[] = 'hidden';
1121 ++$this->hidden;
1122 }
1123 $post_classes = apply_filters( 'ef_calendar_table_td_li_classes', $post_classes, $post_date, $post->ID );
1124
1125 ?>
1126 <li class="<?php echo esc_attr( implode( ' ', $post_classes ) ); ?>" id="post-<?php echo esc_attr( $post->ID ); ?>">
1127 <div style="clear:right;"></div>
1128 <div class="item-static">
1129 <div class="item-default-visible">
1130 <div class="item-status"><span class="status-text"><?php echo esc_html( $status_object->label ); ?></span></div>
1131 <div class="inner">
1132 <span class="item-headline post-title"><strong><?php echo esc_html( _draft_or_post_title( $post->ID ) ); ?></strong></span>
1133 </div>
1134 <?php do_action( 'ef_calendar_item_html', $post->ID ); ?>
1135 </div>
1136 <div class="item-inner">
1137 <?php $this->get_inner_information( $this->get_post_information_fields( $post ), $post ); ?>
1138 </div>
1139 </div>
1140 </li>
1141 <?php
1142
1143 $post_li_html = ob_get_contents();
1144 ob_end_clean();
1145
1146 return $post_li_html;
1147 }
1148
1149 /**
1150 * Generate the inner HTML elements for a calendar item.
1151 *
1152 * Functionality for generating the inner html elements on the calendar
1153 * has been separated out so various ajax functions can reload certain
1154 * parts of an inner html element.
1155 *
1156 * @since 0.8
1157 *
1158 * @param array $ef_calendar_item_information_fields Array of information fields.
1159 * @param WP_Post $post The post object.
1160 */
1161 public function get_inner_information( $ef_calendar_item_information_fields, $post ) {
1162 ?>
1163 <table class="item-information">
1164 <?php foreach ( $this->get_post_information_fields( $post ) as $field => $values ) : ?>
1165 <tr class="item-field item-information-<?php echo esc_attr( $field ); ?>">
1166 <th class="label"><?php echo esc_html( $values['label'] ); ?>:</th>
1167 <?php if ( $values['value'] && isset( $values['type'] ) ) : ?>
1168 <?php if ( isset( $values['editable'] ) && $this->current_user_can_modify_post( $post ) ) : ?>
1169 <?php $editable_class = $values['editable'] ? 'editable-value' : ''; ?>
1170 <td class="value <?php echo esc_attr( $editable_class ); ?>"><?php echo esc_html( $values['value'] ); ?></td>
1171 <?php if ( $values['editable'] ) : ?>
1172 <td class="editable-html hidden" data-type="<?php echo esc_attr( $values['type'] ); ?>" data-metadataterm="<?php echo esc_attr( str_replace( 'editorial-metadata-', '', str_replace( 'tax_', '', $field ) ) ); ?>"><?php echo $this->get_editable_html( $values['type'], $values['value'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_editable_html() escapes each branch (esc_attr/esc_html) and otherwise returns only static markup or core-escaped wp_dropdown_users() output. ?></td>
1173 <?php endif; ?>
1174 <?php else : ?>
1175 <td class="value"><?php echo esc_html( $values['value'] ); ?></td>
1176 <?php endif; ?>
1177 <?php elseif ( $values['value'] ) : ?>
1178 <td class="value"><?php echo esc_html( $values['value'] ); ?></td>
1179 <?php else : ?>
1180 <td class="value"><em class="none"><?php esc_html_e( 'None', 'edit-flow' ); ?></em></td>
1181 <?php endif; ?>
1182 </tr>
1183 <?php endforeach; ?>
1184 <?php do_action( 'ef_calendar_item_additional_html', $post->ID ); ?>
1185 </table>
1186 <?php
1187 $post_type_object = get_post_type_object( $post->post_type );
1188 $item_actions = array();
1189 if ( $this->current_user_can_modify_post( $post ) ) {
1190 // Edit this post.
1191 $item_actions['edit'] = '<a href="' . get_edit_post_link( $post->ID, true ) . '" title="' . esc_attr( __( 'Edit this item', 'edit-flow' ) ) . '">' . __( 'Edit', 'edit-flow' ) . '</a>';
1192 // Trash this post.
1193 $item_actions['trash'] = '<a href="' . get_delete_post_link( $post->ID ) . '" title="' . esc_attr__( 'Trash this item', 'edit-flow' ) . '">' . __( 'Trash', 'edit-flow' ) . '</a>';
1194 // Preview/view this post.
1195 if ( ! in_array( $post->post_status, $this->published_statuses ) ) {
1196 /* translators: %s: post title */
1197 $item_actions['view'] = '<a href="' . esc_url( apply_filters( 'preview_post_link', add_query_arg( 'preview', 'true', get_permalink( $post->ID ) ), $post ) ) . '" title="' . esc_attr( sprintf( __( 'Preview &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . __( 'Preview', 'edit-flow' ) . '</a>';
1198 } elseif ( 'trash' != $post->post_status ) {
1199 /* translators: %s: post title */
1200 $item_actions['view'] = '<a href="' . get_permalink( $post->ID ) . '" title="' . esc_attr( sprintf( __( 'View &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" rel="permalink">' . __( 'View', 'edit-flow' ) . '</a>';
1201 }
1202 // Save metadata.
1203 /* translators: %s: post title */
1204 $item_actions['save hidden'] = '<a href="#savemetadata" id="save-editorial-metadata" class="post-' . esc_attr( $post->ID ) . '" title="' . esc_attr( sprintf( __( 'Save &#8220;%s&#8221;', 'edit-flow' ), $post->post_title ) ) . '" >' . __( 'Save', 'edit-flow' ) . '</a>';
1205 }
1206 // Allow other plugins to add actions.
1207 $item_actions = apply_filters( 'ef_calendar_item_actions', $item_actions, $post->ID );
1208 if ( count( $item_actions ) ) {
1209 // Separate the save action to render it on its own row.
1210 $save_action = '';
1211 if ( isset( $item_actions['save hidden'] ) ) {
1212 $save_action = $item_actions['save hidden'];
1213 unset( $item_actions['save hidden'] );
1214 }
1215
1216 echo '<div class="item-actions">';
1217 $html = '';
1218 foreach ( $item_actions as $class => $item_action ) {
1219 $html .= '<span class="' . esc_attr( $class ) . '">' . $item_action . '</span> | '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1220 }
1221 echo rtrim( $html, ' | ' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1222
1223 // Render save button on its own row (hidden by default, shown via JS when editing).
1224 if ( $save_action ) {
1225 echo '<span class="save hidden">' . $save_action . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1226 }
1227 echo '</div>';
1228 }
1229 ?>
1230 <div style="clear:right;"></div>
1231 <?php
1232 }
1233
1234 /**
1235 * Get editable HTML for a metadata field type.
1236 *
1237 * @param string $type The metadata field type.
1238 * @param string $value The current field value.
1239 * @return string|void The HTML input element.
1240 */
1241 public function get_editable_html( $type, $value ) {
1242
1243 switch ( $type ) {
1244 case 'text':
1245 case 'location':
1246 case 'number':
1247 return '<input type="text" class="metadata-edit-' . esc_attr( $type ) . '" value="' . esc_attr( $value ) . '"/>';
1248 case 'paragraph':
1249 return '<textarea type="text" class="metadata-edit-' . esc_attr( $type ) . '">' . esc_html( $value ) . '</textarea>';
1250 case 'date':
1251 // Convert display value to datetime-local format (Y-m-d\TH:i).
1252 $datetime_value = '';
1253 if ( ! empty( $value ) ) {
1254 $timestamp = strtotime( $value );
1255 if ( false !== $timestamp ) {
1256 $datetime_value = date( 'Y-m-d\TH:i', $timestamp );
1257 }
1258 }
1259 return '<input type="datetime-local" value="' . esc_attr( $datetime_value ) . '" class="metadata-edit-' . esc_attr( $type ) . '"/>';
1260 case 'checkbox':
1261 $output = '<select class="metadata-edit">';
1262
1263 if ( 'No' == $value ) {
1264 $output .= '<option value="0">No</option><option value="1">Yes</option>';
1265 } else {
1266 $output .= '<option value="1">Yes</option><option value="0">No</option>';
1267 }
1268
1269 $output .= '</select>';
1270
1271 return $output;
1272 case 'user':
1273 return wp_dropdown_users( array( 'echo' => false ) );
1274 case 'taxonomy':
1275 return '<input type="text" class="metadata-edit-' . esc_attr( $type ) . '" value="' . esc_attr( $value ) . '" />';
1276 }
1277 }
1278
1279 /**
1280 * Get the information fields to be presented with each post popup.
1281 *
1282 * @since 0.8
1283 *
1284 * @param object $post Post to gather information fields for.
1285 * @return array $information_fields All of the information fields to be presented.
1286 */
1287 public function get_post_information_fields( $post ) {
1288
1289 $information_fields = array();
1290 // Post author.
1291 $information_fields['author'] = array(
1292 'label' => __( 'Author', 'edit-flow' ),
1293 'value' => get_the_author_meta( 'display_name', $post->post_author ),
1294 'type' => 'author',
1295 );
1296
1297 // If the calendar supports more than one post type, show the post type label.
1298 if ( count( $this->get_post_types_for_module( $this->module ) ) > 1 ) {
1299 $information_fields['post_type'] = array(
1300 'label' => __( 'Post Type', 'edit-flow' ),
1301 'value' => get_post_type_object( $post->post_type )->labels->singular_name,
1302 );
1303 }
1304 // Publication time for published statuses.
1305 $published_statuses = array(
1306 'publish',
1307 'future',
1308 'private',
1309 );
1310 if ( in_array( $post->post_status, $published_statuses ) ) {
1311 if ( 'future' == $post->post_status ) {
1312 $information_fields['post_date'] = array(
1313 'label' => __( 'Scheduled', 'edit-flow' ),
1314 'value' => get_the_time( null, $post->ID ),
1315 );
1316 } else {
1317 $information_fields['post_date'] = array(
1318 'label' => __( 'Published', 'edit-flow' ),
1319 'value' => get_the_time( null, $post->ID ),
1320 );
1321 }
1322 }
1323 // Taxonomies and their values.
1324 $args = array(
1325 'post_type' => $post->post_type,
1326 );
1327 $taxonomies = get_object_taxonomies( $args, 'object' );
1328 foreach ( (array) $taxonomies as $taxonomy ) {
1329 // Sometimes taxonomies skip by, so let's make sure it has a label too.
1330 if ( ! $taxonomy->public || ! $taxonomy->label ) {
1331 continue;
1332 }
1333
1334 $terms = get_the_terms( $post->ID, $taxonomy->name );
1335 if ( ! $terms || is_wp_error( $terms ) ) {
1336 continue;
1337 }
1338
1339 $key = 'tax_' . $taxonomy->name;
1340 if ( count( $terms ) ) {
1341 $value = '';
1342 foreach ( (array) $terms as $term ) {
1343 $value .= $term->name . ', ';
1344 }
1345 $value = rtrim( $value, ', ' );
1346 } else {
1347 $value = '';
1348 }
1349 $information_fields[ $key ] = array(
1350 'label' => $taxonomy->label,
1351 'value' => $value,
1352 );
1353
1354 // Only allow non-hierarchical taxonomies to be edited in the calendar.
1355 // Hierarchical taxonomies (like categories) cause performance issues and
1356 // the single-select UI removes all but one category when saved.
1357 if ( is_taxonomy_hierarchical( $taxonomy->name ) ) {
1358 $information_fields[ $key ]['type'] = 'taxonomy hierarchical';
1359 } else {
1360 $information_fields[ $key ]['type'] = 'taxonomy';
1361
1362 if ( 'page' == $post->post_type ) {
1363 $ed_cap = 'edit_page';
1364 } else {
1365 $ed_cap = 'edit_post';
1366 }
1367
1368 if ( current_user_can( $ed_cap, $post->ID ) ) {
1369 $information_fields[ $key ]['editable'] = true;
1370 }
1371 }
1372 }
1373
1374 $information_fields = apply_filters( 'ef_calendar_item_information_fields', $information_fields, $post->ID );
1375 foreach ( $information_fields as $field => $values ) {
1376 // Allow filters to hide empty fields or to hide any given individual field. Hide empty fields by default.
1377 if ( ( apply_filters( 'ef_calendar_hide_empty_item_information_fields', true, $post->ID ) && empty( $values['value'] ) )
1378 || apply_filters( "ef_calendar_hide_{$field}_item_information_field", false, $post->ID ) ) {
1379 unset( $information_fields[ $field ] );
1380 }
1381 }
1382 return $information_fields;
1383 }
1384
1385 /**
1386 * Generate the calendar header for a given range of dates.
1387 *
1388 * @param array $dates Date range for the header.
1389 * @return string $html Generated HTML for the header.
1390 */
1391 public function get_time_period_header( $dates ) {
1392
1393 $html = '';
1394 foreach ( $dates as $date ) {
1395 $html .= '<th class="column-heading" >';
1396 $html .= esc_html( date_i18n( 'l', strtotime( $date ) ) );
1397 $html .= '</th>';
1398 }
1399
1400 return $html;
1401 }
1402
1403 /**
1404 * Query to get all of the calendar posts for a given day.
1405 *
1406 * @param array $args Any filter arguments we want to pass.
1407 * @param string $context Where the query is coming from, to distinguish dashboard and subscriptions.
1408 * @return array $posts All of the posts as an array sorted by date.
1409 */
1410 public function get_calendar_posts_for_week( $args = array(), $context = 'dashboard' ) {
1411
1412 $supported_post_types = $this->get_post_types_for_module( $this->module );
1413 $defaults = array(
1414 'post_status' => null,
1415 'cat' => null,
1416 'author' => null,
1417 'post_type' => $supported_post_types,
1418 // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page -- Calendar needs to show all posts for the week.
1419 'posts_per_page' => 200,
1420 );
1421
1422 $args = array_merge( $defaults, $args );
1423
1424 // Unpublished as a status is just an array of everything but 'publish'.
1425 if ( 'unpublish' == $args['post_status'] ) {
1426 $args['post_status'] = '';
1427 $post_stati = wp_filter_object_list( $this->get_calendar_post_stati(), array( 'name' => 'publish' ), 'not' );
1428
1429 if ( ! apply_filters( 'ef_show_scheduled_as_unpublished', false ) ) {
1430 $post_stati = wp_filter_object_list( $post_stati, array( 'name' => 'future' ), 'not' );
1431 }
1432
1433 $args['post_status'] .= implode( ',', wp_list_pluck( $post_stati, 'name' ) );
1434 }
1435 // The WP functions for printing the category and author assign a value of 0 to the default
1436 // options, but passing this to the query is bad (trashed and auto-draft posts appear!), so
1437 // unset those arguments.
1438 if ( '0' === $args['cat'] ) {
1439 unset( $args['cat'] );
1440 }
1441 if ( '0' === $args['author'] ) {
1442 unset( $args['author'] );
1443 }
1444
1445 if ( empty( $args['post_type'] ) || ! in_array( $args['post_type'], $supported_post_types ) ) {
1446 $args['post_type'] = $supported_post_types;
1447 }
1448
1449 $beginning_date = $this->get_beginning_of_week( $this->start_date, 'Y-m-d', $this->current_week );
1450 $ending_date = date( 'Y-m-d', strtotime( $beginning_date ) + WEEK_IN_SECONDS );
1451
1452 $args['date_query'] = array(
1453 'after' => $beginning_date,
1454 'before' => $ending_date,
1455 'inclusive' => true,
1456 );
1457
1458 // Filter for an end user to implement any of their own query args.
1459 $args = apply_filters( 'ef_calendar_posts_query_args', $args, $context );
1460
1461 // In the public .ics subscription context the request runs as the resolved feed user
1462 // (see handle_ics_subscription()). Mirror the core posts list: a user who cannot edit
1463 // others' posts only sees their own, so a leaked feed URL cannot disclose other
1464 // authors' unpublished posts. Applied after the filter so it cannot be bypassed via
1465 // ef_calendar_posts_query_args. The dashboard calendar (cap-gated) is unaffected.
1466 if ( 'ics_subscription' === $context && ! current_user_can( 'edit_others_posts' ) ) {
1467 $args['author'] = get_current_user_id();
1468 }
1469
1470 $post_results = new WP_Query( $args );
1471
1472 $posts = array();
1473 while ( $post_results->have_posts() ) {
1474 $post_results->the_post();
1475 global $post;
1476 $key_date = date( 'Y-m-d', strtotime( $post->post_date ) );
1477 $posts[ $key_date ][] = $post;
1478 }
1479
1480 return $posts;
1481 }
1482
1483 /**
1484 * Gets the link for the next time period.
1485 *
1486 * @param string $direction 'previous' or 'next', direction to go in time.
1487 * @param array $filters Any filters that need to be applied.
1488 * @param int $weeks_offset Number of weeks we're offsetting the range.
1489 * @return string $url The URL for the next page.
1490 */
1491 public function get_pagination_link( $direction = 'next', $filters = array(), $weeks_offset = null ) {
1492
1493 $supported_post_types = $this->get_post_types_for_module( $this->module );
1494
1495 if ( ! isset( $weeks_offset ) ) {
1496 $weeks_offset = $this->total_weeks;
1497 } elseif ( 0 == $weeks_offset ) {
1498 // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Used for date calculation in calendar context.
1499 $filters['start_date'] = $this->get_beginning_of_week( date( 'Y-m-d', current_time( 'timestamp' ) ) );
1500 }
1501
1502 if ( 'previous' == $direction ) {
1503 $weeks_offset = '-' . $weeks_offset;
1504 }
1505
1506 $filters['start_date'] = date( 'Y-m-d', strtotime( $weeks_offset . ' weeks', strtotime( $filters['start_date'] ) ) );
1507 $url = add_query_arg( $filters, menu_page_url( $this->module->slug, false ) );
1508
1509 if ( count( $supported_post_types ) > 1 ) {
1510 $url = add_query_arg( 'cpt', $filters['cpt'], $url );
1511 }
1512
1513 return $url;
1514 }
1515
1516 /**
1517 * Given a day in string format, returns the day at the beginning of that week, which can be the given date.
1518 * The beginning of the week is determined by the blog option, 'start_of_week'.
1519 *
1520 * @see http://www.php.net/manual/en/datetime.formats.date.php for valid date formats
1521 *
1522 * @param string $date String representing a date.
1523 * @param string $format Date format in which the beginning of the week should be returned.
1524 * @param int $week Number of weeks we're offsetting the range.
1525 * @return string $formatted_start_of_week Beginning of the week.
1526 */
1527 public function get_beginning_of_week( $date, $format = 'Y-m-d', $week = 1 ) {
1528
1529 $date = strtotime( $date );
1530 $start_of_week = get_option( 'start_of_week' );
1531 $day_of_week = date( 'w', $date );
1532 $date += ( ( $start_of_week - $day_of_week - 7 ) % 7 ) * 60 * 60 * 24;
1533 $date = strtotime( '+' . ( $week - 1 ) . ' week', $date );
1534 $formatted_start_of_week = date( $format, $date );
1535 return $formatted_start_of_week;
1536 }
1537
1538 /**
1539 * Given a day in string format, returns the day at the end of that week, which can be the given date.
1540 * The end of the week is determined by the blog option, 'start_of_week'.
1541 *
1542 * @see http://www.php.net/manual/en/datetime.formats.date.php for valid date formats
1543 *
1544 * @param string $date String representing a date.
1545 * @param string $format Date format in which the end of the week should be returned.
1546 * @param int $week Number of weeks we're offsetting the range.
1547 * @return string $formatted_end_of_week End of the week.
1548 */
1549 public function get_ending_of_week( $date, $format = 'Y-m-d', $week = 1 ) {
1550
1551 $date = strtotime( $date );
1552 $end_of_week = get_option( 'start_of_week' ) - 1;
1553 $day_of_week = date( 'w', $date );
1554 $date += ( ( $end_of_week - $day_of_week + 7 ) % 7 ) * 60 * 60 * 24;
1555 $date = strtotime( '+' . ( $week - 1 ) . ' week', $date );
1556 $formatted_end_of_week = date( $format, $date );
1557 return $formatted_end_of_week;
1558 }
1559
1560 /**
1561 * Human-readable time range for the calendar.
1562 *
1563 * Shows something like "for October 30th through November 26th" for a four-week period.
1564 *
1565 * @since 0.7
1566 */
1567 public function calendar_time_range() {
1568
1569 $first_datetime = strtotime( $this->start_date );
1570 $first_date = date_i18n( get_option( 'date_format' ), $first_datetime );
1571 $total_days = ( $this->total_weeks * 7 ) - 1;
1572 $last_datetime = strtotime( '+' . $total_days . ' days', date( 'U', strtotime( $this->start_date ) ) );
1573 $last_date = date_i18n( get_option( 'date_format' ), $last_datetime );
1574 // translators: %1$s = first date, %2$s = last date.
1575 echo esc_html( sprintf( __( 'for %1$s through %2$s', 'edit-flow' ), $first_date, $last_date ) );
1576 }
1577
1578 /**
1579 * Check whether the current user should have the ability to modify the post.
1580 *
1581 * @since 0.7
1582 *
1583 * @param object $post The post object we're checking.
1584 * @return bool $can Whether or not the current user can modify the post.
1585 */
1586 public function current_user_can_modify_post( $post ) {
1587
1588 if ( ! $post ) {
1589 return false;
1590 }
1591
1592 $post_type_object = get_post_type_object( $post->post_type );
1593
1594 // Editors and admins are fine.
1595 if ( current_user_can( $post_type_object->cap->edit_others_posts, $post->ID ) ) {
1596 return true;
1597 }
1598 // Authors and contributors can move their own stuff if it's not published.
1599 if ( current_user_can( $post_type_object->cap->edit_post, $post->ID ) && wp_get_current_user()->ID == $post->post_author && ! in_array( $post->post_status, $this->published_statuses ) ) {
1600 return true;
1601 }
1602 // Those who can publish posts can move any of their own stuff.
1603 if ( current_user_can( $post_type_object->cap->publish_posts, $post->ID ) && wp_get_current_user()->ID == $post->post_author ) {
1604 return true;
1605 }
1606
1607 return false;
1608 }
1609
1610 /**
1611 * Register settings for notifications so we can partially use the Settings API
1612 * We use the Settings API for form generation, but not saving because we have our
1613 * own way of handling the data.
1614 *
1615 * @since 0.7
1616 */
1617 public function register_settings() {
1618
1619 add_settings_section( $this->module->options_group_name . '_general', false, '__return_false', $this->module->options_group_name );
1620 add_settings_field( 'post_types', __( 'Post types to show', 'edit-flow' ), array( $this, 'settings_post_types_option' ), $this->module->options_group_name, $this->module->options_group_name . '_general' );
1621 add_settings_field( 'quick_create_post_type', __( 'Post type to create directly from calendar', 'edit-flow' ), array( $this, 'settings_quick_create_post_type_option' ), $this->module->options_group_name, $this->module->options_group_name . '_general' );
1622 add_settings_field( 'ics_subscription', __( 'Subscription in iCal or Google Calendar', 'edit-flow' ), array( $this, 'settings_ics_subscription_option' ), $this->module->options_group_name, $this->module->options_group_name . '_general' );
1623 }
1624
1625 /**
1626 * Choose the post types that should be displayed on the calendar
1627 *
1628 * @since 0.7
1629 */
1630 public function settings_post_types_option() {
1631 global $edit_flow;
1632 $edit_flow->settings->helper_option_custom_post_type( $this->module );
1633 }
1634
1635 /**
1636 * Choose the post type that should be created on the calendar
1637 *
1638 * @since 0.8
1639 */
1640 public function settings_quick_create_post_type_option() {
1641
1642 $allowed_post_types = $this->get_all_post_types();
1643
1644 echo "<select name='" . esc_attr( $this->module->options_group_name ) . "[quick_create_post_type]'>";
1645 foreach ( $allowed_post_types as $post_type => $title ) {
1646 echo "<option value='" . esc_attr( $post_type ) . "' " . selected( $post_type, $this->module->options->quick_create_post_type, false ) . '>' . esc_html( $title ) . '</option>';
1647 }
1648 echo '</select>';
1649 }
1650
1651 /**
1652 * Enable calendar subscriptions via .ics in iCal or Google Calendar
1653 *
1654 * @since 0.8
1655 */
1656 public function settings_ics_subscription_option() {
1657 $options = array(
1658 'off' => __( 'Disabled', 'edit-flow' ),
1659 'on' => __( 'Enabled', 'edit-flow' ),
1660 );
1661 echo '<select id="ics_subscription" name="' . esc_attr( $this->module->options_group_name ) . '[ics_subscription]">';
1662 foreach ( $options as $value => $label ) {
1663 echo '<option value="' . esc_attr( $value ) . '"';
1664 echo selected( $this->module->options->ics_subscription, $value );
1665 echo '>' . esc_html( $label ) . '</option>';
1666 }
1667 echo '</select>';
1668
1669
1670 $regenerate_url = add_query_arg( 'action', 'ef_calendar_regenerate_calendar_feed_secret', admin_url( 'index.php' ) );
1671 $regenerate_url = wp_nonce_url( $regenerate_url, 'ef-regenerate-ics-key' );
1672 echo '&nbsp;&nbsp;&nbsp;<a href="' . esc_url( $regenerate_url ) . '">' . esc_html__( 'Regenerate calendar feed secret', 'edit-flow' ) . '</a>';
1673 }
1674
1675 /**
1676 * Validate the data submitted by the user in calendar settings.
1677 *
1678 * @since 0.7
1679 *
1680 * @param array $new_options The new options to validate.
1681 * @return array The validated options.
1682 */
1683 public function settings_validate( $new_options ) {
1684
1685 $options = (array) $this->module->options;
1686
1687 $options['post_types'] = $this->clean_post_type_options( $new_options['post_types'], $this->module->post_type_support );
1688
1689 if ( in_array( $new_options['quick_create_post_type'], array_keys( $this->get_all_post_types() ) ) ) {
1690 $options['quick_create_post_type'] = $new_options['quick_create_post_type'];
1691 }
1692
1693 if ( 'on' != $new_options['ics_subscription'] ) {
1694 $options['ics_subscription'] = 'off';
1695 } else {
1696 $options['ics_subscription'] = 'on';
1697 }
1698
1699 return $options;
1700 }
1701
1702 /**
1703 * Settings page for calendar.
1704 */
1705 public function print_configure_view() {
1706 global $edit_flow;
1707 ?>
1708 <form class="basic-settings" action="<?php echo esc_url( menu_page_url( $this->module->settings_slug, false ) ); ?>" method="post">
1709 <?php settings_fields( $this->module->options_group_name ); ?>
1710 <?php do_settings_sections( $this->module->options_group_name ); ?>
1711 <?php
1712 echo '<input id="edit_flow_module_name" name="edit_flow_module_name" type="hidden" value="' . esc_attr( $this->module->name ) . '" />';
1713 ?>
1714 <?php submit_button(); ?>
1715 </form>
1716 <?php
1717 }
1718
1719 /**
1720 * Ajax callback to insert a post placeholder for a particular date.
1721 *
1722 * @since 0.8
1723 */
1724 public function handle_ajax_insert_post() {
1725
1726 // Nonce check.
1727 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1728 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'ef-calendar-modify' ) ) {
1729 $this->print_ajax_response( 'error', $this->module->messages['nonce-failed'] );
1730 }
1731
1732 // Check that the user has the right capabilities to add posts to the calendar (defaults to 'edit_posts').
1733 if ( ! current_user_can( $this->create_post_cap ) ) {
1734 $this->print_ajax_response( 'error', $this->module->messages['invalid-permissions'] );
1735 }
1736
1737 if ( empty( $_POST['ef_insert_date'] ) ) {
1738 $this->print_ajax_response( 'error', __( 'No date supplied.', 'edit-flow' ) );
1739 }
1740
1741 // Post type has to be visible on the calendar to create a placeholder.
1742 if ( ! in_array( $this->module->options->quick_create_post_type, $this->get_post_types_for_module( $this->module ) ) ) {
1743 $this->print_ajax_response( 'error', __( 'Please change Quick Create to use a post type viewable on the calendar.', 'edit-flow' ) );
1744 }
1745
1746 // Sanitize post values.
1747 $post_title = isset( $_POST['ef_insert_title'] ) ? sanitize_text_field( $_POST['ef_insert_title'] ) : null;
1748
1749 if ( ! $post_title ) {
1750 $post_title = esc_html__( 'Untitled', 'edit-flow' );
1751 }
1752
1753 $post_date = sanitize_text_field( $_POST['ef_insert_date'] );
1754
1755 $post_status = $this->get_default_post_status();
1756
1757 // Set new post parameters.
1758 $post_placeholder = array(
1759 'post_title' => $post_title,
1760 'post_status' => $post_status,
1761 'post_date' => date( 'Y-m-d H:i:s', strtotime( $post_date ) ),
1762 'post_type' => $this->module->options->quick_create_post_type,
1763 );
1764
1765 // By default, adding a post to the calendar won't set the timestamp.
1766 // If the user desires that to be the behavior, they can set the result of this filter to 'true'.
1767 // With how WordPress works internally, setting 'post_date_gmt' will set the timestamp.
1768 if ( apply_filters( 'ef_calendar_allow_ajax_to_set_timestamp', false ) ) {
1769 $post_placeholder['post_date_gmt'] = date( 'Y-m-d H:i:s', strtotime( $post_date ) );
1770 }
1771
1772 // Create the post.
1773 $post_id = wp_insert_post( $post_placeholder );
1774
1775 if ( $post_id ) {
1776 $post = get_post( $post_id );
1777
1778 // Generate the HTML for the post item so it can be injected.
1779 $post_li_html = $this->generate_post_li_html( $post, $post_date );
1780
1781 // Announce success and send back the html to inject.
1782 $this->print_ajax_response( 'success', $post_li_html );
1783 } else {
1784 $this->print_ajax_response( 'error', __( 'Post could not be created', 'edit-flow' ) );
1785 }
1786 }
1787
1788 /**
1789 * Returns the singular label for the posts that are quick-created on the calendar.
1790 *
1791 * @return string Singular label for a post-type.
1792 */
1793 public function get_quick_create_post_type_name() {
1794
1795 $post_type_slug = $this->module->options->quick_create_post_type;
1796 $post_type_obj = get_post_type_object( $post_type_slug );
1797
1798 return $post_type_obj->labels->singular_name ? $post_type_obj->labels->singular_name : $post_type_slug;
1799 }
1800
1801 /**
1802 * Update the metadata from the calendar via AJAX.
1803 *
1804 * @since 0.8
1805 */
1806 public function handle_ajax_update_metadata() {
1807 global $wpdb;
1808
1809 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce value passed directly to wp_verify_nonce().
1810 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'ef-calendar-modify' ) ) {
1811 $this->print_ajax_response( 'error', $this->module->messages['nonce-failed'] );
1812 }
1813
1814 if ( ! isset( $_POST['post_id'] ) ) {
1815 $this->print_ajax_response( 'error', $this->module->messages['missing-post'] );
1816 }
1817
1818 // Check that we got a proper post.
1819 $post_id = (int) $_POST['post_id'];
1820 $post = get_post( $post_id );
1821
1822 if ( ! $post ) {
1823 $this->print_ajax_response( 'error', $this->module->messages['missing-post'] );
1824 }
1825
1826
1827 if ( 'page' == $post->post_type ) {
1828 $edit_check = 'edit_page';
1829 } else {
1830 $edit_check = 'edit_post';
1831 }
1832
1833 if ( ! current_user_can( $edit_check, $post->ID ) ) {
1834 $this->print_ajax_response( 'error', $this->module->messages['invalid-permissions'] );
1835 }
1836
1837 // Check that the user can modify the post.
1838 if ( ! $this->current_user_can_modify_post( $post ) ) {
1839 $this->print_ajax_response( 'error', $this->module->messages['invalid-permissions'] );
1840 }
1841
1842 $default_types = array(
1843 'author',
1844 'taxonomy',
1845 );
1846
1847 $metadata_types = array();
1848
1849 if ( ! $this->module_enabled( 'editorial_metadata' ) ) {
1850 $this->print_ajax_response( 'error', $this->module->messages['update-error'] );
1851 }
1852
1853 $metadata_types = array_keys( EditFlow()->editorial_metadata->get_supported_metadata_types() );
1854
1855 // Update an editorial metadata field.
1856 $metadata_term = isset( $_POST['metadata_term'] ) ? sanitize_text_field( wp_unslash( $_POST['metadata_term'] ) ) : '';
1857 $metadata_type = isset( $_POST['metadata_type'] ) ? sanitize_text_field( wp_unslash( $_POST['metadata_type'] ) ) : '';
1858 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Value is sanitized below based on metadata type.
1859 $incoming_metadata_value = isset( $_POST['metadata_value'] ) ? wp_unslash( $_POST['metadata_value'] ) : '';
1860
1861 if ( in_array( $metadata_type, $metadata_types, true ) ) {
1862 // Validate the term slug refers to an existing editorial metadata term before using it in a meta key.
1863 if ( '' === $metadata_term || ! EditFlow()->editorial_metadata->get_editorial_metadata_term_by( 'slug', $metadata_term ) ) {
1864 $this->print_ajax_response( 'error', $this->module->messages['update-error'] );
1865 }
1866
1867 $post_meta_key = '_ef_editorial_meta_' . $metadata_type . '_' . $metadata_term;
1868
1869 // Javascript date parsing is terrible, so use strtotime in PHP.
1870 if ( 'date' === $metadata_type ) {
1871 $metadata_value = strtotime( sanitize_text_field( $incoming_metadata_value ) );
1872 } else {
1873 $metadata_value = sanitize_text_field( $incoming_metadata_value );
1874 }
1875
1876 update_post_meta( $post->ID, $post_meta_key, $metadata_value );
1877 $response = 'success';
1878 } else {
1879 switch ( $metadata_type ) {
1880 case 'taxonomy':
1881 // Validate that the term refers to a taxonomy registered for this post type.
1882 if ( '' === $metadata_term || ! in_array( $metadata_term, get_object_taxonomies( $post->post_type ), true ) ) {
1883 $this->print_ajax_response( 'error', $this->module->messages['update-error'] );
1884 }
1885
1886 // Resolve the submitted value(s) to EXISTING term IDs only. This endpoint
1887 // must not create new terms: passing free-text names to wp_set_post_terms()
1888 // would let any user who can edit a single post create arbitrary taxonomy
1889 // terms, which normally requires the taxonomy's term-management capability.
1890 $incoming_terms = is_array( $incoming_metadata_value ) ? $incoming_metadata_value : array( $incoming_metadata_value );
1891 $term_ids = array();
1892 foreach ( $incoming_terms as $incoming_term ) {
1893 $incoming_term = sanitize_text_field( $incoming_term );
1894 if ( '' === $incoming_term ) {
1895 continue;
1896 }
1897 if ( is_numeric( $incoming_term ) ) {
1898 $existing_term = get_term( (int) $incoming_term, $metadata_term );
1899 } else {
1900 $existing_term = get_term_by( 'slug', sanitize_title( $incoming_term ), $metadata_term );
1901 if ( ! $existing_term ) {
1902 $existing_term = get_term_by( 'name', $incoming_term, $metadata_term );
1903 }
1904 }
1905 if ( $existing_term instanceof WP_Term ) {
1906 $term_ids[] = (int) $existing_term->term_id;
1907 } else {
1908 // A non-empty value matching no existing term: reject rather than create one.
1909 $this->print_ajax_response( 'error', $this->module->messages['update-error'] );
1910 }
1911 }
1912
1913 $response = wp_set_post_terms( $post->ID, $term_ids, $metadata_term, false );
1914 break;
1915 default:
1916 $response = new WP_Error( 'invalid-type', __( 'Invalid metadata type', 'edit-flow' ) );
1917 break;
1918 }
1919 }
1920
1921 // Assuming we've got to this point, just regurgitate the value.
1922 if ( ! is_wp_error( $response ) ) {
1923 $this->print_ajax_response( 'success', $incoming_metadata_value );
1924 } else {
1925 $this->print_ajax_response( 'error', __( 'Metadata could not be updated.', 'edit-flow' ) );
1926 }
1927 }
1928
1929 /**
1930 * Get the filter names used in calendar queries.
1931 *
1932 * @return array Filter names.
1933 */
1934 public function calendar_filters() {
1935 $select_filter_names = array();
1936
1937 $select_filter_names['post_status'] = 'post_status';
1938 $select_filter_names['cat'] = 'cat';
1939 $select_filter_names['author'] = 'author';
1940 $select_filter_names['type'] = 'cpt';
1941 $select_filter_name['num_weeks'] = 'num_weeks';
1942
1943 return apply_filters( 'ef_calendar_filter_names', $select_filter_names );
1944 }
1945
1946 /**
1947 * Sanitize a $_GET or similar filter being used on the calendar.
1948 *
1949 * @since 0.8
1950 *
1951 * @param string $key Filter being sanitized.
1952 * @param string $dirty_value Value to be sanitized.
1953 * @return string|int|false $sanitized_value Safe to use value.
1954 */
1955 public function sanitize_filter( $key, $dirty_value ) {
1956
1957 switch ( $key ) {
1958 case 'post_status':
1959 // Whitelist-based validation for this parameter.
1960 $valid_statuses = wp_list_pluck( $this->get_calendar_post_stati(), 'name' );
1961 $valid_statuses[] = 'unpublish';
1962
1963 if ( in_array( $dirty_value, $valid_statuses ) ) {
1964 return $dirty_value;
1965 } else {
1966 return '';
1967 }
1968 case 'cpt':
1969 $cpt = sanitize_key( $dirty_value );
1970 $supported_post_types = $this->get_post_types_for_module( $this->module );
1971 if ( $cpt && in_array( $cpt, $supported_post_types ) ) {
1972 return $cpt;
1973 } else {
1974 return '';
1975 }
1976 case 'start_date':
1977 return date( 'Y-m-d', strtotime( $dirty_value ) );
1978 case 'cat':
1979 case 'author':
1980 return intval( $dirty_value );
1981 case 'num_weeks':
1982 $num_weeks = intval( $dirty_value );
1983 if ( $num_weeks <= 0 ) {
1984 return $this->total_weeks;
1985 } elseif ( $num_weeks > $this->max_weeks ) {
1986 return $this->max_weeks;
1987 } else {
1988 return $num_weeks;
1989 }
1990 default:
1991 return false;
1992 }
1993 }
1994
1995 /**
1996 * Cache the post date before update to work around core resetting draft dates.
1997 *
1998 * The calendar uses 'post_date' field to store the position on the calendar.
1999 * If a post has a core post status assigned (e.g. 'draft' or 'pending'), the `post_date`
2000 * field will be reset when `wp_update_post()` is used.
2001 *
2002 * This method temporarily caches the `post_date` field if it needs to be restored.
2003 *
2004 * @see http://core.trac.wordpress.org/browser/tags/3.7.1/src/wp-includes/post.php#L2998
2005 * @uses fix_post_date_on_update_part_two()
2006 *
2007 * @param int $post_ID Post ID.
2008 * @param array $data Post data being saved.
2009 */
2010 public function fix_post_date_on_update_part_one( $post_ID, $data ) {
2011
2012 $post = get_post( $post_ID );
2013
2014 // `post_date` is only nooped for these three statuses,
2015 // but don't try to persist if `post_date_gmt` is set.
2016 if ( ! in_array( $post->post_status, array( 'draft', 'pending', 'auto-draft' ) )
2017 || '0000-00-00 00:00:00' !== $post->post_date_gmt
2018 || '0000-00-00 00:00:00' !== $data['post_date_gmt'] ) {
2019 return;
2020 }
2021
2022 $this->post_date_cache[ $post_ID ] = $post->post_date;
2023 }
2024
2025 /**
2026 * Restore the post date after update to work around core resetting draft dates.
2027 *
2028 * The calendar uses 'post_date' field to store the position on the calendar.
2029 * If a post has a core post status assigned (e.g. 'draft' or 'pending'), the `post_date`
2030 * field will be reset when `wp_update_post()` is used.
2031 *
2032 * This method restores the `post_date` field if it needs to be restored.
2033 *
2034 * @see http://core.trac.wordpress.org/browser/tags/3.7.1/src/wp-includes/post.php#L2998
2035 * @uses fix_post_date_on_update_part_one()
2036 *
2037 * @param int $post_ID Post ID.
2038 * @param WP_Post $post_after Post object after the update.
2039 * @param WP_Post $post_before Post object before the update.
2040 */
2041 public function fix_post_date_on_update_part_two( $post_ID, $post_after, $post_before ) {
2042 global $wpdb;
2043
2044 if ( empty( $this->post_date_cache[ $post_ID ] ) ) {
2045 return;
2046 }
2047
2048 $post_date = $this->post_date_cache[ $post_ID ];
2049 unset( $this->post_date_cache[ $post_ID ] );
2050 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Core workaround for custom status date handling.
2051 $wpdb->update( $wpdb->posts, array( 'post_date' => $post_date ), array( 'ID' => $post_ID ) );
2052 clean_post_cache( $post_ID );
2053 }
2054
2055 /**
2056 * Returns a list of custom status objects used by the calendar.
2057 *
2058 * @return array An array of StdClass objects representing statuses.
2059 */
2060 public function get_calendar_post_stati() {
2061 $post_stati = get_post_stati( array(), 'object' );
2062 $custom_status_slugs = wp_list_pluck( $this->get_post_statuses(), 'slug' );
2063 $custom_status_slugs[] = 'future';
2064 $custom_status_slugs[] = 'publish';
2065
2066 $custom_status_slug_keys = array_flip( $custom_status_slugs );
2067
2068 $final_statuses = [];
2069
2070 foreach ( $post_stati as $status ) {
2071 if ( ! empty( $custom_status_slug_keys[ $status->name ] ) ) {
2072 $final_statuses[] = $status;
2073 }
2074 }
2075
2076 return apply_filters( 'ef_calendar_post_stati', $final_statuses );
2077 }
2078
2079 /**
2080 * Get users for the calendar dropdown filter.
2081 *
2082 * @return array Array of WP_User objects.
2083 */
2084 public function get_calendar_users() {
2085 $users_args = array(
2086 'orderby' => 'display_name',
2087 'order' => 'ASC',
2088 'blog_id' => get_current_blog_id(),
2089 );
2090
2091 $users_args = apply_filters( 'ef_calendar_dropdown_users_args', $users_args );
2092
2093 return get_users( $users_args );
2094 }
2095
2096 /**
2097 * Get categories for the calendar dropdown filter.
2098 *
2099 * @return array Array of term objects.
2100 */
2101 public function get_calendar_categories() {
2102 $categories_args = array(
2103 'orderby' => 'id',
2104 'order' => 'ASC',
2105 'hide_empty' => 0,
2106 'hierarchical' => 0,
2107 'taxonomy' => 'category',
2108 );
2109
2110 return get_terms( $categories_args );
2111 }
2112
2113 /**
2114 * Get the frontend configuration for the calendar React component.
2115 *
2116 * @return array Configuration array for the frontend.
2117 */
2118 public function get_calendar_frontend_config() {
2119 global $wp_version;
2120
2121 $all_post_types = get_post_types( null, 'objects' );
2122
2123 $config = array(
2124 'POST_STATI' => $this->get_calendar_post_stati(),
2125 'USERS' => array_map(
2126 function ( $item ) {
2127 return array(
2128 'id' => $item->ID,
2129 'display_name' => $item->display_name,
2130 );
2131 },
2132 $this->get_calendar_users()
2133 ),
2134 'CATEGORIES' => $this->get_calendar_categories(),
2135 'POST_TYPES' => array_map( function ( $item ) use ( $all_post_types ) {
2136 return $all_post_types[ $item ];
2137 }, $this->get_post_types_for_module( $this->module ) ),
2138 'NUM_WEEKS' => array(
2139 'MAX' => $this->max_weeks,
2140 'DEFAULT' => $this->total_weeks,
2141 ),
2142 // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- Used for date calculation in calendar context.
2143 'BEGINNING_OF_WEEK' => $this->get_beginning_of_week( date( 'Y-m-d', current_time( 'timestamp' ) ) ),
2144 'FILTERS' => $this->get_filters(),
2145 'PAGE_URL' => menu_page_url( $this->module->slug, false ),
2146 'WP_VERSION' => $wp_version,
2147 );
2148
2149 return apply_filters( 'ef_calendar_frontend_config', $config );
2150 }
2151 } // EF_Calendar
2152
2153 } // End class_exists check for EF_Calendar.
2154