'array', 'sanitize_callback' => [ self::class, 'sanitize' ], ] ); $sections = [ 'requests' => [ __( 'New requests', 'feature-request-system' ), '' ], 'followers' => [ __( 'Followers', 'feature-request-system' ), __( 'A follower of a request gets an email when its status changes, and whoever suggested a request gets one when it is approved. Every email has a link to stop following. Only logged-in users can follow.', 'feature-request-system' ) ], 'guests' => [ __( 'Visitors without an account', 'feature-request-system' ), __( 'Each board decides whether visitors without an account can vote or suggest features. Set that on the board, under Feature Requests → Boards.', 'feature-request-system' ) ], 'limits' => [ __( 'Vote limits', 'feature-request-system' ), __( 'Votes from one IP address are limited, so that one person cannot vote many times. Taking a vote back counts as well. People in one office, or behind one proxy, share an address: raise the limit for logged-in users if they run into it.', 'feature-request-system' ) ], 'uninstall' => [ __( 'Uninstalling', 'feature-request-system' ), '' ], ]; foreach ( $sections as $id => list( $title, $intro ) ) { add_settings_section( $id, $title, static function () use ( $intro ): void { if ( '' !== $intro ) { echo '

' . esc_html( $intro ) . '

'; } }, self::SLUG ); } $fields = [ Settings::DEFAULT_BOARD => [ __( 'Default board', 'feature-request-system' ), 'requests', 'render_default_board' ], Settings::DEFAULT_STATUS => [ __( 'Status of new requests', 'feature-request-system' ), 'requests', 'render_default_status' ], Settings::NOTIFY_NEW_REQUESTS => [ __( 'Email about new requests', 'feature-request-system' ), 'requests', 'render_notify' ], Settings::NOTIFY_EMAILS => [ __( 'Send those emails to', 'feature-request-system' ), 'requests', 'render_notify_emails' ], Settings::NOTIFY_FOLLOWERS => [ __( 'Following', 'feature-request-system' ), 'followers', 'render_notify_followers' ], Settings::SUBSCRIBE_VOTERS => [ __( 'Voters', 'feature-request-system' ), 'followers', 'render_subscribe_voters' ], Settings::DATA_USE_NOTE => [ __( 'How we use your data', 'feature-request-system' ), 'guests', 'render_data_use_note' ], Settings::VOTES_PER_ADDRESS_USERS => [ __( 'Logged-in users', 'feature-request-system' ), 'limits', 'render_votes_per_address_users' ], Settings::VOTES_PER_ADDRESS_GUESTS => [ __( 'Visitors without an account', 'feature-request-system' ), 'limits', 'render_votes_per_address_guests' ], Settings::DELETE_DATA_ON_UNINSTALL => [ __( 'Data', 'feature-request-system' ), 'uninstall', 'render_delete_data' ], ]; foreach ( $fields as $key => list( $title, $section, $callback ) ) { $labelled = in_array( $key, self::CHECKBOXES, true ) ? [] : [ 'label_for' => self::id( $key ) ]; add_settings_field( $key, $title, [ self::class, $callback ], self::SLUG, $section, $labelled ); } } /** * Cleans what the form sent. Also runs when code saves the option in the admin. * * Settings that were not sent keep their value, so saving one setting from code * cannot reset the others. The form's checkboxes send 0 when they are unticked. * * @param mixed $input Settings as sent. * @return array */ public static function sanitize( $input ): array { $input = is_array( $input ) ? $input : []; $clean = []; foreach ( Settings::defaults() as $key => $default ) { $clean[ $key ] = array_key_exists( $key, $input ) ? self::sanitize_value( $key, $input[ $key ] ) : Settings::get( $key ); } return $clean; } /** * Prints the page. */ public static function render(): void { echo '

' . esc_html( get_admin_page_title() ) . '

'; // WordPress prints "Settings saved." and errors by itself only on pages under its Settings menu. settings_errors(); echo '
'; settings_fields( self::GROUP ); do_settings_sections( self::SLUG ); submit_button(); echo '
'; } /** * Prints the board that requests go to when none is chosen. */ public static function render_default_board(): void { self::term_select( Settings::DEFAULT_BOARD, BoardTaxonomy::NAME ); echo '

' . esc_html__( 'Requests go to this board when none is chosen. It cannot be deleted.', 'feature-request-system' ) . '

'; } /** * Prints the status that new requests start in. */ public static function render_default_status(): void { self::term_select( Settings::DEFAULT_STATUS, StatusTaxonomy::NAME ); echo '

' . esc_html__( 'Every new request starts in this status. It cannot be deleted.', 'feature-request-system' ) . '

'; } /** * Prints the switch for new request emails. */ public static function render_notify(): void { self::checkbox( Settings::NOTIFY_NEW_REQUESTS, __( 'Email the team when someone suggests a feature', 'feature-request-system' ) ); } /** * Prints who gets the new request emails. */ public static function render_notify_emails(): void { printf( '

%5$s

', esc_attr( self::id( Settings::NOTIFY_EMAILS ) ), esc_attr( self::name( Settings::NOTIFY_EMAILS ) ), esc_attr( (string) Settings::get( Settings::NOTIFY_EMAILS ) ), esc_attr( (string) get_option( 'admin_email' ) ), esc_html__( 'One or more email addresses, separated by commas. Leave empty to use the site\'s administration email address.', 'feature-request-system' ) ); } /** * Prints the switch for following requests. */ public static function render_notify_followers(): void { self::checkbox( Settings::NOTIFY_FOLLOWERS, __( 'Let users follow requests, and email the followers of a request when its status changes', 'feature-request-system' ) ); echo '

' . esc_html( NotifySwitch::timing() ) . '

'; } /** * Prints the switch for following by voting. */ public static function render_subscribe_voters(): void { self::checkbox( Settings::SUBSCRIBE_VOTERS, __( 'A user who votes for a request also follows it', 'feature-request-system' ) ); echo '

' . esc_html__( 'Taking the vote back ends it. Users can turn this off for themselves in their profile, and from any of the emails.', 'feature-request-system' ) . '

'; } /** * Prints the note for visitors who suggest a feature without an account. */ public static function render_data_use_note(): void { printf( '

%4$s

%5$s

', esc_attr( self::id( Settings::DATA_USE_NOTE ) ), esc_attr( self::name( Settings::DATA_USE_NOTE ) ), esc_textarea( (string) Settings::get( Settings::DATA_USE_NOTE ) ), esc_html__( 'Shown on the form to visitors without an account, under the name and email fields, behind a "How we use your data" link. A link to your privacy policy page follows it. Links and emphasis are allowed. Leave empty to show nothing.', 'feature-request-system' ), /* translators: %s: suggested text. */ esc_html( sprintf( __( 'A text to start from: %s', 'feature-request-system' ), DataUseNote::suggestion() ) ) ); } /** * Prints the limit on votes from one IP address for logged-in users. */ public static function render_votes_per_address_users(): void { self::votes_per_address( Settings::VOTES_PER_ADDRESS_USERS, true ); } /** * Prints the limit on votes from one IP address for visitors without an account. */ public static function render_votes_per_address_guests(): void { self::votes_per_address( Settings::VOTES_PER_ADDRESS_GUESTS, false ); } /** * Prints the switch for deleting data on uninstall. */ public static function render_delete_data(): void { self::checkbox( Settings::DELETE_DATA_ON_UNINSTALL, __( 'Delete all requests, votes, followers, boards, statuses and settings when the plugin is deleted', 'feature-request-system' ) ); } /** * Cleans one setting. * * @param string $key Setting key. * @param mixed $value Value as sent. * @return mixed */ private static function sanitize_value( string $key, $value ) { switch ( $key ) { case Settings::DEFAULT_BOARD: return self::term_or_current( $key, $value, BoardTaxonomy::NAME ); case Settings::DEFAULT_STATUS: return self::term_or_current( $key, $value, StatusTaxonomy::NAME ); case Settings::NOTIFY_EMAILS: return self::emails( is_string( $value ) ? $value : '' ); case Settings::DATA_USE_NOTE: return DataUseNote::sanitize( is_string( $value ) ? $value : '' ); case Settings::VOTES_PER_ADDRESS_USERS: case Settings::VOTES_PER_ADDRESS_GUESTS: return self::count_or_current( $key, $value ); default: return ! empty( $value ); } } /** * Returns the term ID when it is a term of the taxonomy, otherwise the current setting. * * @param string $key Setting key. * @param mixed $value Term ID as sent. * @param string $taxonomy Taxonomy the term must belong to. */ private static function term_or_current( string $key, $value, string $taxonomy ): int { $term_id = is_numeric( $value ) ? (int) $value : -1; // 0 is valid: it is what a taxonomy without terms has as its default. return 0 === $term_id || ( $term_id > 0 && term_exists( $term_id, $taxonomy ) ) ? $term_id : (int) Settings::get( $key ); } /** * Returns the number when it is 1 or more, otherwise the current setting. * * @param string $key Setting key. * @param mixed $value Number as sent. */ private static function count_or_current( string $key, $value ): int { $count = is_numeric( $value ) ? (int) $value : 0; return $count >= 1 ? $count : (int) Settings::get( $key ); } /** * Keeps the valid addresses of a comma-separated list, and tells the user which were left out. * * @param string $addresses Addresses as typed. */ private static function emails( string $addresses ): string { $typed = array_filter( array_map( 'trim', (array) preg_split( '/[\s,;]+/', $addresses ) ) ); $valid = array_filter( $typed, 'is_email' ); $invalid = array_diff( $typed, $valid ); if ( $invalid && function_exists( 'add_settings_error' ) ) { add_settings_error( Settings::OPTION, Settings::NOTIFY_EMAILS, // WordPress prints the message as HTML, and this part of it was typed. /* translators: %s: list of email addresses. */ sprintf( esc_html__( 'These are not valid email addresses and were left out: %s', 'feature-request-system' ), esc_html( implode( ', ', $invalid ) ) ) ); } return implode( ', ', $valid ); } /** * Prints a dropdown of a taxonomy's terms. * * @param string $key Setting key. * @param string $taxonomy Taxonomy name. */ private static function term_select( string $key, string $taxonomy ): void { $terms = get_terms( [ 'taxonomy' => $taxonomy, 'hide_empty' => false, ] ); $terms = is_array( $terms ) ? $terms : []; if ( StatusTaxonomy::NAME === $taxonomy ) { $terms = StatusTaxonomy::sort( $terms ); } printf( ''; } /** * Prints a number field for a limit on votes from one IP address. * * @param string $key Setting key. * @param bool $logged_in Whether the limit is the one for logged-in users. */ private static function votes_per_address( string $key, bool $logged_in ): void { printf( ' %4$s', esc_attr( self::id( $key ) ), esc_attr( self::name( $key ) ), (int) Settings::votes_per_address( $logged_in ), esc_html__( 'votes an hour from one IP address', 'feature-request-system' ) ); } /** * Prints a checkbox that sends 0 when it is unticked. * * @param string $key Setting key. * @param string $label Label next to the box. */ private static function checkbox( string $key, string $label ): void { printf( '', esc_attr( self::name( $key ) ), checked( (bool) Settings::get( $key ), true, false ), esc_html( $label ) ); } /** * Name of a setting's form field. * * @param string $key Setting key. */ private static function name( string $key ): string { return Settings::OPTION . '[' . $key . ']'; } /** * HTML id of a setting's form field. * * @param string $key Setting key. */ private static function id( string $key ): string { return self::SLUG . '-' . str_replace( '_', '-', $key ); } }