<?php
/**
 * GravityCalendar Add-On - Gravity Forms feeds
 *
 * @package   GravityCalendar
 * @license   GPL2+
 * @author    Katz Web Services, Inc.
 * @link      https://www.gravitykit.com
 * @copyright Copyright 2019, Katz Web Services, Inc.
 */

defined( 'ABSPATH' ) || exit; // Exit if accessed directly

use Gravity_Forms\Gravity_Forms\Settings\Fields\Base;
use GravityKit\GravityCalendar\QueryFilters\QueryFilters;
use GravityKit\GravityCalendar\Spatie\IcalendarGenerator\Components\Calendar;
use GravityKit\GravityCalendar\Spatie\IcalendarGenerator\Components\Event;
use GV\GF_Entry;
use Psr\Log\LoggerInterface;

GFForms::include_feed_addon_framework();

class GV_Extension_Calendar_Feed extends GFFeedAddOn {
	protected $_title = GV_CALENDAR_TITLE;

	protected $_short_title = GV_CALENDAR_TITLE;

	protected $_slug = GV_CALENDAR_SLUG;

	protected $_version = GV_CALENDAR_VERSION;

	protected $_path = GV_CALENDAR_RELATIVE_PATH;

	protected $_full_path = __FILE__;

	protected $_min_gravityforms_version = GV_CALENDAR_MIN_GF_VERSION;

	private static $_instance;

	/* Permissions */
	protected $_capabilities_settings_page = 'manage_options';

	protected $_capabilities_form_settings = 'gravityview_calendar';

	protected $_capabilities_uninstall = 'gravityview_calendar_uninstall';

	/* Members plugin integration */
	protected $_capabilities = [ 'gravityview_calendar', 'gravityview_calendar_uninstall' ];

	/**
	 * @since 1.5.7
	 *
	 * @var string GravityKit\GravityCalendar\QueryFilters\QueryFilters instance.
	 */
	private $query_filters;

	/**
	 * @since 1.1
	 *
	 * @var int Maximum number of events to fetch/display.
	 */
	const DEFAULT_EVENT_LIMIT = 1000;

	/**
	 * @since 2.4
	 *
	 * @var int Maximum number of events to fetch in a single DB query.
	 */
	const DEFAULT_DB_BATCH_SIZE = 500;

	/**
	 * Option key used to store remote calendar feeds.
	 *
	 * @since 2.5
	 */
	const CACHE_KEY = '_gk_calendar_feed_cache_%s';

	/**
	 * Maximum size of the cache, per remote calendar feed.
	 *
	 * @since 2.5
	 */
	const CACHE_SIZE_LIMIT = 524288; // 512KB.

	/**
	 * How long to cache remote calendar feeds.
	 *
	 * @since 2.5
	 */
	const CACHE_EXPIRATION = 600; // 10 minutes.

	/**
	 * Creates the add-on instance.
	 *
	 * @since 1.5.7
	 *
	 * @throws Exception
	 */
	public function __construct() {
		$this->query_filters = new QueryFilters();

		add_action( 'rest_api_init', [ $this, 'register_routes' ] );

		add_filter( 'gk/foundation/integrations/helpscout/display', [ $this, 'maybe_display_helpscout_beacon' ] );

		add_action( 'gravityview/calendar/enqueue-scripts', [ $this, 'register_ical_scripts' ] );

		add_filter( 'gform_notification', [ $this, 'attach_file_to_notification' ], 10, 3 );

		parent::__construct();
	}

	/**
	 * Returns an instance of this class.
	 *
	 * @since 1.0.0
	 *
	 * @return GV_Extension_Calendar_Feed
	 */
	public static function get_instance() {

		if ( self::$_instance === null ) {
			self::$_instance = new GV_Extension_Calendar_Feed();
		}

		return self::$_instance;
	}

