Value => label. */ public static function displays() { return array( 'after' => __( 'Inline, after the content', 'asklet' ), 'before' => __( 'Inline, before the content', 'asklet' ), 'panel' => __( 'Side panel: a Feedback tab on the right edge opens it', 'asklet' ), 'slidein' => __( 'Slide-in: a card appears in the corner when the reader reaches the end', 'asklet' ), 'manual' => __( 'Only where I add the Asklet block or [asklet] shortcode', 'asklet' ), ); } /** * Hooks the settings page and the plugin-list link. */ public static function init() { add_action( 'admin_init', array( __CLASS__, 'register' ) ); add_action( 'admin_menu', array( __CLASS__, 'add_page' ) ); add_filter( 'plugin_action_links_' . plugin_basename( ASKLET_PLUGIN_FILE ), array( __CLASS__, 'action_links' ) ); add_action( 'admin_notices', array( __CLASS__, 'missing_key_notice' ) ); } /** * The saved settings merged over the defaults. * * @return array{site_key: string, post_types: string[], display: string, title: string, tab_label: string, tab_color: string, height_mode: string, height: int, max_width: int, align: string} */ public static function get() { $saved = get_option( self::OPTION, array() ); $settings = wp_parse_args( is_array( $saved ) ? $saved : array(), array( 'site_key' => '', 'post_types' => array( 'post' ), 'display' => 'after', 'title' => '', 'tab_label' => '', 'tab_color' => self::DEFAULT_TAB_COLOR, 'height_mode' => 'fit', 'height' => self::DEFAULT_HEIGHT, 'max_width' => 0, 'align' => 'center', ) ); // The option can be edited outside this page (wp-cli, a migration, another plugin): coerce // every value to its type so the front end never fails on a bad one. $settings['site_key'] = self::validate_site_key( is_string( $settings['site_key'] ) ? $settings['site_key'] : '' ); $settings['post_types'] = is_array( $settings['post_types'] ) ? array_values( array_filter( $settings['post_types'], 'is_string' ) ) : array(); $settings['display'] = is_string( $settings['display'] ) && array_key_exists( $settings['display'], self::display_values() ) ? $settings['display'] : 'after'; $settings['title'] = is_string( $settings['title'] ) ? $settings['title'] : ''; $settings['tab_label'] = is_string( $settings['tab_label'] ) ? $settings['tab_label'] : ''; $tab_color = is_string( $settings['tab_color'] ) ? sanitize_hex_color( $settings['tab_color'] ) : ''; $settings['tab_color'] = $tab_color ? $tab_color : self::DEFAULT_TAB_COLOR; $settings['height_mode'] = 'fixed' === $settings['height_mode'] ? 'fixed' : 'fit'; $settings['height'] = self::clamp_height( $settings['height'] ); $settings['max_width'] = self::clamp_width( $settings['max_width'] ); $settings['align'] = 'left' === $settings['align'] ? 'left' : 'center'; return $settings; } /** * The display values, without labels: get() runs on every request, before translations may * be loaded, so it must not call __(). * * @return array */ private static function display_values() { return array_fill_keys( array( 'after', 'before', 'panel', 'slidein', 'manual' ), true ); } /** * A fixed height in px, within the limits. Anything that isn't a number gives the default. * * @param mixed $value Raw value. * @return int */ public static function clamp_height( $value ) { if ( ! is_numeric( $value ) ) { return self::DEFAULT_HEIGHT; } return (int) min( max( (int) round( (float) $value ), self::MIN_HEIGHT ), self::MAX_HEIGHT ); } /** * A maximum width in px, within the limits, or 0 (the full column) for empty, zero or * anything that isn't a number. * * @param mixed $value Raw value. * @return int */ public static function clamp_width( $value ) { if ( ! is_numeric( $value ) || (float) $value <= 0 ) { return 0; } return (int) min( max( (int) round( (float) $value ), self::MIN_WIDTH ), self::MAX_WIDTH ); } /** * Validates a site key: a bare UUID, surrounding spaces allowed. Anything else (a link, the * snippet, a partial key) is rejected. * * @param string $input Raw field value. * @return string The lower-case key, or '' when the input is not a UUID. */ public static function validate_site_key( $input ) { $input = trim( (string) $input ); return preg_match( '/^' . self::SITE_KEY_PATTERN . '$/D', $input ) ? strtolower( $input ) : ''; } /** * The site key in what was pasted into the field: the key itself, or the survey link or * whole snippet from Asklet's Copy website snippet, whose /embed/s/ carries it. Any * other text, or a UUID outside that path, gives ''. * * @param string $input Raw field value. * @return string The lower-case key, or ''. */ public static function extract_site_key( $input ) { $input = trim( (string) $input ); $key = self::validate_site_key( $input ); if ( '' === $key && preg_match( '#/embed/s/(' . self::SITE_KEY_PATTERN . ')(?![0-9a-fA-F-])#', $input, $match ) ) { $key = strtolower( $match[1] ); } return $key; } /** * Registers the option and its fields. */ public static function register() { register_setting( self::PAGE, self::OPTION, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize' ), 'default' => array(), ) ); add_settings_section( 'asklet_main', '', '__return_false', self::PAGE ); add_settings_field( 'asklet_site_key', __( 'Site key', 'asklet' ), array( __CLASS__, 'field_site_key' ), self::PAGE, 'asklet_main', array( 'label_for' => 'asklet_site_key' ) ); add_settings_field( 'asklet_post_types', __( 'Show on', 'asklet' ), array( __CLASS__, 'field_post_types' ), self::PAGE, 'asklet_main' ); add_settings_field( 'asklet_display', __( 'Display', 'asklet' ), array( __CLASS__, 'field_display' ), self::PAGE, 'asklet_main' ); add_settings_field( 'asklet_title', __( 'Accessible title', 'asklet' ), array( __CLASS__, 'field_title' ), self::PAGE, 'asklet_main', array( 'label_for' => 'asklet_title' ) ); add_settings_section( 'asklet_size', __( 'Size', 'asklet' ), array( __CLASS__, 'section_size' ), self::PAGE ); add_settings_field( 'asklet_height', __( 'Height', 'asklet' ), array( __CLASS__, 'field_height' ), self::PAGE, 'asklet_size' ); add_settings_field( 'asklet_max_width', __( 'Maximum width', 'asklet' ), array( __CLASS__, 'field_max_width' ), self::PAGE, 'asklet_size', array( 'label_for' => 'asklet_max_width' ) ); add_settings_field( 'asklet_align', __( 'Alignment', 'asklet' ), array( __CLASS__, 'field_align' ), self::PAGE, 'asklet_size', array( 'label_for' => 'asklet_align' ) ); add_settings_section( 'asklet_panel', __( 'Side panel', 'asklet' ), array( __CLASS__, 'section_panel' ), self::PAGE ); add_settings_field( 'asklet_tab_label', __( 'Tab label', 'asklet' ), array( __CLASS__, 'field_tab_label' ), self::PAGE, 'asklet_panel', array( 'label_for' => 'asklet_tab_label' ) ); add_settings_field( 'asklet_tab_color', __( 'Tab colour', 'asklet' ), array( __CLASS__, 'field_tab_color' ), self::PAGE, 'asklet_panel', array( 'label_for' => 'asklet_tab_color' ) ); } /** * Sanitizes the submitted settings. * * @param mixed $input Submitted values. * @return array */ public static function sanitize( $input ) { // options.php has already unslashed $input, so it isn't unslashed again here (that would // strip backslashes from the title and tab label). $input = is_array( $input ) ? $input : array(); // An empty field clears the key (the widget stops showing). An invalid one keeps the saved key. $raw_key = isset( $input['site_key'] ) && is_string( $input['site_key'] ) ? trim( $input['site_key'] ) : ''; $site_key = self::extract_site_key( $raw_key ); if ( '' !== $raw_key && '' === $site_key ) { add_settings_error( self::OPTION, 'asklet_site_key', __( 'The site key wasn\'t saved. In Asklet, open your website, click Copy website snippet and copy the Site key, a code like 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d.', 'asklet' ) ); $site_key = self::get()['site_key']; } $allowed_types = array_keys( self::public_post_types() ); $post_types = isset( $input['post_types'] ) && is_array( $input['post_types'] ) ? array_values( array_intersect( array_map( 'sanitize_key', array_filter( $input['post_types'], 'is_string' ) ), $allowed_types ) ) : array(); $display = isset( $input['display'] ) && is_string( $input['display'] ) ? sanitize_key( $input['display'] ) : 'after'; $tab_color = isset( $input['tab_color'] ) && is_string( $input['tab_color'] ) ? sanitize_hex_color( $input['tab_color'] ) : ''; return array( 'site_key' => $site_key, 'post_types' => $post_types, 'display' => array_key_exists( $display, self::display_values() ) ? $display : 'after', 'title' => isset( $input['title'] ) && is_string( $input['title'] ) ? sanitize_text_field( $input['title'] ) : '', 'tab_label' => isset( $input['tab_label'] ) && is_string( $input['tab_label'] ) ? sanitize_text_field( $input['tab_label'] ) : '', 'tab_color' => $tab_color ? $tab_color : self::DEFAULT_TAB_COLOR, 'height_mode' => isset( $input['height_mode'] ) && 'fixed' === $input['height_mode'] ? 'fixed' : 'fit', 'height' => self::clamp_height( isset( $input['height'] ) ? $input['height'] : null ), 'max_width' => self::clamp_width( isset( $input['max_width'] ) ? $input['max_width'] : null ), 'align' => isset( $input['align'] ) && 'left' === $input['align'] ? 'left' : 'center', ); } /** * Public post types except attachments, keyed by name. * * @return array */ public static function public_post_types() { $types = get_post_types( array( 'public' => true ), 'objects' ); unset( $types['attachment'] ); return $types; } /** * Adds Settings → Asklet. */ public static function add_page() { add_options_page( __( 'Asklet', 'asklet' ), __( 'Asklet', 'asklet' ), 'manage_options', self::PAGE, array( __CLASS__, 'render_page' ) ); } /** * Adds a Settings link on the Plugins screen. * * @param string[] $links Existing links. * @return string[] */ public static function action_links( $links ) { $url = admin_url( 'options-general.php?page=' . self::PAGE ); array_unshift( $links, '' . esc_html__( 'Settings', 'asklet' ) . '' ); return $links; } /** * Reminds admins on the Plugins screen that nothing shows until a site key is set. */ public static function missing_key_notice() { $screen = get_current_screen(); if ( ! $screen || 'plugins' !== $screen->id || ! current_user_can( 'manage_options' ) || '' !== self::get()['site_key'] ) { return; } printf( '

%s %s

', esc_html__( 'Asklet is active but has no site key yet, so no survey is shown.', 'asklet' ), esc_url( admin_url( 'options-general.php?page=' . self::PAGE ) ), esc_html__( 'Add your site key', 'asklet' ) ); } /** * Renders the settings page. */ public static function render_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $host = wp_parse_url( home_url(), PHP_URL_HOST ); ?>

' . esc_html( (string) $host ) . '' ); ?>

', esc_attr( self::OPTION ), esc_attr( $settings['site_key'] ) ); printf( '

%s

', esc_html__( 'In Asklet, open your website, click Copy website snippet and copy the Site key. Pasting the survey link or the whole snippet works too: only the key is saved.', 'asklet' ) ); } /** * Post type checkboxes. */ public static function field_post_types() { $settings = self::get(); echo '
'; foreach ( self::public_post_types() as $name => $type ) { printf( '
', esc_attr( self::OPTION ), esc_attr( $name ), checked( in_array( $name, $settings['post_types'], true ), true, false ), esc_html( $type->labels->name ) ); } echo '
'; echo '

' . esc_html__( 'Where the survey is added automatically. Any post or page can still choose its own display in its Asklet box, and the Asklet block and [asklet] shortcode work everywhere.', 'asklet' ) . '

'; } /** * Display radios. */ public static function field_display() { $settings = self::get(); echo '
'; foreach ( self::displays() as $value => $label ) { printf( '
', esc_attr( self::OPTION ), esc_attr( $value ), checked( $settings['display'], $value, false ), esc_html( $label ) ); } echo '
'; echo '

' . esc_html__( 'The default for every post. Change it for one post in the Asklet box in its sidebar.', 'asklet' ) . '

'; } /** * Iframe title field. */ public static function field_title() { $settings = self::get(); printf( '', esc_attr( self::OPTION ), esc_attr( $settings['title'] ), esc_attr__( 'Article feedback', 'asklet' ) ); echo '

' . esc_html__( 'Read out by screen readers. Leave it empty to use “Article feedback”.', 'asklet' ) . '

'; } /** * Intro of the Size section. */ public static function section_size() { echo '

' . esc_html__( 'For the survey in your posts: inline, the Asklet block and the [asklet] shortcode. A block or shortcode can set its own size. The side panel and slide-in always fit the survey.', 'asklet' ) . '

'; } /** * Height: fit to the survey, or a fixed height in px. */ public static function field_height() { $settings = self::get(); echo '
' . esc_html__( 'Height', 'asklet' ) . ''; printf( '
', esc_attr( self::OPTION ), checked( $settings['height_mode'], 'fit', false ), esc_html__( 'Fit to the survey (recommended)', 'asklet' ) ); printf( ' px', esc_attr( self::OPTION ), checked( $settings['height_mode'], 'fixed', false ), esc_html__( 'Fixed height:', 'asklet' ), (int) $settings['height'], (int) self::MIN_HEIGHT, (int) self::MAX_HEIGHT, esc_attr__( 'Fixed height in pixels', 'asklet' ) ); echo '
'; echo '

' . esc_html( sprintf( /* translators: 1: minimum height, 2: maximum height, in pixels */ __( 'Fit grows and shrinks with each question, so there is never empty space. A fixed height (%1$d–%2$d px) keeps the box the same size, but can leave space under short questions or scroll inside on long ones.', 'asklet' ), self::MIN_HEIGHT, self::MAX_HEIGHT ) ) . '

'; } /** * Maximum width in px; empty for the full column. */ public static function field_max_width() { $settings = self::get(); printf( ' px', esc_attr( self::OPTION ), $settings['max_width'] ? (int) $settings['max_width'] : '', (int) self::MIN_WIDTH, (int) self::MAX_WIDTH, esc_attr__( 'Full', 'asklet' ) ); echo '

' . esc_html__( 'Leave empty to use the full width of the content column. Useful on wide or full-width themes, for example 600.', 'asklet' ) . '

'; } /** * Alignment when the survey is narrower than the column. */ public static function field_align() { $settings = self::get(); printf( ''; echo '

' . esc_html__( 'Used when a maximum width makes the survey narrower than the column.', 'asklet' ) . '

'; } /** * Intro of the Side panel section. */ public static function section_panel() { echo '

' . esc_html__( 'Used when a post shows the survey as a side panel.', 'asklet' ) . '

'; } /** * Side panel tab label field. */ public static function field_tab_label() { $settings = self::get(); printf( '', esc_attr( self::OPTION ), esc_attr( $settings['tab_label'] ), esc_attr__( 'Feedback', 'asklet' ) ); echo '

' . esc_html__( 'The text on the tab and at the top of the panel. Leave it empty to use “Feedback”.', 'asklet' ) . '

'; } /** * Side panel tab colour field. */ public static function field_tab_color() { $settings = self::get(); printf( '', esc_attr( self::OPTION ), esc_attr( $settings['tab_color'] ) ); echo '

' . esc_html__( 'The tab\'s background. Its text is white, so pick a colour dark enough to read it.', 'asklet' ) . '

'; } }