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(
'
%s',
esc_attr( $toggle['name'] ),
esc_html( $toggle['label'] )
);
}
$counts = isset( $action['counts'] ) ? $action['counts'] : \Adminkeep\Service\CommentPurger::counts();
printf(
'
%4$s ',
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(); ?>
/>
registry->grouped() as $group => $group_features ) : ?>
>
group_label( $group ) ); ?>
: the is toggled with `hidden` and also holds the button, and a region that disappears as the toggle goes off announces nothing. ?>
>
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(
' %s ',
esc_attr( $name ),
checked( (bool) $value, true, false ),
esc_html( $label )
);
} else {
printf(
'%2$s ',
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(
' %s ',
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(
' %s ',
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 '' . esc_html( $heading ) . ' ';
}
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(
'%s ',
esc_attr( $tone ? 'adminkeep-cell--' . $tone : '' ),
esc_html( (string) $text )
);
}
echo ' ';
}
echo '
';
}
}