	/**
	 * Registers iCalendar scripts.
	 *
	 * @param integer $feed_id
	 */
	public function register_ical_scripts( $feed_id ) {
		if ( ! $feed_id ) {
			return;
		}

		$feed = $this->get_feed( $feed_id );

		if ( ! rgars( $feed, 'meta/iCalUrl' ) ) {
			return;
		}

		$min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) || isset( $_GET['gform_debug'] ) ? '' : '.min';
		wp_enqueue_script( 'gv-fullcalendar-ical', GV_CALENDAR_URL . 'lib/fullcalendar/ical/ical' . $min . '.js', [], filemtime( GV_CALENDAR_PATH . 'lib/fullcalendar/ical/ical' . $min . '.js' ), true );
		wp_enqueue_script( 'gv-fullcalendar-icalendar', GV_CALENDAR_URL . 'lib/fullcalendar/icalendar/main.global' . $min . '.js', [], filemtime( GV_CALENDAR_PATH . 'lib/fullcalendar/icalendar/main.global' . $min . '.js' ), true );
	}

	/**
	 * Adds our Entry Date and Entry Time options to the field mapping <select> dropdowns.
	 *
	 * @since 1.2
	 *
	 * @param array $args
	 * @param array $form
	 *
	 * @return array
	 */
	public function get_form_fields_as_choices( $form, $args = [] ) {
		$fields = parent::get_form_fields_as_choices( $form, $args );

		$custom_fields = [
			'date_created'      => [
				'value' => 'date_created',
				'label' => esc_html__( 'Entry Date (Created)', 'gk-gravitycalendar' ),
			],
			'date_updated'      => [
				'value' => 'date_updated',
				'label' => esc_html__( 'Entry Date (Updated)', 'gk-gravitycalendar' ),
			],
			'date_created_time' => [
				'value' => 'date_created',
				'label' => esc_html__( 'Entry Time (Created)', 'gk-gravitycalendar' ),
			],
			'date_updated_time' => [
				'value' => 'date_updated',
				'label' => esc_html__( 'Entry Time (Updated)', 'gk-gravitycalendar' ),
			],
		];

		$field_types = (array) rgar( $args, 'field_types' );

		foreach ( $field_types as $field_type ) {
			if ( ! empty( $custom_fields[ $field_type ] ) ) {
				$fields[] = $custom_fields[ $field_type ];
			}
		}

		return $fields;
	}

	/**
	 * Checks whether the current page is a GravityCalendar page.
	 *
	 * @since    2.1
	 *
	 * @return bool True: This context is one where plugin scripts and styles should be loaded. False: otherwise!
	 * @internal Don't rely on this function. It's for internal use only.
	 */
	public function should_enqueue_assets() {
		if (
            $this->is_gutenberg_editor()
            || $this->is_feed_edit_page()
            || $this->is_feed_list_page()
            || $this->is_plugin_settings( GV_CALENDAR_SLUG )
        ) {
			return true;
		}

		return false;
	}

	/**
	 * Returns the callback function for true/false values returned by {@see should_enqueue_assets()}.
	 *
	 * @return string If on Gutenberg or Feed Edit page, returns '__return_true'. Otherwise, returns '__return_false'.
	 */
	private function should_enqueue_assets_callback() {
		return $this->should_enqueue_assets() ? '__return_true' : '__return_false';
	}


	/**
	 * Determines if the current request is for an administrative interface page; logic re-used from `\GV\Request`.
	 *
	 * @since 1.5.1
	 *
	 * @return boolean
	 */
	public function is_admin() {
		$doing_ajax          = defined( 'DOING_AJAX' ) && DOING_AJAX;
		$load_scripts_styles = preg_match( '#^/wp-admin/load-(scripts|styles).php$#', rgar( $_SERVER, 'SCRIPT_NAME' ) );

		return is_admin() && ! ( $doing_ajax || $load_scripts_styles );
	}

	/**
	 * Determines if the current page is a Gutenberg editor.
	 *
	 * @since 1.5.1
	 *
	 * @return boolean
	 */
	public function is_gutenberg_editor() {
		$current_screen    = function_exists( 'get_current_screen' ) ? get_current_screen() : false;
		$is_block_editor   = $current_screen && method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor();
		$is_gutenberg_page = function_exists( 'is_gutenberg_page' ) && is_gutenberg_page();

		return $is_block_editor || $is_gutenberg_page;
	}

	// # SCRIPTS & STYLES -----------------------------------------------------------------------------------------------

	/**
	 * Returns the scripts which should be enqueued.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public function scripts() {
		$feed_settings = $this->get_current_settings();

		if ( $this->is_feed_edit_page() ) {
			$conditional_logic = rgar( $feed_settings, 'conditional_logic', 'null' );

			if ( $this->get_current_form() ) {
				try {
					$this->query_filters->set_form( $this->get_current_form() );
				} catch ( Exception $exception ) {

				}
			}

			if ( 'null' === $conditional_logic ) {
				// Detect old conditional logic (Calendar ≤2.1.10)
				$conditional_logic = rgars( $feed_settings, 'feed_condition_conditional_logic_object/conditionalLogic', 'null' );

				if ( ! empty( $conditional_logic ) && 'null' !== $conditional_logic ) {
					$conditional_logic = $this->query_filters->convert_gf_conditional_logic( $conditional_logic );
				}
			}

			$this->query_filters->enqueue_scripts(
				[
					'input_element_name' => $this->prefix_field_name( 'conditional_logic' ),
					'conditions'         => $conditional_logic,
				]
			);
		}

		$min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) || isset( $_GET['gform_debug'] ) ? '' : '.min';

		$should_enqueue_assets = $this->should_enqueue_assets_callback();

		$scripts = [
			[
				'handle'  => 'fullcalendar-core-js',
				/**
				 * @filter `gravityview_calendar_script_src` Modify the FullCalendar core script used
				 *
				 * @param string $path Full URL to the jQuery FullCalendar file
				 */
				'src'     => apply_filters( 'gravityview/calendar/scripts/fullcalendar', GV_CALENDAR_URL . 'lib/fullcalendar/main' . $min . '.js' ),
				'version' => GV_CALENDAR_FULLCALENDAR_VERSION,
				'deps'    => [ 'jquery' ],
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
			[
				'handle'  => 'gv-calendar-popper-js',
				'src'     => GV_CALENDAR_URL . 'lib/tooltip/popper.min.js',
				'version' => filemtime( GV_CALENDAR_PATH . 'lib/tooltip/popper.min.js' ),
				'deps'    => [],
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
			[
				'handle'  => 'gv-calendar-tooltip-js',
				'src'     => GV_CALENDAR_URL . 'lib/tooltip/tippy.umd.min.js',
				'version' => filemtime( GV_CALENDAR_PATH . 'lib/tooltip/tippy.umd.min.js' ),
				'deps'    => [ 'gv-calendar-popper-js' ],
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
			[
				'handle'    => 'gv-fullcalendar-js',
				'src'       => GV_CALENDAR_URL . 'assets/js/fullcalendar-render' . $min . '.js',
				'version'   => filemtime( GV_CALENDAR_PATH . 'assets/js/fullcalendar-render' . $min . '.js' ),
				'deps'      => [ 'fullcalendar-core-js', 'gv-calendar-tooltip-js', 'gv-calendar-popper-js' ],
				'enqueue'   => [
					$should_enqueue_assets,
				],
				'in_footer' => true,
			],

			[
				'handle'    => 'gv-fullcalendar-admin',
				'src'       => GV_CALENDAR_URL . 'assets/js/admin' . $min . '.js',
				'version'   => filemtime( GV_CALENDAR_PATH . 'assets/js/admin' . $min . '.js' ),
				'deps'      => [
					'jquery',
					'wp-a11y',
					'wp-i18n',
					'clipboard',
					'jquery-ui-sortable',
					'jquery-ui-droppable',
					'gv-fullcalendar-js',
				],
				'enqueue'   => [
					$should_enqueue_assets,
				],
				'callback'  => [ $this, 'localize_admin_script' ],
				'in_footer' => true,
			],
		];

		return array_merge( parent::scripts(), $scripts );
	}

	/**
	 * Localizes the admin script that has been added via self::scripts().
	 *
	 * @return void
	 */
	public function localize_admin_script() {

		$strings = [
			'copiedText' => esc_attr__( 'The URL has been copied to your clipboard.', 'gk-gravitycalendar' ),
		];

		wp_localize_script( 'gv-fullcalendar-admin', 'gvCalendarStrings', $strings );
	}

	/**
	 * Returns the stylesheets which should be enqueued.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public function styles( $options = [] ) {
		if ( $this->is_feed_edit_page() ) {
			$this->query_filters->enqueue_styles();
		}

		$min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) || isset( $_GET['gform_debug'] ) ? '' : '.min';

		$should_enqueue_assets = $this->should_enqueue_assets_callback();

		$styles = [
			[
				'handle'  => 'gv-fullcalendar-admin-css',
				'src'     => GV_CALENDAR_URL . 'assets/css/admin.css',
				'version' => filemtime( GV_CALENDAR_PATH . 'assets/css/admin.css' ),
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
			[
				'handle'  => 'gv-fullcalendar-css',
				'src'     => GV_CALENDAR_URL . 'assets/css/gravityview-fullcalendar.css',
				'version' => filemtime( GV_CALENDAR_PATH . 'assets/css/gravityview-fullcalendar.css' ),
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
			[
				'handle'  => 'fullcalendar-core-css',
				'src'     => GV_CALENDAR_URL . 'lib/fullcalendar/main' . $min . '.css',
				'version' => GV_CALENDAR_FULLCALENDAR_VERSION,
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
			[
				'handle'  => 'gv-calendar-tooltip-css',
				'src'     => GV_CALENDAR_URL . 'lib/tooltip/tippy.css',
				'version' => filemtime( GV_CALENDAR_PATH . 'lib/tooltip/tippy.css' ),
				'enqueue' => [
					$should_enqueue_assets,
				],
			],
		];

		if ( $this->should_enqueue_assets() ) {
			$styles[] = [
				'handle'  => $this->_slug . '-admin',
				'src'     => GV_CALENDAR_URL . 'assets/css/admin-settings.css',
				'version' => $this->_version,
				'enqueue' => [
					'__return_true',
				],
			];
		}

		return array_merge( parent::styles(), $styles );
	}

	/**
	 * Renders settings and adds calendar data inline in GF v2.5+
	 *
	 * @since 1.4
	 *
	 * @return void
	 */
	public function feed_settings_init() {

		parent::feed_settings_init();

		$this->settings();
	}

	/**
	 * Renders settings and adds calendar data inline
	 *
	 * @since 1.0.0
	 *
	 * @param array $sections Configuration array containing all fields to be rendered grouped into sections
	 *
	 * @return void
	 */
	public function settings( $sections = [] ) {

		parent::settings( $sections );

		$feed_id = $this->get_current_feed_id();
		$form    = $this->get_current_form();
		$form_id = ( ! empty( $form['id'] ) ) ? $form['id'] : null;

		if ( $feed_id === '' ) {
			return;
		}

		$this->output_calendar_scripts( $feed_id, $form_id );
	}

	/**
	 * Configures the settings which should be rendered on the feed edit page in the Form Settings > Calendar Feed Add-On area.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public function feed_settings_fields() {
		$fc_locales        = [];
		$site_locale       = get_locale();
		$default_fc_locale = 'en';

		foreach ( $this->get_fc_locales() as $fc_locale ) {

			$fc_locales[] = [
				'label' => $fc_locale['lang'],
				'value' => $fc_locale['fc_locale'],
			];

			// If there an FC equivalent of the site's locale, set it as a default choice
			if ( ! empty( $fc_locale['wp_locale'] ) && $site_locale === $fc_locale['wp_locale'] ) {
				$default_fc_locale = $fc_locale['fc_locale'];
			}
		}

		/**
		 * @filter `gravityview/calendar/default_locale` Override default calendar locale (automatically set to 'en' or WP language code)
		 *
		 * @since  1.3
		 *
		 * @param string $default_fc_locale FC locale
		 */
		$default_fc_locale = apply_filters( 'gravityview/calendar/default_locale', $default_fc_locale );

		$is_new_feed = ( 0 === (int) $this->get_current_feed_id() );

		// translators: Do not translate the words inside the [] square brackets; they are replaced.
		$is_secure_description = __( 'This will require a [code]secret[/code] attribute on shortcodes and other requests. This is useful for preventing unauthorized access to your calendar.', 'gk-gravitycalendar' );

		if ( ! $is_new_feed ) {
			// translators: Do not translate the words inside the [] square brackets; they are replaced.
			$is_secure_description .= ' ' . esc_html__( '[strong]When enabled, existing shortcodes and blocks must be updated[/strong] to include the [code]secret[/code] attribute.', 'gk-gravitycalendar' );
		}

		return [
			[
				'fields' => [
					[
						'name'     => 'feedName',
						'label'    => esc_html__( 'Feed Name', 'gk-gravitycalendar' ),
						'required' => true,
						'type'     => 'text',
						'class'    => 'medium',
						'tooltip'  => sprintf( '<h6>%s</h6> %s', esc_html__( 'Feed Name', 'gk-gravitycalendar' ), esc_html__( 'Enter a feed name to uniquely identify this configuration.', 'gk-gravitycalendar' ) ),
					],
					[
						'name'        => 'is_secure',
						'label'       => __( 'Enable security for this calendar', 'gk-gravitycalendar' ),
						'description' => strtr( $is_secure_description, [
                            '[code]'  => '<code>',
                            '[/code]' => '</code>',
                            '[strong]' => '<strong>',
                            '[/strong]' => '</strong>'
                        ] ),
						'type'        => 'toggle',
                        'default_value' => $is_new_feed, // Default to true for new feeds, false for existing feeds.
					],
					[
						'name' => 'calendarembed',
						'type' => 'calendar_embed',
					],
				],
			],
			[
				'title'  => esc_html__( 'Calendar Fields', 'gk-gravitycalendar' ),
				'fields' => [
					[
						'name'     => 'startdate',
						'label'    => esc_html__( 'Start Date', 'gk-gravitycalendar' ),
						'required' => true,
						'type'     => 'field_select',
						'args'     => [
							'field_types' => apply_filters( 'gravityview/calendar/settings/fields/date', [ 'date', 'date_created', 'date_updated', 'event' ] ),
						],
						'class'    => 'medium',
					],
					[
						'name'        => 'enddate',
						'label'       => esc_html__( 'End Date', 'gk-gravitycalendar' ),
						'required'    => false,
						'type'        => 'field_select',
						'args'        => [
							'field_types' => apply_filters( 'gravityview/calendar/settings/fields/date', [ 'date', 'date_created', 'date_updated', 'event' ] ),
						],
						'class'       => 'medium',
						'description' => esc_html__( 'If left blank, the Start Date will be used.', 'gk-gravitycalendar' ),
					],
					[
						'name'        => 'starttime',
						'label'       => esc_html__( 'Start Time', 'gk-gravitycalendar' ),
						'required'    => false,
						'type'        => 'field_select',
						'args'        => [
							'field_types' => apply_filters( 'gravityview/calendar/settings/fields/time', [ 'time', 'date_created_time', 'date_updated_time', 'event' ] ),
						],
						'class'       => 'medium',
						'description' => esc_html__( 'If no start time is provided, events will be shown as all-day.', 'gk-gravitycalendar' ),
					],
					[
						'name'     => 'endtime',
						'label'    => esc_html__( 'End Time', 'gk-gravitycalendar' ),
						'required' => false,
						'type'     => 'field_select',
						'args'     => [
							'field_types' => apply_filters( 'gravityview/calendar/settings/fields/time', [ 'time', 'date_created_time', 'date_updated_time', 'event' ] ),
						],
						'class'    => 'medium',
					],
					[
						'name'     => 'eventtitle',
						'label'    => esc_html__( 'Event Title', 'gk-gravitycalendar' ),
						'required' => true,
						'type'     => 'text',
						'class'    => 'medium merge-tag-support mt-position-right mt-hide_all_fields',
					],
					[
						'name'       => 'eventdescription',
						'label'      => esc_html__( 'Event Description', 'gk-gravitycalendar' ),
						'required'   => false,
						'allow_html' => true,
						'type'       => 'textarea',
						'class'      => 'medium merge-tag-support mt-position-right mt-hide_all_fields',
					],
					[
						'name'     => 'eventlocation',
						'label'    => esc_html__( 'Event Location', 'gk-gravitycalendar' ),
						'required' => false,
						'args'     => [
							'field_types' => apply_filters( 'gravityview/calendar/settings/fields/location', [ 'address' ] ),
						],
						'type'     => 'text',
						'class'    => 'medium merge-tag-support mt-position-right mt-hide_all_fields',
					],
					[
						'name'        => 'eventurl',
						'label'       => esc_html__( 'Event URL', 'gk-gravitycalendar' ),
						'required'    => false,
						'type'        => 'field_select',
						'args'        => [
							'field_types' => apply_filters( 'gravityview/calendar/settings/fields/url', [ 'url', 'website' ] ),
						],
						'class'       => 'medium',
						'description' => esc_html__( 'A URL that will be visited when this event is clicked by the user.', 'gk-gravitycalendar' ),
					],

					[
						'name'          => 'eventcolor',
						'label'         => esc_html__( 'Event Color', 'gk-gravitycalendar' ),
						'required'      => false,
						'type'          => 'radio',
						'choices'       => [
							[
								'label' => __( 'Red', 'gk-gravitycalendar' ),
								'value' => '#d50000',
								'icon'  => 'fa-circle gv-cal-color gv-red',
							],
							[
								'label' => __( 'Orange', 'gk-gravitycalendar' ),
								'value' => '#ef6c00',
								'icon'  => 'fa-circle gv-cal-color gv-orange',
							],
							[
								'label' => __( 'Yellow', 'gk-gravitycalendar' ),
								'value' => '#f6bf26',
								'icon'  => 'fa-circle gv-cal-color gv-yellow',
							],
							[
								'label' => __( 'Green', 'gk-gravitycalendar' ),
								'value' => '#0c8043',
								'icon'  => 'fa-circle gv-cal-color gv-green',
							],
							[
								'label' => __( 'Light Blue', 'gk-gravitycalendar' ),
								'value' => '#4286f5',
								'icon'  => 'fa-circle gv-cal-color gv-light-blue',
							],
							[
								'label' => __( 'Blue', 'gk-gravitycalendar' ),
								'value' => '#3f51b5',
								'icon'  => 'fa-circle gv-cal-color gv-blue',
							],
							[
								'label' => __( 'Purple', 'gk-gravitycalendar' ),
								'value' => '#8f24ab',
								'icon'  => 'fa-circle gv-cal-color gv-purple',
							],
							[
								'label' => __( 'Dark Grey', 'gk-gravitycalendar' ),
								'value' => '#616161',
								'icon'  => 'fa-circle gv-cal-color gv-dark-grey',
							],
							[
								'label' => __( 'Light Grey', 'gk-gravitycalendar' ),
								'value' => '#a79b8e',
								'icon'  => 'fa-circle gv-cal-color gv-light-grey',
							],
						],
						'default_value' => '#4286f5',
						'onchange'      => 'gvCalendar[Object.keys(gvCalendar)[0]]["instance"].setOption("eventColor", this.value);',
						'class'         => 'medium',
						'description'   => esc_html__( 'Sets the background and border colors for all events on the calendar.', 'gk-gravitycalendar' ),
					],

					[
						'name'                => 'iCalUrl',
						'label'               => esc_html__( 'Display Events from Another Calendar Feed', 'gk-gravitycalendar' ),
						'required'            => false,
						'placeholder'         => sprintf( 'https://example.com/%s.ics', sanitize_title_with_dashes( esc_html_x( 'public calendar subscription link', 'this is used as the path of of an example URL, showing users that calendar feeds need to be public', 'gk-gravitycalendar' ), 'save' ) ),
						'type'                => 'text',
						'validation_callback' => [ $this, 'validate_calendar_feed_url' ],
						'class'               => 'medium',
						// translators: Do not translate the words inside the {} curly brackets; they are replaced.
						'description'         => strtr(
							esc_html__( 'Enter the URL of a public calendar feed to display its events on this calendar. This also works with other GravityCalendar feeds that have the "Create a Calendar Subscription Link" setting enabled. {link}Learn more about displaying events from another calendar.{/link}', 'gk-gravitycalendar' ),
							[
								'{link}'  => '<a href="https://docs.gravitykit.com/article/899-displaying-events-from-another-calendar" data-beacon-article-modal="639b4a8742b6294d9d14c8d0" target="_blank">',
								'{/link}' => '<span class="screen-reader-text"> ' . esc_html__( '(This link opens in a new window.)', 'gk-gravitycalendar' ) . '</span></a>',
							]
						) . ' ' . esc_html__( 'Supports multiple feed URLs, separated by commas.', 'gk-gravitycalendar' ),
					],

					[
						'name'          => 'iCalEventColor',
						'label'         => esc_html__( 'Calendar Feed Event Color', 'gk-gravitycalendar' ),
						'required'      => false,
						'dependency'    => [
							'live'   => true,
							'fields' => [
								[
									'field'  => 'iCalUrl',
									'values' => [ '_notempty_' ],
								],
							],
						],
						'type'          => 'radio',
						'choices'       => [
							[
								'label' => __( 'Red', 'gk-gravitycalendar' ),
								'value' => '#d50000',
								'icon'  => 'fa-circle gv-cal-color gv-red',
							],
							[
								'label' => __( 'Orange', 'gk-gravitycalendar' ),
								'value' => '#ef6c00',
								'icon'  => 'fa-circle gv-cal-color gv-orange',
							],
							[
								'label' => __( 'Yellow', 'gk-gravitycalendar' ),
								'value' => '#f6bf26',
								'icon'  => 'fa-circle gv-cal-color gv-yellow',
							],
							[
								'label' => __( 'Green', 'gk-gravitycalendar' ),
								'value' => '#0c8043',
								'icon'  => 'fa-circle gv-cal-color gv-green',
							],
							[
								'label' => __( 'Light Blue', 'gk-gravitycalendar' ),
								'value' => '#4286f5',
								'icon'  => 'fa-circle gv-cal-color gv-light-blue',
							],
							[
								'label' => __( 'Blue', 'gk-gravitycalendar' ),
								'value' => '#3f51b5',
								'icon'  => 'fa-circle gv-cal-color gv-blue',
							],
							[
								'label' => __( 'Purple', 'gk-gravitycalendar' ),
								'value' => '#8f24ab',
								'icon'  => 'fa-circle gv-cal-color gv-purple',
							],
							[
								'label' => __( 'Dark Grey', 'gk-gravitycalendar' ),
								'value' => '#616161',
								'icon'  => 'fa-circle gv-cal-color gv-dark-grey',
							],
							[
								'label' => __( 'Light Grey', 'gk-gravitycalendar' ),
								'value' => '#a79b8e',
								'icon'  => 'fa-circle gv-cal-color gv-light-grey',
							],
						],
						'default_value' => '#4286f5',
						'class'         => 'medium',
						'onchange'      => '
							var calendar = gvCalendar[Object.keys(gvCalendar)[0]]["instance"];
							var sources = calendar.getEventSources();
							if(sources.length > 0){
								for(key in sources){
									if(Object.hasOwn(sources[key].internalEventSource.meta, "url")){
										sources[key].internalEventSource.ui.backgroundColor = this.value;
										sources[key].internalEventSource.ui.borderColor = this.value;
										sources[key].refetch();
									}
								}
							}
						',
						'description'   => esc_html__( 'Sets the background and border colors for all feed events on the calendar.', 'gk-gravitycalendar' ),
					],

				],
			],

			[
				'title'  => esc_html__( 'Notifications', 'gk-gravitycalendar' ),
				'id'     => 'section-calendar-notification',
				'fields' => $this->get_notifications_fields(),
			],

			[
				'title'  => esc_html__( 'Calendar Layout', 'gk-gravitycalendar' ),
				'id'     => 'section-calendar-preview',
				'fields' => [
					[
						'name'        => 'layout',
						'label'       => esc_html__( 'Default Layout', 'gk-gravitycalendar' ),
						'description' => esc_html__( 'The layout can be changed by adding a layout-switching control.', 'gk-gravitycalendar' ),
						'type'        => 'select',
						'required'    => true,
						'choices'     => [
							[
								'label'   => esc_html__( 'Grid', 'gk-gravitycalendar' ),
								'choices' => [
									[
										'label' => esc_html__( 'Month (Grid)', 'gk-gravitycalendar' ),
										'value' => 'dayGridMonth',
									],
									[
										'label' => esc_html__( 'Week (Grid)', 'gk-gravitycalendar' ),
										'value' => 'dayGridWeek',
									],
									[
										'label' => esc_html__( 'Day (Grid)', 'gk-gravitycalendar' ),
										'value' => 'dayGridDay',
									],
								],
							],
							[
								'label'   => esc_html__( 'Agenda', 'gk-gravitycalendar' ),
								'choices' => [
									[
										'label' => esc_html__( 'Week (Agenda)', 'gk-gravitycalendar' ),
										'value' => 'timeGridWeek',
									],
									[
										'label' => esc_html__( 'Day (Agenda)', 'gk-gravitycalendar' ),
										'value' => 'timeGridDay',
									],
								],
							],
							[
								'label'   => esc_html__( 'List', 'gk-gravitycalendar' ),
								'choices' => [
									[
										'label' => esc_html__( 'Year (List)', 'gk-gravitycalendar' ),
										'value' => 'listYear',
									],
									[
										'label' => esc_html__( 'Month (List)', 'gk-gravitycalendar' ),
										'value' => 'listMonth',
									],
									[
										'label' => esc_html__( 'Week (List)', 'gk-gravitycalendar' ),
										'value' => 'listWeek',
									],
									[
										'label' => esc_html__( 'Day (List)', 'gk-gravitycalendar' ),
										'value' => 'listDay',
									],
								],
							],
						],
						'class'       => 'medium',
						'onchange'    => 'var calendar=gvCalendar[Object.keys(gvCalendar)[0]]["instance"];calendar.changeView(this.value);calendar.setOption("initialView", this.value);',
						'tooltip'     => sprintf( '<h6>%s</h6><p>%s</p><p>%s</p><p>%s</p><p>%s</p>', esc_html__( 'Layout', 'gk-gravitycalendar' ), esc_html__( 'Select the default layout type for the calendar. This can be changed once the calendar is displayed if the calendar controls are visible.', 'gk-gravitycalendar' ), esc_html__( 'Grid: Shows like a typical calendar.', 'gk-gravitycalendar' ), esc_html__( 'Agenda: This is like Grid view, but adds the time along the left side. ', 'gk-gravitycalendar' ), esc_html__( 'List: Shows all items in a simple list.', 'gk-gravitycalendar' ) ),
					],
					[
						'name'          => 'sizing',
						'label'         => esc_html__( 'Sizing', 'gk-gravitycalendar' ),
						'description'   => esc_html__( 'Sets the height of the entire calendar, including header and footer.', 'gk-gravitycalendar' ),
						'type'          => 'radio',
						'required'      => true,
						'horizontal'    => true,
						'default_value' => 'auto',
						'choices'       => [
							[
								'label' => esc_html__( 'Auto', 'gk-gravitycalendar' ),
								'value' => 'auto',
							],
							[
								'label' => esc_html__( 'Fixed', 'gk-gravitycalendar' ),
								'value' => 'fixed',
							],
						],
						'class'         => 'small',
						'tooltip'       => sprintf( '<h6>%s</h6>%s', esc_html__( 'Sizing', 'gk-gravitycalendar' ), esc_html__( 'Select the calendar size.', 'gk-gravitycalendar' ) ),
					],
					[
						'name'          => 'height',
						'label'         => esc_html__( 'Calendar Height', 'gk-gravitycalendar' ),
						'description'   => esc_html__( 'Sets the height of the entire calendar, including header and footer.', 'gk-gravitycalendar' ) . '<a href="https://fullcalendar.io/docs/height">' . esc_html__( 'Learn more about available height settings.', 'gk-gravitycalendar' ) . '</a>',
						'type'          => 'text',
						'placeholder'   => esc_html__( 'Height (eg: 400px, 100%, 10vh)', 'gk-gravitycalendar' ),
						'required'      => false,
						'class'         => 'small',
						'data-requires' => 'sizing1=fixed',
						'onchange'      => 'gvCalendar[Object.keys(gvCalendar)[0]]["instance"].setOption("height", this.value );',
					],
					[
						'label' => esc_html__( 'Calendar Controls', 'gk-gravitycalendar' ),
						'name'  => 'controls',
						'type'  => 'calendar_controls',
					],
					[
						'name' => 'calendarpreview',
						'type' => 'calendar_preview',
					],
				],
			],
			[
				'title'  => esc_html__( 'Calendar Settings', 'gk-gravitycalendar' ),
				'fields' => [
					[
						'name'          => 'dynamicEventsLoading',
						'label'         => esc_html__( 'Dynamically Load Events', 'gk-gravitycalendar' ),
						'description'   => esc_html__( 'By default, the calendar loads all available events at once. Select this option if instead you want to dynamically load events for the displayed date range as you navigate between years/months/days.', 'gk-gravitycalendar' ),
						'required'      => false,
						'type'          => 'radio',
						'horizontal'    => true,
						'choices'       => [
							[
								'label' => esc_html__( 'No', 'gk-gravitycalendar' ),
								'value' => false,
							],
							[
								'label' => esc_html__( 'Yes', 'gk-gravitycalendar' ),
								'value' => true,
							],
						],
						'default_value' => 'no',
					],
					[
						'name'          => 'navigateToEvents',
						'label'         => esc_html__( 'No Current Events Behavior', 'gk-gravitycalendar' ),
						'description'   => esc_html__( 'If there are no events for the current calendar window, what should the calendar show?', 'gk-gravitycalendar' ) . ' <a href="https://docs.gravitykit.com/article/669-no-current-events" rel="external">' . esc_html__( 'Learn more about this setting &rarr;', 'gk-gravitycalendar' ) . '</a>',
						'required'      => false,
						'type'          => 'radio',
						'choices'       => [
							[
								'label' => esc_html__( 'Stay on Today (Show Current Calendar)', 'gk-gravitycalendar' ),
								'value' => 'current',
							],
							[
								'label' => esc_html__( 'Show Last Event', 'gk-gravitycalendar' ),
								'value' => 'past',
							],
							[
								'label' => esc_html__( 'Show Next Event', 'gk-gravitycalendar' ),
								'value' => 'future',
							],
						],
						'default_value' => 'current',
						'tooltip'       => sprintf( '<h6>%s</h6> %s', esc_html__( 'Navigate To Event Date', 'gk-gravitycalendar' ), esc_html__( 'Automatically open the year/month/week/day view for the first available past, current or future event.', 'gk-gravitycalendar' ) ),
						'onchange'      => 'window.gvFullCalendar.navigateToEvents(jQuery(gvCalendar[Object.keys(gvCalendar)[0]]["instance"].el).data("calendar_id"), this.value);',
					],
					[
						'name'          => 'localization',
						'label'         => esc_html__( 'Localization', 'gk-gravitycalendar' ),
						'required'      => false,
						'type'          => 'select',
						'choices'       => $fc_locales,
						'class'         => 'large',
						'default_value' => $default_fc_locale,
						'tooltip'       => sprintf( '<h6>%s</h6> %s', esc_html__( 'Localize Calendar', 'gk-gravitycalendar' ), esc_html__( 'Localize certain aspects of the calendar (e.g., date formatting, first day).', 'gk-gravitycalendar' ) ),
						'onchange'      => 'window.gvFullCalendar.changeLocale(this.value, jQuery(gvCalendar[Object.keys(gvCalendar)[0]]["instance"].el).data("calendar_id"));',
					],
					[
						'name'        => 'allow-html-content',
						'label'       => esc_html__( 'Allow HTML Content', 'gk-gravitycalendar' ),
						'description' => esc_html__( 'When enabled, HTML content can be used in event title/description.', 'gk-gravitycalendar' ),
						'type'        => 'checkbox',
						'required'    => false,
						'choices'     => [
							[
								'label' => esc_html__( 'Enable', 'gk-gravitycalendar' ),
								'name'  => 'allow-html-content',
							],
						],
					],
					[
						'name'        => 'editable',
						'label'       => esc_html__( 'Allow Event Editing', 'gk-gravitycalendar' ),
						'description' => esc_html__( 'When enabled, this allows users at or above a specific role to drag and drop calendar events and edit the corresponding form entries.', 'gk-gravitycalendar' ),
						'type'        => 'checkbox',
						'required'    => false,
						'choices'     => [
							[
								'label' => esc_html__( 'Enable', 'gk-gravitycalendar' ),
								'name'  => 'enable-editing',
							],
						],
						'onchange'    => 'gvCalendar[Object.keys(gvCalendar)[0]]["instance"].setOption("editable",this.checked);',
					],
					[
						'name'          => 'editroles[]',
						'label'         => esc_html__( 'Roles', 'gk-gravitycalendar' ),
						'type'          => 'select',
						'enhanced_ui'   => true,
						'required'      => false,
						'multiple'      => 'multiple',
						'choices'       => $this->get_roles_as_choices(),
						'data-requires' => 'enable-editing',
						'description'   => esc_html__( 'Select the roles that are able to edit calendar events.', 'gk-gravitycalendar' ),
					],
					[
						'type' => 'save',
					],
				],
			],
			[
				'title'       => esc_html__( 'Filter Displayed Entries', 'gk-gravitycalendar' ),
				'description' => '',
				'fields'      => [
					[
						'label'          => esc_html__( 'Conditional Logic', 'gk-gravitycalendar' ),
						'name'           => 'condition',
						'type'           => 'callback',
						'checkbox_label' => esc_html__( 'Enable', 'gk-gravitycalendar' ),
						'instructions'   => esc_html__( 'Process feed if', 'gk-gravitycalendar' ),
						'callback'       => function () {
							echo '<span class="gform-settings-description">' . esc_html__( 'When conditional logic is enabled, form submissions will only be processed for this feed when the condition is met.', 'gk-gravitycalendar' ) . '</span>';
							echo '<div id="gk-query-filters"></div>';
						},
					],
				],
			],
			[
				'title'       => esc_html__( 'Calendar Subscription', 'gk-gravitycalendar' ),
				'description' => '',
				'fields'      => [
					[
						'name'        => 'enable-ics-feed',
						'label'       => esc_html__( 'Create a Calendar Subscription Link', 'gk-gravitycalendar' ),
						'description' => esc_html__( 'This allows users to subscribe to events in their calendar applications. Events will appear as configured in this feed. This link is not publicly visible; only those with the link can subscribe.', 'gk-gravitycalendar' ),
						'type'        => 'checkbox',
						'required'    => false,
						'choices'     => [
							[
								'label' => esc_html__( 'Enable calendar subscription link', 'gk-gravitycalendar' ),
								'name'  => 'enable-ics-feed',
							],
						],
					],
					[
						'name'          => 'export-ics-url',
						'type'          => 'callback',
						'callback'      => function () {
							echo join(
								'',
								[
									$this->settings_text(
										[
											'name'          => 'export-ics-url',
											'class'         => 'widefat',
											'readonly'      => true,
											'placeholder'   => esc_html__( 'Save the feed settings to generate a calendar feed URL.', 'gk-gravitycalendar' ),
											'data-requires' => 'enable-ics-feed',
										],
										false
									),
									'<div id="gv-calendar-ics-url-buttons">',
									'<div class="copy-to-clipboard-container alignright" style="margin-top: 0">
								<span class="success hidden" style="padding: 0 1em;" aria-hidden="true">' . esc_html__( 'Copied!', 'gk-gravitycalendar' ) . '</span>
								<button type="button" class="button copy-attachment-url" data-clipboard-target="[name=_gform_setting_export-ics-url]"><span class="dashicons dashicons-clipboard"></span>
								' . esc_html__( 'Copy URL to Clipboard', 'gk-gravitycalendar' ) . '</button>
							</div>',
									$this->settings_button(
										[
											'type'          => 'submit',
											'name'          => 'gform-settings-save',
											'value'         => 'Reset',
											'form'          => 'gform-settings',
											'label'         => esc_html__( 'Regenerate URL', 'gk-gravitycalendar' ),
											'onclick'       => 'return confirm("' . esc_html__( 'You are about to change the URL for this feed. Once changed, subscriptions to the existing calendar feed URL will no longer work. This cannot be undone. Continue?', 'gk-gravitycalendar' ) . '");',
											'data-requires' => 'enable-ics-feed',
										],
										false
									),
									'</div>',
								]
							);
						},
						'save_callback' => function () {
							$url = rgar( $this->get_previous_settings(), 'export-ics-url' );
							if ( $url == '' || ( rgpost( 'gform-settings-save' ) === 'Reset' ) ) {
								try {

									// The final hash is going to be 32 characters, but a longer seed is more secure.
									$seed_length = 32;
									$hash_seed   = wp_generate_password( $seed_length, true, true );

									if ( function_exists( 'random_bytes' ) ) {
										try {
											$bytes     = random_bytes( $seed_length );
											$hash_seed = bin2hex( $bytes );
										} catch ( Exception $exception ) {

										}
									}

									$url = rest_url( 'gravitycalendar/v1/feeds/' . wp_hash( $hash_seed ) );

								} catch ( Exception $exception ) {
									self::logger()->error( sprintf( esc_html__( 'There was an error generating the URL: %s', 'gk-gravitycalendar' ), $exception->getMessage() ) );

									return rgar( $this->get_previous_settings(), 'export-ics-url' );
								}
							}

							return $url;
						},
					],
				],
			],
		];
	}


	/**
	 * Attach ics file to a notification.
	 *
	 * @param array $notification
	 * @param array $form
	 * @param array $entry
	 *
	 * @return array
	 */
	public function attach_file_to_notification( $notification, $form, $entry ) {
		$calendar_feeds = self::get_instance()->get_active_feeds( $form['id'] );
		if ( empty( $calendar_feeds ) ) {
			return $notification;
		}

		$notification['attachments'] = rgar( $notification, 'attachments', [] );
		foreach ( $calendar_feeds as $feed ) {
			$allowed_notifications = rgar( $feed['meta'], 'calendarNotification', [] );
			if ( empty( $allowed_notifications ) ) {
				continue;
			}

			$current_notification = rgar( $allowed_notifications, $notification['id'], false );
			if ( $current_notification === false ) {
				continue;
			}

			$file          = $this->generate_ics_file( $feed, false, $entry['id'] );
			$feed_name     = sanitize_file_name( rgar( $feed['meta'], 'feedName', 0 ) );
			$temp_filename = $feed_name . '.ics';
			$temp_filepath = get_temp_dir() . $temp_filename;
			file_put_contents( $temp_filepath, $file );
			$notification['attachments'][] = $temp_filepath;

			add_action(
				'gform_after_email',
				function () use ( $temp_filepath ) {
					if ( file_exists( $temp_filepath ) ) {
						unlink( $temp_filepath );
					}
				}
			);

		}

		return $notification;
	}

	/**
	 * Get notifications fields for the feed.
	 *
	 * @return array
	 */
	public function get_notifications_fields() {

		$feed_id = $this->get_current_feed_id();
		$options = [];
		if ( $feed_id ) {
			$feed          = $this->get_feed( $feed_id );
			$form          = GFAPI::get_form( $feed['form_id'] );
			$notifications = GFCommon::get_notifications( 'resend_notifications', $form );

			if ( ! empty( $notifications ) ) {
				foreach ( $notifications as $notification ) {
					if ( $notification['isActive'] !== true ) {
						continue;
					}
					$options[] = [
						'label' => $notification['name'],
						'name'  => 'calendarNotification[' . $notification['id'] . ']',
					];
				}
			}
		}

		$fields = [
			[
				'name'     => 'calendarNotification',
				'label'    => esc_html__( 'Send events as an email attachment for the selected notification(s):', 'gk-gravitycalendar', 'gk-gravitycalendar' ),
				'type'     => 'checkbox',
				'choices'  => $options,
				'class'    => 'medium',
				'required' => false,
			],
		];

		if ( empty( $options ) ) {
			$fields[0]['type']     = 'callback';
			$fields[0]['callback'] = function () {
				echo '<span class="gform-settings-description">' . esc_html__( 'This form has no active notifications.', 'gk-gravitycalendar' ) . '</span>';
			};
		}

		return $fields;
	}

	/**
	 * Validate the Calendar Feed URL setting on save.
	 *
	 * @since    2.3
	 *
	 * @param Base   $field Setting field being validated.
	 * @param string $value Value of the setting field.
	 *
	 * @return void
	 * @internal This is for internal use only and should not be used by third-party developers.
	 */
	public function validate_calendar_feed_url( $field, $value ) {

		if ( ! $value ) {
			return;
		}

		$urls = explode( ',', $value );

		$error_urls = [];
		foreach ( $urls as $url ) {
			$url = trim( $url );
			if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
				$error_urls[] = $url;
			}
		}

		if ( empty( $error_urls ) ) {
			return;
		}

		$error_string = __( 'Please enter a valid URL.', 'gk-gravitycalendar' );
		$error_string .= ' ';
		$error_string .= sprintf( '%s: %s', _n( 'Invalid URL', 'Invalid URLs', 'gk-gravitycalendar' ), implode( ',', $error_urls ) );

		$field->set_error( $error_string );
	}

	/**
	 * Output a <button> field in the feed settings
	 *
	 * @param array $field Field configuration array
	 * @param bool  $echo
	 *
	 * @return string HTML output for the <button>
	 */
	public function settings_button( $field, $echo = true ) {
		$properties = [
			'type'    => esc_attr( rgar( $field, 'type', '' ) ),
			'onclick' => esc_js( rgar( $field, 'onclick', '' ) ),
			'name'    => esc_attr( rgar( $field, 'name', '' ) ),
			'value'   => esc_attr( rgar( $field, 'value', '' ) ),
			'form'    => esc_attr( rgar( $field, 'form', '' ) ),
			'class'   => esc_attr( rgar( $field, 'class', 'button button-secondary' ) ),
		];

		$button_properties = [];
		foreach ( $properties as $property => $value ) {
			if ( ! empty( $value ) ) {
				$button_properties[] = sprintf( '%s="%s"', $property, esc_attr( $value ) );
			}
		}
		$button = sprintf( '<button %s>%s</button>', implode( ' ', $button_properties ), esc_attr( rgar( $field, 'label', '' ) ) );

		if ( $echo ) {
			echo $button;
		}

		return $button;
	}

	/**
	 * Returns shortcode for the current feed.
	 *
	 * @since 2.6
	 *
	 * @param array $feed The feed to get the shortcode for.
	 *
	 * @return string The shortcode.
	 */
	public function get_shortcode_for_feed( array $feed = [] ) : string {
		if ( ! $feed ) {
			$feed = (array) ( $this->get_current_feed() ?: [] );

			if ( ! $feed ) {
				return '';
			}
		}

		if ( $this->is_feed_edit_page() ) {
			// Make sure we have the posted values as well.
			$feed['meta'] = $this->get_current_settings();
		}

		$atts = [ sprintf( '%s="%d"', 'id', rgar( $feed, 'id' ) ) ];

		try {
			if ( $this->is_secure( $feed ) ) {
				$secret = $this->get_validation_secret( $feed );
				$atts[] = sprintf( '%s="%s"', 'secret', $secret );
			}
		} catch ( Exception $e ) {
			self::logger()->error( $e->getMessage() );
		}

		return sprintf( '[gravitycalendar %s /]', implode( ' ', $atts ) );
	}

	/**
	 * Adds Calendar Preview placeholder in GF v2.5+
	 *
	 * @since 2.0
	 *
	 * @param array $field Section field data
	 *
	 * @return void
	 */
	public function settings_calendar_embed( $field = [] ) {
		$feed_id     = (int) $this->get_current_feed_id();
		$embed_label = esc_html__( 'Embed Calendar', 'gk-gravitycalendar' );

		$embed_shortcode_notice = esc_html__( 'To embed this calendar in a post or page, add a GravityCalendar block ({link}learn more{/link}) or embed following shortcode: {shortcode}', 'gk-gravitycalendar' );
		$embed_shortcode_notice = strtr(
			$embed_shortcode_notice,
			[
				'{link}'      => '<a href="https://docs.gravitykit.com/article/830-calendar-blocks" target="_blank" rel="noopener noreferrer external" title="' . esc_attr__( 'Learn about GravityCalendar blocks', 'gk-gravitycalendar' ) . '">',
				'{/link}'     => '<span class="screen-reader-text"> ' . esc_html__( '(This link opens in a new window.)', 'gk-gravitycalendar' ) . '</span></a>',
				'{shortcode}' => $this->get_column_value_shortcode( $this->get_current_feed() ),
			]
		);

		$display_embed_notice = ! $feed_id ? 'hidden' : '';

		echo <<<HTML
<div class="calendar-embed-container {$display_embed_notice}">
    <div class="gform-settings-field__header">
        <label class="gform-settings-label" for="feedName">$embed_label</label>
    </div>
    <div class="calendar-embed-shortcode">
        <p>{$embed_shortcode_notice}</p>
    </div>
HTML;
	}


	/**
	 * Adds Calendar Preview placeholder in GF v2.5+
	 *
	 * @since 1.4
	 *
	 * @param array $field Section field data
	 *
	 * @return void
	 */
	public function settings_calendar_preview( $field = [] ) {
		$this->single_setting_row_calendar_preview( $field );
	}

	/**
	 * Adds Calendar Preview placeholder
	 *
	 * @since 1.0.0
	 *
	 * @param array $field Section field data
	 *
	 * @return void
	 */
	public function single_setting_row_calendar_preview( $field = [] ) {
		$feed_id     = (int) $this->get_current_feed_id();
		$calendar_id = wp_generate_password();
		echo <<<HTML

<div class="calendar-preview-container">
    <div class="calendar-preview-notice hidden"></div>
    <div class="gv-fullcalendar calendar-preview" data-feed_id="{$feed_id}" data-calendar_id="{$calendar_id}"></div>
</div>
HTML;
	}

	/**
	 * Overrides select row to check if it has a requirement
	 *
	 * @since 1.0.0
	 *
	 * @param array $field field data
	 *
	 * @return void
	 */
	public function single_setting_row_select( $field ) {
		if ( ! empty( $field['data-requires'] ) ) {

			$this->render_requires( $field );
		} else {

			$this->single_setting_row( $field );
		}
	}

	/**
	 * Overrides text row to check if it has a requirement
	 *
	 * @since 1.0.0
	 *
	 * @param array $field field data
	 *
	 * @return void
	 */
	public function single_setting_row_text( $field ) {
		if ( ! empty( $field['data-requires'] ) ) {

			$this->render_requires( $field );

		} else {

			$this->single_setting_row( $field );

		}
	}

	/**
	 * Overrides textarea row to check if it has a requirement
	 *
	 * @since 1.0.0
	 *
	 * @param array $field field data
	 *
	 * @return void
	 */
	public function single_setting_row_calendar_options( $field ) {
		if ( ! empty( $field['data-requires'] ) ) {

			$this->render_requires( $field );

		} else {

			$this->single_setting_row( $field );

		}
	}

	/**
	 * Adds a data-requires attribute to the field
	 *
	 * @since 1.0.0
	 *
	 * @param array $field field data
	 *
	 * @return void
	 */
	public function render_requires( $field = [] ) {
		$display = rgar( $field, 'hidden' ) || rgar( $field, 'type' ) == 'hidden' ? 'style="display:none;"' : '';

		// Prepare setting description.
		$description = rgar( $field, 'description' ) ? '<span class="gf_settings_description">' . $field['description'] . '</span>' : null;

		$show_if = ( ! empty( $field['data-requires'] ) ) ? sprintf( ' data-requires="%s" class="hide-if-js"', $field['data-requires'] ) : '';

		?>

		<tr
			id="gaddon-setting-row-
		<?php
			echo $field['name']
			?>
		"
			<?php
			echo $display;
			echo $show_if;
			?>
		>
			<th>
				<?php
				$this->single_setting_label( $field );
				?>
			</th>
			<td>
				<?php
				$this->single_setting( $field );
				echo $description;
				?>
			</td>
		</tr>

		<?php
	}


	/**
	 * Render calendar control setting field markup
	 *
	 * @since 1.0.0
	 *
	 * @param array $field Array of field data
	 *
	 * @return void
	 */
	public function settings_calendar_controls( $field = [] ) {
		$default_controls = [
			'controls-top-left'      => [ 'prev', 'next', 'space', 'today' ],
			'controls-top-center'    => [ 'title' ],
			'controls-top-right'     => [ 'dayGridMonth', 'timeGridWeek', 'timeGridDay' ],
			'controls-bottom-left'   => [],
			'controls-bottom-center' => [],
			'controls-bottom-right'  => [],
		];

		$setting_value = $this->get_setting(
			$field['name'],
			$default_controls
		);

		// Sometimes the settings can be corrupted and appear as a string. In that case, use the defaults.
		if ( ! is_array( $setting_value ) ) {
			$setting_value = $default_controls;
		}

		// Again, check to make sure the settings are in the correct format. If not, use the defaults.
		if ( array_keys( $setting_value ) !== array_keys( $default_controls ) ) {
			$setting_value = $default_controls;
		}

		$setting_name = ( $this->is_gravityforms_supported( '2.5-beta' ) ? '_gform_setting_' : '_gaddon_setting_' ) . $field['name'];
		?>
		<span class="gform-settings-description"><?php esc_html_e( 'Configure the controls displayed above and below the calendars. Buttons allow users to navigate the calendar. [Title] displays the current timeframe being displayed. [Space] adds a blank space.', 'gk-gravitycalendar' ); ?></span>
		<div id="calendar-controls">
			<input type="hidden" name="<?php echo esc_attr( $setting_name ); ?>" value="<?php echo esc_attr( json_encode( $setting_value ) ); ?>" />
			<div id="controls-tray">
				<p>
					<?php
					esc_html_e( 'Available Controls', 'gk-gravitycalendar' )
					?>
				</p>
				<ul
					id="controls-available" class="calendar-controls-sortable controls-tray" title="
				<?php
				esc_html_e( 'Available Controls Tray Area', 'gk-gravitycalendar' )
				?>
				"
				>
					<?php
					$this->control_button_markup( 'title' );
					$this->control_button_markup( 'today' );
					$this->control_button_markup( 'prev' );
					$this->control_button_markup( 'next' );
					$this->control_button_markup( 'prevYear' );
					$this->control_button_markup( 'nextYear' );
					$this->control_button_markup( 'space' );
					$this->control_button_markup( 'dayGridMonth' );
					$this->control_button_markup( 'dayGridWeek' );
					$this->control_button_markup( 'dayGridDay' );
					$this->control_button_markup( 'listMonth' );
					$this->control_button_markup( 'listWeek' );
					$this->control_button_markup( 'listDay' );
					$this->control_button_markup( 'timeGridWeek' );
					$this->control_button_markup( 'timeGridDay' );

					?>
				</ul>
			</div>
			<div id="controls-top">
				<div class="controls-left">
					<p>
						<?php
						esc_html_e( 'Top Left', 'gk-gravitycalendar' )
						?>
					</p>
					<ul
						id="controls-top-left" class="calendar-controls-sortable controls-drop" title="
					<?php
					esc_html_e( 'Top Left Tray Area', 'gk-gravitycalendar' )
					?>
					"
					>
						<?php
						if ( ! empty( $setting_value['controls-top-left'] ) ) {
							foreach ( $setting_value['controls-top-left'] as $type ) {
								$this->control_button_markup( $type );
							}
						}
						?>
					</ul>
				</div>
				<div class="controls-center">
					<p>
						<?php
						esc_html_e( 'Top Center', 'gk-gravitycalendar' )
						?>
					</p>
					<ul
						id="controls-top-center" class="calendar-controls-sortable controls-drop" title="
					<?php
					esc_html_e( 'Top Center Tray Area', 'gk-gravitycalendar' )
					?>
					"
					>
						<?php
						if ( ! empty( $setting_value['controls-top-center'] ) ) {
							foreach ( $setting_value['controls-top-center'] as $type ) {
								$this->control_button_markup( $type );
							}
						}
						?>
					</ul>
				</div>
				<div class="controls-right">
					<p>
						<?php
						esc_html_e( 'Top Right', 'gk-gravitycalendar' )
						?>
					</p>
					<ul
						id="controls-top-right" class="calendar-controls-sortable controls-drop" title="
					<?php
					esc_html_e( 'Top Right Tray Area', 'gk-gravitycalendar' )
					?>
					"
					>
						<?php
						if ( ! empty( $setting_value['controls-top-right'] ) ) {
							foreach ( $setting_value['controls-top-right'] as $type ) {
								$this->control_button_markup( $type );
							}
						}
						?>
					</ul>
				</div>
			</div>
			<div id="controls-bottom">
				<div class="controls-left">
					<p>
						<?php
						esc_html_e( 'Bottom Left', 'gk-gravitycalendar' )
						?>
					</p>
					<ul
						id="controls-bottom-left" class="calendar-controls-sortable controls-drop" title="
					<?php
					esc_html_e( 'Bottom Left Tray Area', 'gk-gravitycalendar' )
					?>
					"
					>
						<?php
						if ( ! empty( $setting_value['controls-bottom-left'] ) ) {
							foreach ( $setting_value['controls-bottom-left'] as $type ) {
								$this->control_button_markup( $type );
							}
						}
						?>
					</ul>
				</div>
				<div class="controls-center">
					<p>
						<?php
						esc_html_e( 'Bottom Center', 'gk-gravitycalendar' )
						?>
					</p>
					<ul
						id="controls-bottom-center" class="calendar-controls-sortable controls-drop" title="
					<?php
					esc_html_e( 'Bottom Center Tray Area', 'gk-gravitycalendar' )
					?>
					"
					>
						<?php
						if ( ! empty( $setting_value['controls-bottom-center'] ) ) {
							foreach ( $setting_value['controls-bottom-center'] as $type ) {
								$this->control_button_markup( $type );
							}
						}
						?>
					</ul>
				</div>
				<div class="controls-right">
					<p>
						<?php
						esc_html_e( 'Bottom Right', 'gk-gravitycalendar' )
						?>
					</p>
					<ul
						id="controls-bottom-right" class="calendar-controls-sortable controls-drop" title="
					<?php
					esc_html_e( 'Bottom Right Tray Area', 'gk-gravitycalendar' )
					?>
					"
					>
						<?php
						if ( ! empty( $setting_value['controls-bottom-right'] ) ) {
							foreach ( $setting_value['controls-bottom-right'] as $type ) {
								$this->control_button_markup( $type );
							}
						}
						?>
					</ul>
				</div>
			</div>
		</div>

		<?php
	}

	/**
	 * Render calendar options setting field markup
	 *
	 * @since 1.0.0
	 *
	 * @param array $field Array of field data
	 *
	 * @return string HTML of the "calendar_options" field type
	 */
	public function settings_calendar_options( $field = [], $echo = true ) {
		$field['type'] = 'textarea'; // making sure type is set to textarea
		$attributes    = $this->get_field_attributes( $field );
		$default_value = rgar( $field, 'value', rgar( $field, 'default_value' ) );
		$value         = $this->get_setting( $field['name'], $default_value );
		$value         = ! empty( $value ) ? json_encode( $value ) : '';

		$name = '' . esc_attr( $field['name'] );
		$html = '';
		$html .= '<input id="' . $name . '_data"type="hidden" name="_gaddon_setting_' . $name . '" value="' . esc_attr( $value ) . '" />';
		$html .= '<textarea name="_gaddon_setting_' . $name . '" ' . implode( ' ', $attributes ) . '></textarea>';

		if ( $this->field_failed_validation( $field ) ) {
			$html .= $this->get_error_icon( $field );
		}

		if ( $echo ) {
			echo $html;
		}

		return $html;
	}

	/**
	 * Returns markup for each control button type
	 *
	 * @since 1.0.0
	 *
	 * @param boolean $echo Echo markup or return
	 * @param string  $type Type of control button
	 *
	 * @return mixed Echo markup if $echo is true, otherwise return markup
	 */
	public function control_button_markup( $type = '', $echo = true ) {
		switch ( $type ) {
			case 'title':
				$markup = '<li data-button-id="title" title="' . esc_html__( 'Title Button', 'gk-gravitycalendar' ) . '"><span class="placeholder-title button button-secondary">[' . esc_html__( 'Title', 'gk-gravitycalendar' ) . ']</span></li>';
				break;
			case 'today':
				$markup = '<li data-button-id="today" title="' . esc_html__( 'Today Button', 'gk-gravitycalendar' ) . '"><span class="fc-today-button button button-secondary">' . esc_html__( 'today', 'gk-gravitycalendar' ) . '</span></li>';
				break;
			case 'prev':
				$markup = '<li data-button-id="prev" title="' . esc_html__( 'Previous Button', 'gk-gravitycalendar' ) . '"><span class="fc-prev-button button button-secondary" aria-label="prev"><span class="fc-icon fc-icon-chevron-left"></span></span></li>';
				break;
			case 'next':
				$markup = '<li data-button-id="next" title="' . esc_html__( 'Next Button', 'gk-gravitycalendar' ) . '"><span class="fc-next-button button button-secondary" aria-label="next"><span class="fc-icon fc-icon-chevron-right"></span></span></li>';
				break;
			case 'prevYear':
				$markup = '<li data-button-id="prevYear" title="' . esc_html__( 'Previous Year Button', 'gk-gravitycalendar' ) . '"><span class="fc-prevYear-button button button-secondary" aria-label="prevYear"><span class="fc-icon fc-icon-chevrons-left"></span></span></li>';
				break;
			case 'nextYear':
				$markup = '<li data-button-id="nextYear" title="' . esc_html__( 'Next Year Button', 'gk-gravitycalendar' ) . '"><span class="fc-nextYear-button button button-secondary" aria-label="nextYear"><span class="fc-icon fc-icon-chevrons-right"></span></span></li>';
				break;
			case 'space':
				$markup = '<li data-button-id="space" title="' . esc_html__( 'Space Placeholder Button', 'gk-gravitycalendar' ) . '"><span class="placeholder-space button button-secondary">[' . esc_html__( 'Space', 'gk-gravitycalendar' ) . ']</span></li>';
				break;
			default:
				$markup = '';

				$labels = [
					'dayGridMonth'  => esc_html__( 'Month (Grid)', 'gk-gravitycalendar' ),
					'dayGridWeek'   => esc_html__( 'Week (Grid)', 'gk-gravitycalendar' ),
					'dayGridDay'    => esc_html( 'Day (Grid)', 'gk-gravitycalendar' ),
					'listYear'      => esc_html__( 'Year (List)', 'gk-gravitycalendar' ),
					'listMonth'     => esc_html__( 'Month (List)', 'gk-gravitycalendar' ),
					'listWeek'      => esc_html__( 'Week (List)', 'gk-gravitycalendar' ),
					'listDay'       => esc_html__( 'Day (List)', 'gk-gravitycalendar' ),
					'timeGridDay'   => esc_html( 'Day (Agenda)', 'gk-gravitycalendar' ),
					'timeGridWeek'  => esc_html( 'Week (Agenda)', 'gk-gravitycalendar' ),
					'timeGridMonth' => esc_html( 'timeGridMonth', 'gk-gravitycalendar' ),
				];

				if ( isset( $labels[ $type ] ) ) {
					$label  = $labels[ $type ];
					$markup = '<li data-button-id="' . esc_attr( $type ) . '" title="' . esc_attr( $label ) . '"><span class="fc-' . $type . '-button button button-secondary" aria-label="' . $type . '">' . esc_html( $label ) . '</span></li>';
				}
				break;
		}

		if ( $echo ) {
			echo $markup;
		}

		return $markup;
	}

	// # FEED LIST FUNCTIONS --------------------------------------------------------------------------------------------

	/**
	 * Configures which columns should be displayed on the feed list page.
	 *
	 * @since 1.0.0
	 *
	 * @return array
	 */
	public function feed_list_columns() {
		return [
			'feedName'    => esc_html__( 'Name', 'gk-gravitycalendar' ),
			'shortcode' => sprintf(
				'%s <small>(%s)</small>',
				esc_html__( 'Shortcode', 'gk-gravitycalendar' ),
				esc_html__( 'Click to copy', 'gk-gravitycalendar' )
			),
			'previewlink' => esc_html__( 'Preview', 'gk-gravitycalendar' ),
		];
	}

	/**
	 * Formats the value to be displayed in the shortcode column.
	 *
	 * @since 1.0.0
	 *
	 * @param array|false $feed The feed being included in the feed list. False if we are in a new feed.
	 *
	 * @return string
	 */
	public function get_column_value_shortcode( $feed ) {
		return '<div class="gk-calendar-shortcode">
            <input title="' . esc_html__( 'Click to copy', 'gk-gravity-calendar', 'gk-gravitycalendar' ) . '" class="code shortcode widefat" readonly value="' . esc_html( $this->get_shortcode_for_feed( $feed ?: [] ) ) . '" data-secret="' . $this->get_validation_secret( $feed ?: [], true ) . '" />
            <div class="copied">'. esc_html__('Copied!', 'gk-gravity-calendar', 'gk-gravitycalendar'). '</div>
        </div>';
	}

	/**
	 * Formats the value to be displayed in the preview column.
	 *
	 * @since 1.0.0
	 *
	 * @param array $feed The feed being included in the feed list.
	 *
	 * @return string
	 */
	public function get_column_value_previewlink( $feed ) {
		$url = admin_url( 'admin.php?page=gf_edit_forms&view=settings&subview=gravityview-calendar&fid=' . $feed['id'] . '&id=' . $feed['form_id'] . '#section-calendar-preview' );

		$anchor = esc_html__( 'Preview Calendar', 'gk-gravitycalendar' );

		return '<a href="' . esc_url( $url ) . '">' . $anchor . '</a>';
	}

	/**
	 * Checks whether the current request is called from the Block Editor
	 *
	 * @see   https://github.com/WordPress/gutenberg/issues/8846#issuecomment-485793891
	 *
	 * @since 1.0.0
	 *
	 * @return bool True: doing a REST request; false: not doing a REST request.
	 */
	public function is_rest_request() {
		return defined( 'REST_REQUEST' ) && REST_REQUEST;
	}

	/**
	 * Gets calendar options that are used to configure each calendar instance.
	 *
	 * @since 1.5.1
	 *
	 * @param int $form_id ID of the form.
	 * @param int $feed_id Feed ID.
	 * @param int $feed_id View ID.
	 *
	 * @return array[]
	 */
	public function get_calendar_instance_options( $feed_id = 0, $form_id = 0, $view_id = 0 ) {
		$calendar_options = $this->calendar_options( $feed_id );
		$extra_options    = [];

		// `navigateToEvents` is a custom calendar option and will throw an error if used to initialize FullCalendar instance in the UI
		if ( isset( $calendar_options['navigateToEvents'] ) ) {
			$extra_options['navigateToEvents'] = $calendar_options['navigateToEvents'];
			unset( $calendar_options['navigateToEvents'] );
		}

		// Add WordPress start of the week option.
		$calendar_options['firstDay'] = (int) get_option( 'start_of_week' );

		$feed = $this->get_feed( $feed_id );

		/**
		 * @filter `gravityview/calendar/scripts/fullcalendar/locales_dir_url` Modify the URL to FullCalendar locales.
		 *
		 * @since  1.3
		 *
		 * @param string $locales_url URL to FullCalendar locales.
		 */
		$locales_url = apply_filters( 'gravityview/calendar/scripts/fullcalendar/locales_dir_url', GV_CALENDAR_URL . 'lib/fullcalendar/locales/' );

		$dynamic_events_loading = (int) rgars( $feed, 'meta/dynamicEventsLoading' );

		if ( $dynamic_events_loading ) {
			$extra_options = array_merge(
				$extra_options,
				[
					'ajax_params' => [
						'action' => 'gv_calendar_get_events',
					],
					'loader'      => true,
				]
			);
		} else {
			try {
				$calendar_options['events'] = $this->calendar_events( $feed_id );
			} catch ( Exception $exception ) {
				$calendar_options['events'] = [];
			}
		}

		$extra_options = array_merge(
			$extra_options,
			[
				'_nonce'                 => wp_create_nonce( GravityView_Calendar_Ajax::get_nonce_handle( $feed_id ) ),
				'feed_id'                => $feed_id,
				'form_id'                => $form_id,
				'ajax_url'               => admin_url( 'admin-ajax.php' ),
				'site_url'               => get_site_url(),
				'calendar_rest_url'      => rest_url( 'gravitycalendar/v1/' ),
				'locales_url'            => $locales_url,
				'allow_html_content'     => (int) rgars( $feed, 'meta/allow-html-content' ),
				'ical_feeds'             => $this->get_event_sources( $feed ),
				'dynamic_events_loading' => $dynamic_events_loading,
			]
		);

		/**
		 * @filter `gravityview/calendar/options` Modify FullCalendar options.
		 *
		 * @since  1.4
		 *
		 * @param array $calendar_options Calendar options.
		 * @param int   $form_id          Form ID.
		 * @param int   $feed_id          Calendar feed ID.
		 */
		$calendar_options = apply_filters( 'gravityview/calendar/options', $calendar_options, (int) $form_id, (int) $feed_id );

		/**
		 * @filter `gravityview/calendar/extra_options` Modify extra options used in the plugin's UI.
		 *
		 * @since  1.5
		 *
		 * @param array $calendar_options Extra options.
		 * @param int   $form_id          Form ID.
		 * @param int   $feed_id          Calendar feed ID.
		 */
		$extra_options = apply_filters( 'gravityview/calendar/extra_options', $extra_options, (int) $form_id, (int) $feed_id );

		return [
			'calendar_options' => $calendar_options,
			'extra_options'    => $extra_options,
		];
	}

	/**
     * Process iCal feeds from feed meta into an array of feeds.
     *
	 * @param array $feed Gravity Forms feed array.
     *
     * @since 2.5
     *
     * @return array {
     *   @type string $url URL of the iCal feed.
     *   @type string $format Format of the iCal feed (default: ics).
     *   @type string $color Color of the events from the iCal feed.
     * }
	 */
    private function get_event_sources( $feed ) {

	    $feed_urls_string = rgars( $feed, 'meta/iCalUrl', '' );

	    $feed_urls = explode( ',', $feed_urls_string );

	    $defaults = [
		    'url'    => '',
		    'format' => 'ics',
		    'color'  => rgars( $feed, 'meta/iCalEventColor' ),
	    ];

	    $event_sources = [];

	    foreach ( $feed_urls as $feed_url ) {
		    $feed_url = trim( $feed_url );

		    if ( empty( $feed_url ) ) {
			    continue;
		    }

		    $feed_url = esc_url_raw( $feed_url );

		    if ( ! filter_var( $feed_url, FILTER_VALIDATE_URL ) ) {
			    continue;
		    }

		    $event_source = [
			    'url' => strtok( $feed_url, '#' ), // We only want the URL without any fragment.
		    ];

		    $color = $this->get_event_source_color( $feed_url );

		    if ( $color ) {
			    $event_source['color'] = $color;
		    }

		    $event_sources[] = wp_parse_args( $event_source, $defaults );
	    }

	    /**
	     * @filter `gk/gravitycalendar/event-sources` Modifies the additional sources of events shown on a calendar.
         * @since 2.5
         * @param array $event_sources Array of calendar sources with the following keys: url, format, color.
         * @param int   $form_id       Form ID.
         * @param int   $feed_id       Calendar feed ID.
	     */
	    $event_sources = apply_filters( 'gk/gravitycalendar/event-sources', $event_sources, (int) rgar( $feed, 'form_id', 0 ), (int) rgar( $feed, 'id', 0 ) );

	    return $event_sources;
    }

	/**
	 * Returns the color of the feed by parsing the feed URL.
	 *
	 * TODO: When fetching a local feed, grab the color from the feed settings.
	 *
	 * @since 2.5
	 *
	 * @param string $feed_url URL of the feed. May or may not contain a fragment containing a color.
	 *
	 * @return string|null Color of the feed, sanitized. Null if no color is found.
	 */
	private function get_event_source_color( $feed_url ) {

		// If there is a color added to the URL as a fragment, use that as the color.
		$url_parts = parse_url( $feed_url );

		if ( empty( $url_parts['fragment'] ) ) {
			return null;
		}

		$color = $url_parts['fragment'];

		// If a hex, prefix with #. Otherwise, it's a color name.
		if ( preg_match( '/^([a-f0-9]{3}){1,2}$/i', $color ) ) {
			$color = '#' . $color;
		}

		return sanitize_text_field( $color );
	}

	/**
	 * Generates and outputs <script> tags for calendars.
	 *
	 * @since 1.0.0
	 * @since 1.5 Added $view_id parameter.
	 *
	 * @param int $form_id ID of the form.
	 * @param int $feed_id Feed ID.
	 * @param int $view_id View ID.
	 *
	 * @return void
	 */
	public function output_calendar_scripts( $feed_id = 0, $form_id = 0, $view_id = 0 ) {
		global $wp_scripts;

		// Don't print the same configuration twice.
		// This can change in the future, since we pass feed_id to the JS.
		if ( did_action( 'gravityview/calendar/print-scripts/' . $feed_id ) ) {
			return;
		}

		$calendar_data = $this->get_calendar_instance_options( $feed_id, $form_id, $view_id );

		$this->enqueue_scripts();

		wp_enqueue_script( 'gv-fullcalendar-js' );

		$wp_scripts->add_inline_script( 'gv-fullcalendar-js', 'var gvCalendarData = gvCalendarData || []; gvCalendarData[' . $feed_id . '] = ' . json_encode( $calendar_data ) );

		do_action( 'gravityview/calendar/print-scripts/' . $feed_id );

		do_action( 'gravityview/calendar/enqueue-scripts', $feed_id );
	}

	/**
	 * Adds clipboard scripts for frontend use.
	 *
	 * @since 2.0
	 *
	 * @return void
	 */
	public static function gv_calendar_clipboard_frontend_scripts() {
		wp_enqueue_script( 'clipboard' );

		$script = <<<EOD
			document.addEventListener( 'DOMContentLoaded', ( event ) => {
				const clipboard = new ClipboardJS( '.gv-calendar-ics-copy > a' );
				
				let successTimeout;
	
				clipboard.on( 'success', function ( e ) {
					const triggerElement = e.trigger;
					const successElement = triggerElement.nextElementSibling;
			
					e.clearSelection();
					
					triggerElement.focus();

					clearTimeout( successTimeout );

					successElement.classList.remove( 'hidden' );

					successTimeout = setTimeout( function () {
						successElement.classList.add( 'hidden' );
					}, 3000 );
				} );
			} );
EOD;
		wp_add_inline_script( 'clipboard', $script );

	}

	/**
	 * Outputs styles for calendars.
	 *
	 * @since 1.0.0
	 * @since 1.5 Added $view_id parameter.
	 *
	 * @param int $feed_id Feed ID.
	 *
	 * @return void
	 */
	public function output_calendar_styles( $feed_id = 0 ) {
		// Don't print the same configuration twice.
		// This can change in the future, since we pass feed_id to the JS.
		if ( did_action( 'gravityview/calendar/print-styles' ) ) {
			return;
		}

		foreach ( [ 'gv-fullcalendar-css', 'fullcalendar-core-css', 'gv-calendar-tooltip-css' ] as $style ) {
			wp_enqueue_style( $style );
		}

		do_action( 'gravityview/calendar/print-styles', $feed_id );
	}

	/**
	 * Generates FullCalendar options data for the feed.
	 *
	 * @since 1.0.0
	 *
	 * @param integer $feed_id Feed ID.
	 *
	 * @return array Array of options data.
	 */
	public function calendar_options( $feed_id = 0 ) {

		if ( ! $feed_id ) {
			$feed_id = $this->get_current_feed_id();
		}

		$options = [
			'initialView' => 'dayGridMonth',
		];

		$feed = $this->get_feed( $feed_id );

		if ( ! $feed || empty( $feed['meta'] ) ) {
			return $options;
		}

		$feed_settings = array_merge( $feed['meta'], $this->get_posted_settings() );

		// Automatic navigation option.
		$options['navigateToEvents'] = rgar( $feed_settings, 'navigateToEvents' );

		// Locale.
		$options['locale'] = rgar( $feed_settings, 'localization' );

		// Layout view.
		$options['initialView'] = rgar( $feed_settings, 'layout', 'dayGridMonth' );

		// Header and footer.
		$header = $this->generate_controls_option( 'header', $feed_settings['controls'] );
		$footer = $this->generate_controls_option( 'footer', $feed_settings['controls'] );

		if ( $header ) {
			$options['headerToolbar'] = $header;
		}

		if ( $footer ) {
			$options['footerToolbar'] = $footer;
		}

		// Editable.
		if ( ! empty( $feed_settings['enable-editing'] ) ) {
			if ( $this->user_has_edit_roles( rgar( $feed_settings, 'editroles' ) ) ) {
				$options['editable'] = true;
			}
		}

		// Event color.
		$options['eventColor'] = rgar( $feed_settings, 'eventcolor', '#4286f5' );

		// Height.
		$options['height'] = ( 'fixed' === $feed_settings['sizing'] && ! empty( $feed_settings['height'] ) ) ? $feed_settings['height'] : 'auto';

		return $options;
	}

	/**
	 * Reduces queries by only fetching feeds from the database once per request.
	 *
	 * @since 2.5
	 *
	 * @param int $id Feed ID.
	 *
	 * @return array|false
	 */
	public function get_feed( $id ) {
		static $feeds;

		$feeds = isset( $feeds ) ? $feeds : [];

		if ( isset( $feeds[ $id ] ) ) {
			return $feeds[ $id ];
		}

		$feeds[ $id ] = parent::get_feed( $id );

		return $feeds[ $id ];
	}

	/**
	 * Checks whether the user has required roles for editing events.
	 *
	 * @since 1.0.0
	 *
	 * @param array $edit_roles Array of role keys.
	 *
	 * @return bool True: user can edit events.
	 */
	public function user_has_edit_roles( $edit_roles = [] ) {
		// No roles.
		if ( empty( $edit_roles ) ) {
			return false;
		}

		$current_user = wp_get_current_user();

		// Not logged-in.
		if ( ! $current_user->exists() ) {
			return false;
		}

		foreach ( (array) $edit_roles as $edit_role ) {
			if ( in_array( $edit_role, $current_user->roles, true ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Generates array of control data based on settings value.
	 *
	 * @since 1.0.0
	 *
	 * @param array  $controls_settings Control settings values.
	 * @param string $location          Location name.
	 *
	 * @return array|false Array of setting data or false if not set.
	 */
	public function generate_controls_option( $location = 'header', $controls_settings = [] ) {
		if ( empty( $controls_settings ) ) {
			return false;
		}

		$controls = [];

		$location_name = ( 'header' === $location ) ? 'top' : 'bottom';

		$section_settings = [
			'left'   => rgar( $controls_settings, 'controls-' . $location_name . '-left', [] ),
			'center' => rgar( $controls_settings, 'controls-' . $location_name . '-center', [] ),
			'right'  => rgar( $controls_settings, 'controls-' . $location_name . '-right', [] ),
		];

		// If all three areas are empty, return false.
		if ( empty( $section_settings['left'] ) && empty( $section_settings['center'] ) && empty( $section_settings['right'] ) ) {
			return false;
		}

		// Reformat array of buttons into the string format FullCalendar uses.
		foreach ( $section_settings as $section_name => $buttons ) {

			$button_data = implode( ',', $buttons );
			$button_data = str_replace( ',space,', ' ', $button_data );

			$controls[ $section_name ] = $button_data;

		}

		return $controls;
	}

	/**
	 * Returns event data, or sample data when none exists.
	 *
	 * @since 1.0.0
	 * @since 1.5 Added $from_date and $to_date parameters.
	 *
	 * @param int|false|null $from_date Start date timestamp used to filter events. False if error creating timestamp. Null if not set.
	 * @param int|false|null $to_date   End date timestamp used to filter events. False if error creating timestamp. Null if not set.
	 *
	 * @param integer        $feed_id   ID of the calendar feed.
	 *
	 * @throws Exception
	 *
	 * @return array Array of event data.
	 */
	public function calendar_events( $feed_id = 0, $from_date = null, $to_date = null ) {
		/**
		 * @filter `gk/gravitycalendar/events/custom-response` Whether to short-circuit event fetching/processing and return a custom response.
		 *
		 * @since  2.5
		 *
		 * @param bool|array $custom_response Whether to skip processing events and return a custom object. Default: false.
		 * @param int        $feed_id         ID of the current feed being processed.
		 */
		$custom_response = apply_filters( 'gk/gravitycalendar/events/custom-response', null, $feed_id );

		if ( is_array( $custom_response ) ) {
			return $custom_response;
		}

		if ( apply_filters( 'gk/gravitycalendar/events/override', false, $feed_id ) ) {
			return [];
		}

		$feed_id = (int) $feed_id;

		if ( ! $feed_id ) {
			$feed_id = (int) $this->get_current_feed_id();
		}

		// Return sample events if this is a new feed, and we're in the admin panel.
		$_this                   = $this;
		$maybe_get_sample_events = function ( $feed_id, $events = [], $entries = [] ) use ( $_this ) {
			if ( $_this->is_admin() && GFForms::is_gravity_page() ) {
				$extra_calendar_options = [];

				if ( ! $feed_id ) {
					$events                                            = $_this->get_sample_events();
					$extra_calendar_options['calendar_preview_notice'] = esc_html__( 'The Calendar is currently showing sample events. Configure required fields and save feed to see real events.', 'gk-gravitycalendar' );
				}

				if ( empty( $entries ) && empty( $events ) ) {
					$extra_calendar_options['calendar_preview_notice'] = esc_html__( 'None of the form entries match the configured feed logic.', 'gk-gravitycalendar' );
				}

				$modify_extra_calendar_options = function ( $options ) use ( $extra_calendar_options ) {
					return array_merge( $options, $extra_calendar_options );
				};

				add_filter( 'gravityview/calendar/extra_options', $modify_extra_calendar_options );
			}

			return $events;
		};

		// Get Feed and Form objects.
		if ( $feed_id ) {
			$feed = $this->get_feed( $feed_id );
			$form = GFAPI::get_form( $feed['form_id'] );

			// Map fields from settings to field IDs.
			$field_map = $this->map_fields( $feed );
		} else {
			return $maybe_get_sample_events( $feed_id );
		}

		/**
		 * @filter `gravityview/calendar/settings/total-event-limit` Modify the total number of events displayed on a calendar.
		 *
		 * @since  1.1
		 *
		 * @param int $total_event_limit Maximum total number of events to fetch (default: 1000).
		 * @param int $feed_id           ID of the current feed being processed.
		 *
		 * @todo   Consider making this a setting in the feed configuration
		 */
		$total_event_limit = (int) apply_filters( 'gravityview/calendar/settings/total-event-limit', self::DEFAULT_EVENT_LIMIT, $feed_id );

		/**
		 * @filter `gravityview/calendar/events/source_data` Define form entries with events
		 *
		 * @since  1.4
		 * @since  1.5.2 Added $from_date and $to_date parameters
		 *
		 * @param array          $entries   Form entries (default: empty array)
		 * @param int            $feed_id   ID of the current feed being processed
		 * @param int|false|null $from_date Start date timestamp used to filter events. False if error creating timestamp. Null if not set.
		 * @param int|false|null $to_date   End date timestamp used to filter events. False if error creating timestamp. Null if not set.
		 */
		$entries = apply_filters( 'gravityview/calendar/events/source_data', [], $feed_id, $from_date, $to_date );

		if ( ! is_array( $entries ) && ! $entries ) {
			return $maybe_get_sample_events( $feed_id );
		}

		// Get form entries.
		if ( is_array( $entries ) && count( $entries ) ) {
			$entries = $total_event_limit ? array_slice( $entries, 0, $total_event_limit ) : $entries;
		} else {
			// @todo remove field filters/search criteria in favor of Query Filters
			$start_date_filters = [
				'key'      => $field_map['start_date'],
				'operator' => '<>',
				'value'    => '',
			];

			if ( $from_date ) {
				$start_date_filters['operator'] = '>=';
				$start_date_filters['value']    = $from_date;
			}

			$end_date_filters = [];

			if ( $field_map['start_date'] !== $field_map['end_date'] ) {
				$end_date_filters = [
					'key'      => $field_map['end_date'],
					'operator' => '<>',
					'value'    => '',
				];
			}

			if ( $to_date ) {
				$end_date_filters = [
					'key'      => $field_map['end_date'],
					'operator' => '<=',
					'value'    => $to_date,
				];
			}

			$search_criteria = [
				'field_filters' => array_filter(
					[
						$start_date_filters,
						$end_date_filters,
					]
				),
			];

			/**
			 * @filter     `gravityview/calendar/settings/entries_status` Set status used to find matching entries (default: "active").
			 *
			 * @since      1.5.7
			 *
			 * @param string $status Entry status.
			 */
			$status = apply_filters( 'gravityview/calendar/settings/entry_status', 'active' );

			if ( ! empty( $status ) ) {
				$search_criteria['status'] = $status;
			}

			/**
			 * @filter `gravityview/calendar/settings/sort_order` Configure sort order for form entries.
			 *
			 * @since  1.4.4
			 *
			 * @param array $sorting   Default sort order (ASC by start date).
			 * @param int   $feed_id   ID of the current feed being processed.
			 * @param array $field_map Array of feed fields mapped to calendar settings (e.g., start_time, end_time).
			 */
			$sorting = apply_filters(
				'gravityview/calendar/settings/sort_order',
				[
					'key'       => rgars( $feed, 'meta/sort_field', $field_map['start_date'] ),
					'direction' => rgars( $feed, 'meta/sort_order', 'ASC' ),
				],
				$feed_id,
				$field_map
			);

			$conditional_logic = rgars( $feed, 'meta/conditional_logic', 'null' );

			/**
			 * @filter `gk/gravitycalendar/settings/db-batch-size` Control the number of records returned by a single database query when fetching events.
			 *
			 * @since  2.4
			 *
			 * @param int $db_query_batch_size Maximum total number of events to fetch (default: 500).
			 * @param int $feed_id             ID of the current feed being processed.
			 *
			 * @todo   Consider making this a setting in the feed configuration
			 */
			$db_query_batch_size = (int) apply_filters( 'gk/gravitycalendar/settings/db-batch-size', self::DEFAULT_DB_BATCH_SIZE, $feed_id );
			$offset              = 0;
			$loop                = true;
			$conditions          = [];

			// Process events with conditional logic.
			if ( $feed && 'null' !== $conditional_logic ) {
				$this->query_filters->set_form( $form );

				$this->query_filters->set_filters( $conditional_logic );

				$conditions = $this->query_filters->get_query_conditions();
			}

			while ( $loop ) {
				$query = new GF_Query(
					$feed['form_id'],
					$search_criteria,
					$sorting,
					[
						'offset'    => $offset,
						'page_size' => $db_query_batch_size,
					]
				);

				if ( ! empty( $conditions ) ) {
					$query_parts = $query->_introspect();

					$query->where( GF_Query_Condition::_and( $query_parts['where'], $conditions ) );
				}

				$entries_batch = $query->get();

				array_push( $entries, ...$entries_batch );

				$offset += $db_query_batch_size;

				if ( $db_query_batch_size < 1 || count( $entries_batch ) < ( $db_query_batch_size || $total_event_limit ) ) {
					$loop = false;
				}
			}
		}

		// Extract entry IDs.
		$entry_ids = ! empty( $entries ) ? array_column( $entries, 'id' ) : [];

		/**
		 * @filter `gravityview/calendar/events/exclude` Exclude events from calendar view.
		 *
		 * @since  1.1
		 * @since  1.5.2 Added $entries parameter.
		 *
		 * @param array $array     Array of entry IDs (default: empty array).
		 * @param array $form      Calendar form.
		 * @param array $feed      Calendar feed.
		 * @param array $field_map Array of feed fields mapped to calendar settings (e.g., start_time, end_time).
		 * @param array $entries   Form entries.
		 */
		$events_to_exclude = apply_filters( 'gravityview/calendar/events/exclude', [], $form, $feed, $field_map, $entries );

		/**
		 * @filter `gravityview/calendar/events/include` Include only these events in the calendar view.
		 *
		 * @since  1.1
		 * @since  1.5.2 Added $entries parameter.
		 *
		 * @param array $entry_ids Array of entry IDs (default: empty array or entry IDs).
		 * @param array $form      Calendar form.
		 * @param array $feed      Calendar feed.
		 * @param array $field_map Array of feed fields mapped to calendar settings (e.g., start_time, end_time).
		 * @param array $entries   Form entries.
		 */
		$events_to_include = apply_filters( 'gravityview/calendar/events/include', $entry_ids, $form, $feed, $field_map, $entries );

		/**
		 * @filter `gravityview/calendar/events/do_shortcodes` Whether to process shortcodes in the event title and event description.
		 *
		 * @since  1.4
		 *
		 * @param bool  $do_shortcode Default: true.
		 * @param array $form         Calendar form.
		 * @param array $feed         Calendar feed.
		 * @param array $field_map    Array of feed fields mapped to calendar settings (e.g., start_time, end_time).
		 */
		$do_shortcodes = apply_filters( 'gravityview/calendar/events/do_shortcodes', true, $form, $feed, $field_map );

		$events = [];

		// Loop through entries and process.
		if ( ! empty( $events_to_include ) && $field_map && $entries ) {

			foreach ( $entries as $entry ) {
				$entry = ( $entry instanceof GF_Entry ) ? $entry->as_entry() : $entry;

				if ( ! is_array( $entry ) ) {
					continue;
				}

				$event_condition_met = $this->is_feed_condition_met( $feed, $form, $entry );
				$exclude_event       = in_array( (int) $entry['id'], $events_to_exclude );
				$include_event       = in_array( (int) $entry['id'], $events_to_include );

				// Skip this entry if it's on the list of exclusions or feed condition is not met.
				if ( $exclude_event || ( count( $events_to_include ) && ! $include_event ) || ! $event_condition_met ) {
					continue;
				}

				// Extract, validate and format start and end periods.
				preg_match( '/\d{4}-\d{2}-\d{2}/i', rgar( $entry, $field_map['start_date'] ), $start_date );
				preg_match( '/\d{4}-\d{2}-\d{2}/i', rgar( $entry, $field_map['end_date'] ), $end_date );
				$start_date = ( $start_date ) ? $start_date[0] : null;
				$end_date   = ( $end_date ) ? $end_date[0] : null;

				if ( ! $start_date || ! $end_date ) {
					continue;
				}

				preg_match( '/\d{1,2}:\d{2}(\s[a|p]m)?/i', rgar( $entry, $field_map['start_time'] ), $start_time );
				preg_match( '/\d{1,2}:\d{2}(\s[a|p]m)?/i', rgar( $entry, $field_map['end_time'] ), $end_time );

				$start_time = ( $start_time ) ? $start_time[0] : null;
				try {
					$start = new DateTime( $start_date . ' ' . $start_time );
					$start = $start_time ? $start->format( 'Y-m-d\TH:i:s' ) : $start->format( 'Y-m-d' );
				} catch ( Exception $e ) {
					try {
						$start = new DateTime( $start_date );
						$start = $start->format( 'Y-m-d' );
					} catch ( Exception $e ) {
						$start = null;
					}
				}

				/**
				 * @filter `gravityview/calendar/events/allow_invalid_start_date` Whether to allow events with invalid start date.
				 *
				 * @since  1.5.4
				 *
				 * @param bool  $do_shortcode Default: false.
				 * @param array $entry        Form entry.
				 * @param array $form         Calendar form.
				 * @param array $feed         Calendar feed.
				 */
				if ( ! $start && ! apply_filters( 'gravityview/calendar/events/allow_invalid_start_date', false, $entry, $form, $feed ) ) {
					continue;
				}

				$end_time = ( $end_time ) ? $end_time[0] : $start_time;
				try {
					$end = new DateTime( $end_date . ' ' . $end_time );
					$end = $end_time ? $end->format( 'Y-m-d\TH:i:s' ) : $end->format( 'Y-m-d' );
				} catch ( Exception $e ) {
					try {
						$end = new DateTime( $end_date );
						$end = $end->format( 'Y-m-d' );
					} catch ( Exception $e ) {
						$end = null;
					}
				}

				$is_all_day_event = ( ! $start_time && ! $end_time );

				if ( $is_all_day_event && $start_date !== $end_date ) {
					$end = ( new DateTime( $end ) )->modify( '+1 day' )->format( 'Y-m-d\TH:i:s' );
				}

				// Handle fields that support merge tags.
				$title       = GFCommon::replace_variables( $field_map['title'], $form, $entry, false, false, false );
				$description = GFCommon::replace_variables( $field_map['description'], $form, $entry, false, false, false );
				$location    = GFCommon::replace_variables( $field_map['location'], $form, $entry, false, false, false );

				if ( $do_shortcodes ) {
					$title       = do_shortcode( $title );
					$description = do_shortcode( $description );
				}

				$title       = wp_specialchars_decode( $title, ENT_QUOTES );
				$description = wp_specialchars_decode( $description, ENT_QUOTES );
				$location    = wp_specialchars_decode( $location, ENT_QUOTES );

				// Map all the event data to the correct array keys.
				$event_data = [
					'event_id'    => $entry['id'],
					'title'       => $title,
					'description' => $description,
					'location'    => $location,
					'url'         => rgar( $entry, $field_map['url'], '' ),
					'start'       => $start,
					'end'         => $end,
					'allDay'      => $is_all_day_event,
				];

				$events[] = $event_data;
			}
		}

		if ( $total_event_limit === count( $events ) ) {
			self::logger()->error( sprintf( 'The total event limit has been reached for Calendar Feed #%d; the calendar is displaying the maximum number of entries (%d). Use `gravityview/calendar/settings/total-event-limit` filter to modify this limit.', $feed_id, $total_event_limit ) );
		}

		// Return sample events if there are no entries.
		$events = $maybe_get_sample_events( $feed_id, $events, $entries );

		/**
		 * @filter `gravityview/calendar/events` Modify the final events array.
		 *
		 * @since  1.4
		 * @since  1.5.2 Added $entries parameter.
		 *
		 * @see    https://docs.gravitykit.com/article/681-modifying-a-single-event
		 *
		 * @param array $events    Array of events.
		 * @param array $form      Calendar form.
		 * @param array $feed      Calendar feed.
		 * @param array $field_map Array of feed fields mapped to calendar settings (e.g., start_time, end_time).
		 * @param array $entries   Form entries.
		 */
		$events = apply_filters( 'gravityview/calendar/events', $events, $form, $feed, $field_map, $entries );

		return $events;
	}

	/**
	 * Maps fields from settings.
	 *
	 * @since 1.0.0
	 *
	 * @param array $feed Array of feed settings.
	 *
	 * @return array Array of mapped fields, filtered for empty status.
	 */
	public function map_fields( $feed = [] ) {
		$field_map = [
			'start_date'  => rgars( $feed, 'meta/startdate', false ),
			'end_date'    => rgars( $feed, 'meta/enddate', false ),
			'start_time'  => rgars( $feed, 'meta/starttime', false ),
			'end_time'    => rgars( $feed, 'meta/endtime', false ),
			'title'       => rgars( $feed, 'meta/eventtitle', false ),
			'description' => rgars( $feed, 'meta/eventdescription', false ),
			'location'    => rgars( $feed, 'meta/eventlocation', false ),
			'url'         => rgars( $feed, 'meta/eventurl', false ),
			'color'       => rgars( $feed, 'meta/eventcolor', false ),
		];

		if ( empty( $field_map['end_date'] ) ) {
			$field_map['end_date'] = $field_map['start_date'];
		}

		return $field_map;
	}

	/**
	 * Generates some sample events to display.
	 *
	 * @since 1.0.0
	 *
	 * @param string|null $meta Metadata to add to each sample event.
	 *
	 * @return array Array of sample event data.
	 */
	public function get_sample_events( $meta = null ) {
		// Start with the sample events from FullCalendar.
		$sample_events = [
			[
				'title' => esc_html__( '(Sample) All Day Event', 'gk-gravitycalendar' ),
				'start' => '2019-04-01',
			],
			[
				'title' => esc_html__( '(Sample) Long Event', 'gk-gravitycalendar' ),
				'start' => '2019-04-07',
				'end'   => '2019-04-10',
			],
			[
				'title' => esc_html__( '(Sample) Conference', 'gk-gravitycalendar' ),
				'start' => '2019-04-11',
				'end'   => '2019-04-13',
			],
			[
				'title' => esc_html__( '(Sample) Meeting', 'gk-gravitycalendar' ),
				'start' => '2019-04-12T10:30:00',
				'end'   => '2019-04-12T12:30:00',
			],
			[
				'title' => esc_html__( '(Sample) Lunch', 'gk-gravitycalendar' ),
				'start' => '2019-04-12T12:00:00',
			],
			[
				'title' => esc_html__( '(Sample) Meeting', 'gk-gravitycalendar' ),
				'start' => '2019-04-12T14:30:00',
			],
			[
				'title' => esc_html__( '(Sample) Happy Hour', 'gk-gravitycalendar' ),
				'start' => '2019-04-12T17:30:00',
			],
			[
				'title' => esc_html__( '(Sample) Dinner', 'gk-gravitycalendar' ),
				'start' => '2019-04-12T20:00:00',
			],
			[
				'title' => esc_html__( '(Sample) Birthday Party', 'gk-gravitycalendar' ),
				'start' => '2019-04-13T07:00:00',
			],
			[
				'title' => esc_html__( '(Sample) Click for Google', 'gk-gravitycalendar' ),
				'url'   => 'https://www.google.com/',
				'start' => '2019-04-28',
			],
		];

		// Swap out the April 2019 month with the current month and year.
		array_walk_recursive(
			$sample_events,
			function ( &$value ) {

				$value = str_replace( '2019-04', date( 'Y-m' ), $value );
			}
		);

		// Set event description and meta.
		$sample_events = array_map(
			function ( $event ) use ( $meta ) {
				$event['description'] = __( 'This is a sample description.', 'gk-gravitycalendar' );

				if ( ! is_null( $meta ) ) {
					$event['meta'] = $meta;
				}

				return $event;
			},
			$sample_events
		);

		return $sample_events;
	}

	/**
	 * Returns an array of available FullCalendar locales and, where available, their WP equivalent.
	 *
	 * @since 1.3
	 *
	 * @return array[] FullCalendar locales.
	 */
	public function get_fc_locales() {
		$fc_locales = [
			[
				'lang'      => esc_html__( 'Afrikaans', 'gk-gravitycalendar' ),
				'fc_locale' => 'af',
				'wp_locale' => 'af',
			],
			[
				'lang'      => esc_html__( 'Albanian', 'gk-gravitycalendar' ),
				'fc_locale' => 'sq',
				'wp_locale' => 'sq',
			],
			[
				'lang'      => esc_html__( 'Arabic', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar',
				'wp_locale' => 'ar',
			],
			[
				'lang'      => esc_html__( 'Arabic (Algeria)', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar-dz',
				'wp_locale' => 'arq',
			],
			[
				'lang'      => esc_html__( 'Arabic (Libya)', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar-ly',
			],
			[
				'lang'      => esc_html__( 'Arabic (Saudi Arabia)', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar-sa',
			],
			[
				'lang'      => esc_html__( 'Arabic (Morocco)', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar-ma',
				'wp_locale' => 'ary',
			],
			[
				'lang'      => esc_html__( 'Arabic (Tunisia)', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar-tn',
			],
			[
				'lang'      => esc_html__( 'Arabic (Kuwait)', 'gk-gravitycalendar' ),
				'fc_locale' => 'ar-kw',
			],
			[
				'lang'      => esc_html__( 'Basque', 'gk-gravitycalendar' ),
				'fc_locale' => 'eu',
				'wp_locale' => 'eu',
			],
			[
				'lang'      => esc_html__( 'Bulgarian', 'gk-gravitycalendar' ),
				'fc_locale' => 'bg',
				'wp_locale' => 'bg_BG',
			],
			[
				'lang'      => esc_html__( 'Bosnian', 'gk-gravitycalendar' ),
				'fc_locale' => 'bs',
				'wp_locale' => 'bs_BA',
			],
			[
				'lang'      => esc_html__( 'Catalan', 'gk-gravitycalendar' ),
				'fc_locale' => 'ca',
				'wp_locale' => 'ca',
			],
			[
				'lang'      => esc_html__( 'Chinese (China)', 'gk-gravitycalendar' ),
				'fc_locale' => 'zh-cn',
				'wp_locale' => 'zh_CN',
			],
			[
				'lang'      => esc_html__( 'Chinese (Taiwan)', 'gk-gravitycalendar' ),
				'fc_locale' => 'zh-tw',
				'wp_locale' => 'zh_TW',
			],
			[
				'lang'      => esc_html__( 'Croatian', 'gk-gravitycalendar' ),
				'fc_locale' => 'hr',
				'wp_locale' => 'hr',
			],
			[
				'lang'      => esc_html__( 'Czech', 'gk-gravitycalendar' ),
				'fc_locale' => 'cs',
				'wp_locale' => 'cs_CZ',
			],
			[
				'lang'      => esc_html__( 'Danish', 'gk-gravitycalendar' ),
				'fc_locale' => 'da',
				'wp_locale' => 'da_DK',
			],
			[
				'lang'      => esc_html__( 'Dutch', 'gk-gravitycalendar' ),
				'fc_locale' => 'nl',
				'wp_locale' => 'nl_NL',
			],
			[
				'lang'      => esc_html__( 'English', 'gk-gravitycalendar' ),
				'fc_locale' => 'en',
				'wp_locale' => 'en_US',
			],
			[
				'lang'      => esc_html__( 'English (Australia)', 'gk-gravitycalendar' ),
				'fc_locale' => 'en-au',
				'wp_locale' => 'en_AU',
			],
			[
				'lang'      => esc_html__( 'English (New Zealand)', 'gk-gravitycalendar' ),
				'fc_locale' => 'en-nz',
				'wp_locale' => 'en_NZ',
			],
			[
				'lang'      => esc_html__( 'English (UK)', 'gk-gravitycalendar' ),
				'fc_locale' => 'en-gb',
				'wp_locale' => 'en_GB',
			],
			[
				'lang'      => esc_html__( 'Estonian', 'gk-gravitycalendar' ),
				'fc_locale' => 'et',
				'wp_locale' => 'et',
			],
			[
				'lang'      => esc_html__( 'Farsi', 'gk-gravitycalendar' ),
				'fc_locale' => 'fa',
				'wp_locale' => 'fa_IR',
			],
			[
				'lang'      => esc_html__( 'Finnish', 'gk-gravitycalendar' ),
				'fc_locale' => 'fi',
				'wp_locale' => 'fi',
			],
			[
				'lang'      => esc_html__( 'French (Switzerland)', 'gk-gravitycalendar' ),
				'fc_locale' => 'fr-ch',

			],
			[
				'lang'      => esc_html__( 'French (Canada)', 'gk-gravitycalendar' ),
				'fc_locale' => 'fr-ca',
				'wp_locale' => 'fr_CA',
			],
			[
				'lang'      => esc_html__( 'French (France)', 'gk-gravitycalendar' ),
				'fc_locale' => 'fr',
				'wp_locale' => 'fr_FR',
			],
			[
				'lang'      => esc_html__( 'Galician', 'gk-gravitycalendar' ),
				'fc_locale' => 'gl',
				'wp_locale' => 'gl_ES',
			],
			[
				'lang'      => esc_html__( 'German', 'gk-gravitycalendar' ),
				'fc_locale' => 'de',
				'wp_locale' => 'de_DE',
			],
			[
				'lang'      => esc_html__( 'Georgian', 'gk-gravitycalendar' ),
				'fc_locale' => 'ka',
				'wp_locale' => 'ka_GE',
			],
			[
				'lang'      => esc_html__( 'Greek', 'gk-gravitycalendar' ),
				'fc_locale' => 'el',
				'wp_locale' => 'el',
			],
			[
				'lang'      => esc_html__( 'Hebrew', 'gk-gravitycalendar' ),
				'fc_locale' => 'he',
				'wp_locale' => 'he_IL',
			],
			[
				'lang'      => esc_html__( 'Hindi', 'gk-gravitycalendar' ),
				'fc_locale' => 'hi',
				'wp_locale' => 'hi_IN',
			],
			[
				'lang'      => esc_html__( 'Hungarian', 'gk-gravitycalendar' ),
				'fc_locale' => 'hu',
				'wp_locale' => 'hu_HU',
			],
			[
				'lang'      => esc_html__( 'Icelandic', 'gk-gravitycalendar' ),
				'fc_locale' => 'is',
				'wp_locale' => 'is_IS',
			],
			[
				'lang'      => esc_html__( 'Indonesian', 'gk-gravitycalendar' ),
				'fc_locale' => 'id',
				'wp_locale' => 'id_ID',
			],
			[
				'lang'      => esc_html__( 'Italian', 'gk-gravitycalendar' ),
				'fc_locale' => 'it',
				'wp_locale' => 'it_IT',
			],
			[
				'lang'      => esc_html__( 'Japanese', 'gk-gravitycalendar' ),
				'fc_locale' => 'ja',
				'wp_locale' => 'ja',
			],
			[
				'lang'      => esc_html__( 'Kazakh', 'gk-gravitycalendar' ),
				'fc_locale' => 'kk',
				'wp_locale' => 'kk',
			],
			[
				'lang'      => esc_html__( 'Korean', 'gk-gravitycalendar' ),
				'fc_locale' => 'ko',
				'wp_locale' => 'ko_KR',
			],
			[
				'lang'      => esc_html__( 'Lithuanian', 'gk-gravitycalendar' ),
				'fc_locale' => 'lt',
				'wp_locale' => 'lt_LT',
			],
			[
				'lang'      => esc_html__( 'Luxembourgish', 'gk-gravitycalendar' ),
				'fc_locale' => 'lb',
				'wp_locale' => 'lb_LU',
			],
			[
				'lang'      => esc_html__( 'Latvian', 'gk-gravitycalendar' ),
				'fc_locale' => 'lv',
				'wp_locale' => 'lv',
			],
			[
				'lang'      => esc_html__( 'Macedonian', 'gk-gravitycalendar' ),
				'fc_locale' => 'mk',
				'wp_locale' => 'mk_MK',
			],
			[
				'lang'      => esc_html__( 'Malay', 'gk-gravitycalendar' ),
				'fc_locale' => 'ms',
				'wp_locale' => 'ms_MY',
			],
			[
				'lang'      => esc_html__( 'Norwegian (Bokmål)', 'gk-gravitycalendar' ),
				'fc_locale' => 'nb',
				'wp_locale' => 'nb_NO',
			],
			[
				'lang'      => esc_html__( 'Norwegian (Nynorsk)', 'gk-gravitycalendar' ),
				'fc_locale' => 'nn',
				'wp_locale' => 'nn_NO',
			],
			[
				'lang'      => esc_html__( 'Polish', 'gk-gravitycalendar' ),
				'fc_locale' => 'pl',
				'wp_locale' => 'pl_PL',
			],
			[
				'lang'      => esc_html__( 'Portuguese (Portugal)', 'gk-gravitycalendar' ),
				'fc_locale' => 'pt',
				'wp_locale' => 'pt_PT',
			],
			[
				'lang'      => esc_html__( 'Portuguese (Brazil)', 'gk-gravitycalendar' ),
				'fc_locale' => 'pt-br',
				'wp_locale' => 'pt_BR',
			],
			[
				'lang'      => esc_html__( 'Romanian', 'gk-gravitycalendar' ),
				'fc_locale' => 'ro',
				'wp_locale' => 'ro_RO',
			],
			[
				'lang'      => esc_html__( 'Russian', 'gk-gravitycalendar' ),
				'fc_locale' => 'ru',
				'wp_locale' => 'ru_RU',
			],
			[
				'lang'      => esc_html__( 'Serbian', 'gk-gravitycalendar' ),
				'fc_locale' => 'sr',
			],
			[
				'lang'      => esc_html__( 'Serbian (Cyrillic)', 'gk-gravitycalendar' ),
				'fc_locale' => 'sr-cyrl',
				'wp_locale' => 'sr_RS',
			],
			[
				'lang'      => esc_html__( 'Slovak', 'gk-gravitycalendar' ),
				'fc_locale' => 'sk',
				'wp_locale' => 'sk_SK',
			],
			[
				'lang'      => esc_html__( 'Slovenian', 'gk-gravitycalendar' ),
				'fc_locale' => 'sl',
				'wp_locale' => 'sl_SI',
			],
			[
				'lang'      => esc_html__( 'Spanish (United States)', 'gk-gravitycalendar' ),
				'fc_locale' => 'es-us',
			],
			[
				'lang'      => esc_html__( 'Spanish (Spain)', 'gk-gravitycalendar' ),
				'fc_locale' => 'es',
				'wp_locale' => 'es_ES',
			],
			[
				'lang'      => esc_html__( 'Swedish', 'gk-gravitycalendar' ),
				'fc_locale' => 'sv',
				'wp_locale' => 'sv_SE',
			],
			[
				'lang'      => esc_html__( 'Thai', 'gk-gravitycalendar' ),
				'fc_locale' => 'th',
				'wp_locale' => 'th',
			],
			[
				'lang'      => esc_html__( 'Turkish', 'gk-gravitycalendar' ),
				'fc_locale' => 'tr',
				'wp_locale' => 'tr_TR',
			],
			[
				'lang'      => esc_html__( 'Ukrainian', 'gk-gravitycalendar' ),
				'fc_locale' => 'uk',
				'wp_locale' => 'uk',
			],
			[
				'lang'      => esc_html__( 'Vietnamese', 'gk-gravitycalendar' ),
				'fc_locale' => 'vi',
				'wp_locale' => 'vi',
			],
		];

		/**
		 * @filter `gravityview/calendar/scripts/fullcalendar/locales` Modify the list of available FullCalendar locales.
		 *
		 * @since  1.3
		 *
		 * @param array $fc_locales FullCalendar locales.
		 */
		$fc_locales = apply_filters( 'gravityview/calendar/scripts/fullcalendar/locales', $fc_locales );

		return $fc_locales;
	}

	// # MISC HELPER FUNCTIONS ------------------------------------------------------------------------------------------

	/**
	 * Returns roles as a choices list for use with Event Editing.
	 *
	 * @since 1.0.0
	 *
	 * @return array[] Each user role has a `label` and `value`.
	 */
	public function get_roles_as_choices() {
		if ( ! function_exists( 'get_editable_roles' ) ) {
			require_once ABSPATH . '/wp-admin/includes/user.php';
		}

		$roles   = get_editable_roles();
		$choices = [];

		foreach ( $roles as $role => $details ) {
			$name      = translate_user_role( $details['name'] );
			$choices[] = [
				'label' => $name,
				'value' => $role,
			];
		}

		return $choices;
	}

	/**
	 * Returns calendar icon for the form settings menu.
	 *
	 * @since 1.5
	 *
	 * @return string
	 */
	public function get_menu_icon() {
		return '<svg style="height: 18px; width: 18px;" height="528" width="529" xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 528 529" viewBox="0 0 528 529"><path d="m456.959 528h-383.989c-39.763 0-71.998-32.236-71.998-72v-228c0-6.628 5.373-12 12-12h467.986v-96.001c0-13.255-10.745-24-23.999-24h-47.999v36.001c0 6.627-5.372 12-11.999 12h-24c-6.627 0-11.999-5.373-11.999-12v-36.001h-191.995v36.001c0 6.627-5.372 12-11.999 12h-24c-6.627 0-11.999-5.373-11.999-12v-36.001h-47.999c-13.254 0-23.999 10.745-23.999 24v36.001c0 6.627-5.372 11.999-12 11.999h-23.999c-6.627 0-12-5.372-12-11.999v-36.001c0-39.764 32.235-71.999 71.998-71.999h47.999v-36c0-6.628 5.372-12 11.999-12h24c6.627 0 11.999 5.372 11.999 12v36h191.995v-36c0-6.628 5.372-12 11.999-12h24c6.627 0 11.999 5.372 11.999 12v36h47.999c39.763 0 71.997 32.235 71.997 71.999v132c0 6.628-5.372 12.001-11.999 12.001h-467.986v192c0 13.254 10.745 23.999 23.999 23.999h383.989c13.254 0 23.999-10.745 23.999-23.999v-132c0-6.628 5.372-12.001 11.999-12.001h24c6.627 0 11.999 5.373 11.999 12.001v132c0 39.764-32.234 72-71.997 72z" fill-rule="evenodd"/></svg>';
	}

	/**
	 * Prefixes field name for use in input elements according to the GF version.
	 *
	 * @since 1.5.7
	 *
	 * @param string $name Field name.
	 *
	 * @return string Prefixed field name.
	 */
	private function prefix_field_name( $name = '' ) {
		$prefix = $this->is_gravityforms_supported( '2.5-beta' ) ? '_gform_setting_' : '_gaddon_setting_';

		return $prefix . $name;
	}

	/**
	 * Determines if the current view is the screen for editing a form's feed settings..
	 *
	 * @since 1.5.7
	 *
	 * @return bool
	 */
	public function is_feed_edit_page() {
		if ( $this->is_gravityforms_supported( '2.5-beta' ) ) {
			return parent::is_feed_edit_page();
		}

		return 'gf_edit_forms' === rgget( 'page' ) && $this->get_slug() === rgget( 'subview' ) && array_key_exists( 'fid', $_GET );
	}

	/**
	 * @inheritdoc
	 *
	 * @since 2.2
	 *
	 * @return bool
	 */
	public function is_feed_list_page() {
		return ! isset( $_GET['fid'] ) && ( 'gf_edit_forms' === rgget( 'page' ) && $this->get_slug() === rgget( 'subview' ) );
	}

	/**
	 * @inheritdoc
	 *
	 * @since 1.5.7
	 */
	public function register_noconflict_scripts( $scripts ) {
		$scripts[] = QueryFilters::ASSETS_HANDLE;

		return parent::register_noconflict_scripts( $scripts );
	}

	/**
	 * @inheritdoc
	 *
	 * @since 1.5.7
	 */
	public function register_noconflict_styles( $styles ) {
		$styles[] = QueryFilters::ASSETS_HANDLE;

		return parent::register_noconflict_styles( $styles );
	}

	/**
	 * Registers REST API routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		register_rest_route(
			'gravitycalendar/v1',
			'/feeds/(?P<hash>[a-z\d]+)',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'ics_export_route' ],
					'permission_callback' => '__return_true',
				],
			]
		);

		register_rest_route(
			'gravitycalendar/v1',
			'/feeds/(?P<id>[\d]+)/(?P<hash>[a-z\d]+)',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'ics_export_route' ],
					'permission_callback' => '__return_true',
				],
			]
		);

		register_rest_route(
			'gravitycalendar/v1',
			'/feeds/url/(?P<id>[\d]+)',
			[
				[
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => [ $this, 'proxy_icalendar_feed' ],
					'permission_callback' => '__return_true',
				],
			]
		);
	}

	/**
	 * Regenerates calendar feed from url to avoid CORS policy error.
	 *
	 * @param \WP_REST_Request $request
	 *
	 * @return void|mixed
	 */
	public function proxy_icalendar_feed( $request ) {

		if ( empty( $request['id'] ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_id', __( 'The feed id is required.', 'gk-gravitycalendar' ) );
		}

		$feed_id = (int) $request['id'];

		$feed = $this->get_feed( $feed_id );

		if ( empty( $feed ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_id_empty', __( 'The feed is required.', 'gk-gravitycalendar' ) );
		}

        // Fetch feeds from get_event_sources so that filters are applied.
        $feeds = $this->get_event_sources( $feed );

		if ( empty( $feeds ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_no_event_sources', __( 'The calendar feed URL is not set.', 'gk-gravitycalendar' ) );
		}

		$index_id = (int) $request->get_param('index');

		if ( ! isset( $feeds[ $index_id ] ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_index_missing', __( 'The calendar feed URL is not set.', 'gk-gravitycalendar' ) );
		}

		$source = $feeds[ $index_id ];

		$body = $this->get_proxy_feed_body( $source );

		if ( is_wp_error( $body ) ) {
			return $body;
		}

		$feed_name = rgar( $feed['meta'], 'feedName', 0 );

		switch ( $source['format'] ) {
			case 'json':
				header( 'Content-Type:application/json' );
				header( 'Content-Disposition:attachment; filename="' . sanitize_title_with_dashes( $feed_name, 'save' ) . '.json"' );
				break;
			case 'ics':
			default:
				header( 'Content-Type:text/calendar' );
				header( 'Content-Disposition:attachment; filename="' . sanitize_title_with_dashes( $feed_name, 'save' ) . '.ics"' );
				break;
		}

		header( 'charset:utf-8' );
		header( 'Content-Length: ' . strlen( $body ) );
		header( 'Connection: close' );

		echo $body;
	}

	/**
	 * Returns the body of the proxy feed.
	 *
	 * @param array $source Feed source, with `url`, `color`, and `format` keys.
	 *
	 * @return string|WP_Error
	 */
	private function get_proxy_feed_body( $source ) {

		if ( empty( $source['url'] ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_url_empty', __( 'The calendar feed URL is not set.', 'gk-gravitycalendar' ) );
		}

		$hash = sprintf( self::CACHE_KEY, wp_hash( $source['url'] ) );

		$body = GravityKit\GravityCalendar\Foundation\Helpers\WP::get_transient( $hash );

		if ( $body ) {
			return $body;
		}

		$file = wp_remote_get( $source['url'] );

		if ( is_wp_error( $file ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_url_error', sprintf( __( 'The calendar feed returned an error: %s', 'gk-gravitycalendar' ), $file->get_error_message() ) );
		}

		$code = wp_remote_retrieve_response_code( $file );

		if ( (int) $code >= 400 ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_unavailable', __( 'The calendar feed is unavailable.', 'gk-gravitycalendar' ) );
		}

		$body = wp_remote_retrieve_body( $file );

		if ( empty( $body ) ) {
			return new WP_Error( 'gv_calendar_icalendar_feed_body_empty', __( 'The calendar returned no content.', 'gk-gravitycalendar' ) );
		}

		// Only cache if the $body is less than 500Kb in size.
		if ( strlen( $body ) < self::CACHE_SIZE_LIMIT ) {
			GravityKit\GravityCalendar\Foundation\Helpers\WP::set_transient( $hash, $body, self::CACHE_EXPIRATION );
		}

		return $body;
	}

	/**
	 * Exports calendar feed REST route.
	 *
	 * @param array $data
	 *
	 * @return void|WP_Error
	 */
	public function ics_export_route( $data ) {
		$hash     = esc_attr( $data['hash'] );
		$event_id = ( isset( $data['id'] ) ? (int) $data['id'] : false );

		if ( empty( $hash ) ) {
			return new WP_Error( 'gv_calendar_ics_download', __( 'The feed hash is required.', 'gk-gravitycalendar' ) );
		}

		$calendar_feeds = self::get_instance()->get_active_feeds();

		if ( empty( $calendar_feeds ) ) {
			return new WP_Error( 'gv_calendar_ics_download', __( 'There are no calendar feeds.', 'gk-gravitycalendar' ) );
		}

		foreach ( $calendar_feeds as $feed ) {

			if ( empty( $feed['meta']['enable-ics-feed'] ) && ! $event_id ) {
				continue;
			}

			$url = rgars( $feed, 'meta/export-ics-url', '' );

			$parts = explode( '/', $url );

			$feed_setting_hash = array_pop( $parts );

			if ( $feed_setting_hash === $hash ) {
				return $this->generate_ics_file( $feed, true, $event_id );
			}
		}

		return new WP_Error( 'gv_calendar_ics_download', __( 'There are no feeds with that hash.', 'gk-gravitycalendar' ) );
	}

	/**
	 * Generates an ICS file for calendar feed.
	 *
	 * @param array   $feed
	 * @param boolean $download
	 * @param integer $single_entry
	 *
	 * @return void|string|WP_Error
	 */
	public function generate_ics_file( $feed, $download = false, $single_entry = false ) {
		$feed_id = (int) $feed['id'];

		$feed_name = rgar( $feed['meta'], 'feedName', 0 );

		try {
			$events = self::get_instance()->calendar_events( $feed_id );
		} catch ( Exception $exception ) {
			return new WP_Error( 'gv_calendar_ics_download_datetime', __( 'An error occurred.', 'gk-gravitycalendar' ) );
		}

		if ( ! class_exists( '\GravityKit\GravityCalendar\Spatie\IcalendarGenerator\Components\Calendar' ) ) {
			return new WP_Error( 'gv_calendar_ics_download_library_not_loaded', __( 'An error occurred: a required library is missing.', 'gk-gravitycalendar' ) );
		}

		$calendar = Calendar::create( $feed_name );

		if ( $single_entry ) {
			$events = array_values(
				array_filter(
					$events,
					function ( $event ) use ( $single_entry ) {
						return (int) $event['event_id'] === (int) $single_entry;
					}
				)
			);
		}

		foreach ( $events as $_event ) {
			$description = $this->prepare_description( rgar( $_event, 'description', '' ) );

			try {
				$Event = Event::create()->name( rgar( $_event, 'title', '' ) )
				              ->description( $description )
				              ->address( rgar( $_event, 'location', '' ) )
				              ->url( rgar( $_event, 'url', '' ) )
				              ->withoutTimezone()
				              ->createdAt( new DateTime( get_gmt_from_date( rgar( $_event, 'start', '' ) ) ) )
				              ->startsAt( new DateTime( get_gmt_from_date( rgar( $_event, 'start', '' ) ) ) )
				              ->endsAt( new DateTime( get_gmt_from_date( rgar( $_event, 'end', '' ) ) ) );

				if ( rgar( $_event, 'allDay', false ) ) {
					$Event = $Event->startsAt( new DateTime( rgar( $_event, 'start', '' ) ) )
					               ->endsAt( new DateTime( rgar( $_event, 'end', '' ) ) )
					               ->fullDay();
				}

				$calendar->event( $Event );
			} catch ( Exception $e ) {
				return new WP_Error( 'gv_calendar_ics_download', $e->getMessage() );
			}
		}

		if ( ! $download ) {
			return $calendar->toString();
		}

		header( 'Content-Type:text/calendar' );
		header( 'Content-Disposition:attachment; filename="' . sanitize_title_with_dashes( $feed_name, 'save' ) . '.ics"' );
		header( 'charset:utf-8' );
		header( 'Content-Length: ' . strlen( $calendar->get() ) );
		header( 'Connection: close' );

		echo $calendar->get();

		exit;
	}

	/**
	 * Fixes the description to be RFC compliant.
	 *
	 * @param string $description Event description.
	 *
	 * @return string Event description, with CRLF replaced with "\n".
	 */
	private function prepare_description( $description ) {
		/**
		 * Multiline is fine, but we need to replace actual CRLF characters with literal "\n".
		 *
		 * @see https://www.rfc-editor.org/rfc/rfc5545#section-3.8.1.5
		 */
		return str_replace( "\r\n", "\n", $description );
	}

	/**
	 * Fires action after a feed is saved.
	 *
	 * @param integer $feed_id
	 * @param integer $form_id
	 * @param array   $settings
	 * @param object  $class
	 *
	 * @return void
	 */
	public function post_feed_save( $feed_id, $form_id, $settings, $class ) {
		if ( empty( $settings['enable-ics-feed'] ) ) {
			return;
		}

		// Check if url already exists or a reset is required.
		if ( $settings['export-ics-url'] == '' || (int) $settings['reset-ics-url'] === 1 ) {
			$url                        = rest_url( 'gravitycalendar/v1/feeds/' . wp_hash( wp_generate_password() ) );
			$settings['export-ics-url'] = $url;
			$settings['reset-ics-url']  = '';
			$this->update_feed_meta( $feed_id, $settings );
		}
	}

	/**
	 * Conditionally displays Help Scout beacon on certain pages.
	 *
	 * @since 2.2
	 *
	 * @param bool $display
	 *
	 * @return bool
	 */
	public function maybe_display_helpscout_beacon( $display ) {
		if ( $display ) {
			return true;
		}

		return $this->is_feed_edit_page() || $this->is_feed_list_page();
	}

	/**
	 * Whether the provided feed is secured.
	 *
	 * @since 2.6
	 *
	 * @param array $feed The feed object.
	 *
	 * @return bool
	 */
	final public function is_secure( array $feed ) : bool {
		if ( ! isset( $feed['id'], $feed['form_id'], $feed['meta'] ) ) {
			self::logger()->error( 'The provided array is not a correct feed object.', compact( 'feed' ) );
		}

		return (bool) rgars( $feed, 'meta/is_secure', false );
	}

	/**
	 * Calculates and returns the calendar feed's validation secret.
	 *
	 * @since 2.6
	 *
	 * @param array $feed                     The feed object.
	 * @param bool  $skip_feed_security_check Whether to skip the security check. Default is false.
	 *
	 * @throws InvalidArgumentException When the feed object is not valid.
	 *
	 * @return string|null The calendar feed's secret.
	 */
	final public function get_validation_secret( array $feed, $skip_feed_security_check = false ) : ?string {
		if ( ! $skip_feed_security_check && ! $this->is_secure( $feed ) ) {
			self::logger()->debug( 'Feed is not secured.', compact( 'feed' ) );

			return null;
		}

		if ( ! class_exists( GravityKitFoundation::class ) ) {
			self::logger()->error( 'Encryption failed because Foundation is not (yet) registered.', compact( 'feed' ) );

			return null;
		}

		$foundation = GravityKitFoundation::get_instance();
		$encryption = $foundation->encryption();
		$hash       = $encryption->hash( sprintf( '%d.%d', rgar( $feed, 'form_id' ), rgar( $feed, 'id' ) ) );

		$secret = substr( $hash, 0, 12 );
		if ( $secret === false || strlen( $secret ) !== 12 ) {
			self::logger()->error( 'Foundation failed to create a secret.' );

			return null;
		}

		return $secret;
	}

	/**
	 * Returns whether the provided secret validates for a calendar feed.
	 *
	 * @since 2.6
	 *
	 * @param array $feed The feed object.
	 * @param string $secret The provided secret.
	 *
	 * @return bool
	 * @throws InvalidArgumentException When the feed object is not valid.
	 */
	final public function is_valid_secret( array $feed, string $secret ) : bool {
		// If it is not secured, we allow any secret.
		if ( ! $this->is_secure( $feed ) ) {
			return true;
		}

		$validation_secret = $this->get_validation_secret( $feed );
		if ( ! $validation_secret ) {
			return true;
		}

		return $secret === $validation_secret;
	}

	/**
	 * Returns the logger for this plugin.
	 *
	 * @since 2.6
	 *
	 * @return LoggerInterface
	 */
	final public static function logger() {
		$calendar = static::get_instance();

		return GravityKitFoundation::logger( $calendar->get_slug(), $calendar->_title );
	}
}

GV_Extension_Calendar_Feed::get_instance();
