registry = $registry; $this->writer = new \Adminkeep\Service\SettingsWriter( $registry ); } /** * Register hooks. * * @return void */ public function boot() { add_action( 'admin_menu', array( $this, 'register_page' ) ); add_action( 'admin_init', array( $this, 'maybe_run_action' ) ); add_action( 'admin_init', array( $this, 'maybe_save' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** * Add the top-level menu page. * * @return void */ public function register_page() { $this->hook = add_menu_page( __( 'Adminkeep', 'adminkeep' ), __( 'Adminkeep', 'adminkeep' ), self::CAPABILITY, self::SLUG, array( $this, 'render' ), 'dashicons-lock', 80 ); } /** * Asset cache-busting version for one file. * * Three tiers, because the plugin version alone silently serves stale assets during * development — it does not change while you are editing: * * - SCRIPT_DEBUG on → time(), so every single request is fresh. * - otherwise → the file's mtime, so an edited asset busts on its own. * - unreadable file → the plugin version. * * @param string $relative_path Path under the plugin root, e.g. 'assets/js/settings.js'. * @return string */ public function asset_version( $relative_path = '' ) { return \Adminkeep\Assets::version( $relative_path ); } /** * Load assets on this screen only. Never globally. * * @param string $hook_suffix Current admin page. * @return void */ public function enqueue_assets( $hook_suffix ) { if ( ! $this->hook || $hook_suffix !== $this->hook ) { return; } wp_enqueue_style( 'adminkeep-settings', ADMINKEEP_URL . 'assets/css/settings.css', array(), $this->asset_version( 'assets/css/settings.css' ) ); wp_enqueue_script( 'adminkeep-settings', ADMINKEEP_URL . 'assets/js/settings.js', array(), $this->asset_version( 'assets/js/settings.js' ), true ); wp_localize_script( 'adminkeep-settings', 'adminkeepConfig', array( 'root' => esc_url_raw( rest_url( RestController::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'i18n' => array( 'saved' => __( 'Changes saved', 'adminkeep' ), 'saveFailed' => __( 'Changes could not be saved. Nothing was changed.', 'adminkeep' ), 'saving' => __( 'Saving', 'adminkeep' ), 'deleting' => __( 'Deleting comments', 'adminkeep' ), /* translators: %s: number of comments deleted. */ 'deleted' => __( '%s comments deleted', 'adminkeep' ), 'purgeFailed'=> __( 'Deletion stopped. Nothing further was removed.', 'adminkeep' ), 'dismiss' => __( 'Dismiss', 'adminkeep' ), 'nothingToDo'=> __( 'Nothing to delete', 'adminkeep' ), 'hiddenOne' => __( '1 unused feature hidden', 'adminkeep' ), /* translators: %d: number of features. */ 'hiddenMany' => __( '%d unused features hidden', 'adminkeep' ), ), ) ); } /** * Handle a settings save. * * @return void */ public function maybe_save() { if ( ! isset( $_POST['adminkeep_save'] ) ) { return; } // The destructive-action buttons live inside this same form; they are not a settings save. if ( isset( $_POST['adminkeep_action'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing return; } if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'You are not allowed to change these settings.', 'adminkeep' ), '', array( 'response' => 403 ) ); } check_admin_referer( self::NONCE ); // The payload is a nested array of per-feature panels. It is not sanitized here because it // cannot be sanitized generically: save_values() walks each feature's settings_schema() and // runs SettingsWriter::sanitize_field() per key, which is the only place the expected type // is known. Keys absent from a schema are dropped rather than stored. // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce checked above; sanitized per-field in save_values(). $posted = isset( $_POST['adminkeep'] ) && is_array( $_POST['adminkeep'] ) ? wp_unslash( $_POST['adminkeep'] ) : array(); $this->save_values( $posted ); wp_safe_redirect( add_query_arg( array( 'page' => self::SLUG, 'updated' => 'true', ), admin_url( 'admin.php' ) ) ); exit; } /** * Run a destructive feature action, in confirmed batches. * * Two steps on purpose. The first click only asks; nothing is destroyed until a second, * separately nonced confirmation comes back. Deletion then runs a batch at a time, redirecting * between batches, so a site with a very large number of comments cannot time out. * * @return void */ public function maybe_run_action() { if ( ! isset( $_POST['adminkeep_action'] ) ) { return; } if ( ! current_user_can( self::CAPABILITY ) || ! current_user_can( 'moderate_comments' ) ) { wp_die( esc_html__( 'You are not allowed to delete comments.', 'adminkeep' ), '', array( 'response' => 403 ) ); } $action = sanitize_key( wp_unslash( $_POST['adminkeep_action'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing $include_approved = ! empty( $_POST['adminkeep_include_approved'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing $include_woo = ! empty( $_POST['adminkeep_include_woo'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing $confirmed = ! empty( $_POST['adminkeep_confirmed'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( \Adminkeep\Feature\DisableComments::PURGE_ARG !== $action ) { return; } check_admin_referer( $confirmed ? self::ACTION_NONCE : self::NONCE ); if ( ! $confirmed ) { // Step one: bounce back and ask. wp_safe_redirect( add_query_arg( array( 'page' => self::SLUG, 'adminkeep_confirm' => $action, 'approved' => $include_approved ? '1' : '0', 'woo' => $include_woo ? '1' : '0', ), admin_url( 'admin.php' ) ) ); exit; } // Step two: confirmed. Delete one batch, then come back for the rest. $deleted = \Adminkeep\Service\CommentPurger::delete_batch( \Adminkeep\Service\CommentPurger::BATCH_SIZE, $include_approved, $include_woo ); $remaining = \Adminkeep\Service\CommentPurger::count( $include_approved, $include_woo ); $args = array( 'page' => self::SLUG ); if ( $remaining > 0 && $deleted > 0 ) { $args['adminkeep_confirm'] = $action; $args['approved'] = $include_approved ? '1' : '0'; $args['woo'] = $include_woo ? '1' : '0'; $args['resume'] = '1'; } else { $args['adminkeep_purged'] = '1'; } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } /** * Whether a feature offers any actions. * * @param \Adminkeep\Feature $feature Feature. * @return bool */ private function has_actions( $feature ) { return method_exists( $feature, 'actions' ) && ! empty( $feature->actions() ); } /** * Render a feature's actions. * * @param \Adminkeep\Feature $feature Feature. * @return void */ private function render_actions( $feature ) { if ( ! $this->has_actions( $feature ) ) { return; } foreach ( $feature->actions() as $action ) { echo '
'; if ( ! empty( $action['description'] ) ) { echo '

' . esc_html( $action['description'] ) . '

'; } foreach ( ( isset( $action['toggles'] ) ? $action['toggles'] : array() ) as $toggle ) { printf( '', esc_attr( $toggle['name'] ), esc_html( $toggle['label'] ) ); } $counts = isset( $action['counts'] ) ? $action['counts'] : \Adminkeep\Service\CommentPurger::counts(); printf( '', esc_attr( $action['id'] ), esc_attr( wp_json_encode( $counts ) ), empty( $action['disabled'] ) ? '' : ' disabled', esc_html( $action['label'] ) ); echo '
'; } } /** * Render the confirmation panel for a pending destructive action. * * @return void */ private function render_confirmation() { // phpcs:disable WordPress.Security.NonceVerification.Recommended if ( ! isset( $_GET['adminkeep_confirm'] ) ) { return; } $include_approved = isset( $_GET['approved'] ) && '1' === $_GET['approved']; $include_woo = isset( $_GET['woo'] ) && '1' === $_GET['woo']; $resuming = isset( $_GET['resume'] ); $action = sanitize_key( wp_unslash( $_GET['adminkeep_confirm'] ) ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $count = \Adminkeep\Service\CommentPurger::count( $include_approved, $include_woo ); if ( $count < 1 ) { return; } ?>

' . esc_html( number_format_i18n( $count ) ) . '' ); ?>

writer->save_values( $posted ); } /** * A terse word for what a feature is currently doing. * * Content-derived, not an On/Off badge: "Closed", "No installs", "3 blocked". Features opt in by * declaring state_label(); anything that does not gets no chip rather than filler. * * @param \Adminkeep\Feature $feature Feature. * @return string */ public function state_label( $feature ) { if ( ! Settings::is_enabled( $feature->id() ) || ! method_exists( $feature, 'state_label' ) ) { return ''; } return (string) $feature->state_label(); } /** * The link to a feature's own screen, when it has one this user can open. * * @param \Adminkeep\Feature $feature Feature. * @param bool $require_enabled False to get the link of a feature that is off, * for the slot the panel keeps ready. * @return array{url: string, label: string}|null */ public function panel_link_for( $feature, $require_enabled = true ) { if ( ! method_exists( $feature, 'panel_link' ) ) { return null; } if ( $require_enabled && ! Settings::is_enabled( $feature->id() ) ) { return null; } $link = (array) $feature->panel_link(); if ( empty( $link['url'] ) || empty( $link['label'] ) ) { return null; } return array( 'url' => (string) $link['url'], 'label' => (string) $link['label'], ); } /** * Whether a user hides unused features from the nav. * * Per user rather than per site: two administrators can want different views of one screen. * * @param int $user_id User id; the current user when 0. * @return bool */ public static function hides_unused( $user_id = 0 ) { $user_id = $user_id ? (int) $user_id : get_current_user_id(); return $user_id && '1' === get_user_meta( $user_id, self::HIDE_UNUSED_META, true ); } /** * Render the screen. * * A nav of features on the left, one panel each on the right. Panels are all rendered and * hidden with the `hidden` attribute rather than fetched, because there are three of them and * a round trip to swap a panel would be slower and worse. Without JavaScript every panel is * simply visible in sequence and each one saves itself. * * @return void */ public function render() { if ( ! current_user_can( self::CAPABILITY ) ) { return; } $features = $this->registry->all(); $hide_unused = self::hides_unused(); $hidden_count = 0; foreach ( array_keys( $features ) as $feature_id ) { $hidden_count += Settings::is_enabled( $feature_id ) ? 0 : 1; } ?>

render_confirmation(); ?>

v

render_overview( $features ); ?> $feature ) : ?> render_panel( $id, $feature ); ?>

$feature ) { if ( Settings::is_enabled( $id ) === $wanted ) { $ordered[ $id ] = $feature; } } } ?>

' . esc_html( number_format_i18n( $on ) ) . '', esc_html( number_format_i18n( count( $features ) ) ) ); ?>

registry->resolve_settings( $feature ); $schema = $feature->settings_schema(); $steps = method_exists( $feature, 'how_it_works' ) ? (array) $feature->how_it_works() : array(); ?> field_label( $key ); $name = sprintf( 'adminkeep[%s][%s]', $feature_id, $key ); if ( '' === $label ) { $label = $key; } if ( 'status' === $ui ) { $this->render_status( $feature, $label ); return; } echo '
'; switch ( $ui ) { case 'post_types': $this->render_choices_field( $name, $label, $value, $this->public_post_types() ); break; case 'taxonomies': $this->render_choices_field( $name, $label, $value, $this->public_taxonomies() ); break; case 'post_types_flat': $this->render_choices_field( $name, $label, $value, $this->flat_post_types() ); break; case 'taxonomies_flat': $this->render_choices_field( $name, $label, $value, $this->flat_taxonomies() ); break; case 'radio': $this->render_radio_field( $name, $label, $value, $spec, $key, $feature ); break; default: if ( isset( $spec['type'] ) && 'bool' === $spec['type'] ) { printf( '', esc_attr( $name ), checked( (bool) $value, true, false ), esc_html( $label ) ); } else { printf( '', esc_attr( $feature_id . '-' . $key ), esc_html( $label ), esc_attr( $name ), esc_attr( is_scalar( $value ) ? (string) $value : '' ) ); } } echo '
'; } /** * Public post types, as slug => label. * * Resolved here rather than in settings_schema(): that runs on plugins_loaded and post types * are not registered until init. Same reason field labels are resolved at render time. * * @return array */ private function public_post_types() { $choices = array(); $public_types = get_post_types( array( 'public' => true, 'show_ui' => true, ), 'objects' ); foreach ( $public_types as $type ) { if ( 'attachment' === $type->name ) { continue; } $choices[ $type->name ] = $type->labels->name; } return $choices; } /** * Public taxonomies, as slug => label. * * @return array */ private function public_taxonomies() { $choices = array(); $public_taxonomies = get_taxonomies( array( 'public' => true, 'show_ui' => true, ), 'objects' ); foreach ( $public_taxonomies as $taxonomy ) { $choices[ $taxonomy->name ] = $taxonomy->labels->name; } return $choices; } /** * Non-hierarchical public post types, as slug => label. * * Order-only. Duplicate keeps public_post_types(), because duplicating a Page is meaningful * while flat-ordering one is not. * * @return array */ private function flat_post_types() { $choices = array(); $types = get_post_types( array( 'public' => true, 'show_ui' => true, 'hierarchical' => false, ), 'objects' ); foreach ( $types as $type ) { if ( 'attachment' === $type->name ) { continue; } $choices[ $type->name ] = $type->labels->name; } return $choices; } /** * Non-hierarchical public taxonomies, as slug => label. * * @return array */ private function flat_taxonomies() { $choices = array(); $taxonomies = get_taxonomies( array( 'public' => true, 'show_ui' => true, 'hierarchical' => false, ), 'objects' ); foreach ( $taxonomies as $taxonomy ) { $choices[ $taxonomy->name ] = $taxonomy->labels->name; } return $choices; } /** * Render a checkbox per choice. * * @param string $name Field name, e.g. adminkeep[duplicate][post_types]. * @param string $label Field label. * @param mixed $value Current value. * @param array $choices Available choices, slug => label. * @return void */ private function render_choices_field( $name, $label, $value, array $choices ) { $selected = is_array( $value ) ? $value : array(); printf( '%s', esc_html( $label ) ); // Unchecking every box would otherwise drop the key from the payload entirely, and an // absent value falls back to the schema default — making "none" impossible to save. The // empty entry is filtered out by the array sanitizer. printf( '', esc_attr( $name ) ); echo '
'; foreach ( $choices as $slug => $choice_label ) { printf( '', esc_attr( $name ), esc_attr( $slug ), checked( in_array( (string) $slug, $selected, true ), true, false ), esc_html( $choice_label ) ); } echo '
'; } /** * One radio per declared choice. * * The allowed values come from the schema's 'choices' (which SettingsWriter::sanitize_field() * also enforces); the labels come from the feature's field_choices(), which runs at render * time and may translate. A choice the feature gives no label for is shown by its value. * * @param string $name Input name. * @param string $label Field label. * @param mixed $value Current value. * @param array $spec Schema entry. * @param string $key Field key. * @param \Adminkeep\Feature $feature Feature. * @return void */ private function render_radio_field( $name, $label, $value, array $spec, $key, $feature ) { $choices = isset( $spec['choices'] ) && is_array( $spec['choices'] ) ? $spec['choices'] : array(); $labels = method_exists( $feature, 'field_choices' ) ? (array) $feature->field_choices( $key ) : array(); $current = is_scalar( $value ) ? (string) $value : ''; printf( '%s', esc_html( $label ) ); echo '
'; foreach ( $choices as $choice ) { $choice = (string) $choice; printf( '', esc_attr( $name ), esc_attr( $choice ), checked( $choice, $current, false ), esc_html( isset( $labels[ $choice ] ) ? $labels[ $choice ] : $choice ) ); } echo '
'; } /** * Render a feature's read-only status table. * * The feature declares its own columns. It used to be hardcoded to one feature's column * shape, which meant another feature's rows rendered under the wrong headings and were * labelled with meaning that did not apply to them. One feature's data in another's table. * * Expected shape: * array( * 'columns' => array( 'When', 'Username' ), * 'rows' => array( array( '2026-08-06', array( 'text' => 'bob', 'tone' => 'mono' ) ) ), * 'empty' => 'Nothing yet.', * ) * * @param \Adminkeep\Feature $feature Feature. * @param string $label Section label. * @return void */ private function render_status( $feature, $label ) { if ( ! method_exists( $feature, 'status' ) ) { return; } $status = (array) $feature->status(); $columns = isset( $status['columns'] ) ? (array) $status['columns'] : array(); $rows = isset( $status['rows'] ) ? (array) $status['rows'] : array(); $empty = isset( $status['empty'] ) ? (string) $status['empty'] : __( 'Nothing to show yet.', 'adminkeep' ); echo '
' . esc_html( $label ) . ''; if ( ! $rows || ! $columns ) { echo '

' . esc_html( $empty ) . '

'; return; } echo ''; foreach ( $columns as $heading ) { echo ''; } echo ''; foreach ( $rows as $row ) { echo ''; foreach ( (array) $row as $cell ) { $text = is_array( $cell ) && isset( $cell['text'] ) ? $cell['text'] : $cell; $tone = is_array( $cell ) && isset( $cell['tone'] ) ? $cell['tone'] : ''; printf( '', esc_attr( $tone ? 'adminkeep-cell--' . $tone : '' ), esc_html( (string) $text ) ); } echo ''; } echo '
' . esc_html( $heading ) . '
%s
'; } }