> */ public static function get_builtin_sections(): array { return [ [ 'id' => 'albert/safe-mode', 'title' => __( 'Safe mode', 'albert-ai-butler' ), 'priority' => 55, 'icon' => 'shield', 'description' => __( 'Whether destructive actions an assistant requests wait for your approval.', 'albert-ai-butler' ), 'fields' => [ [ 'id' => 'enabled', 'type' => 'radio-cards', // Not "Safe mode" again: the section heading above already // says that, and every other section names its controls // for what they do rather than repeating itself. 'label' => __( 'Hold destructive actions', 'albert-ai-butler' ), 'option_name' => Gate::OPTION, 'default' => Gate::DEFAULT_VALUE, 'options' => [ 'on' => [ 'label' => __( 'On', 'albert-ai-butler' ), 'description' => __( 'Deletions and other destructive changes are held on the Approvals screen until you approve them.', 'albert-ai-butler' ), 'recommended' => true, ], 'off' => [ 'label' => __( 'Off', 'albert-ai-butler' ), 'description' => __( 'Assistants can make destructive changes immediately, without approval.', 'albert-ai-butler' ), ], ], 'sanitize_callback' => [ Gate::class, 'sanitize' ], 'disabled' => [ self::class, 'safe_mode_unavailable' ], 'hint' => [ self::class, 'safe_mode_hint' ], ], [ 'id' => 'ttl', 'type' => 'number', 'label' => __( 'Approval window', 'albert-ai-butler' ), 'description' => __( 'How long a held action stays approvable before it expires, in minutes. Shorter is safer: approving a stale request can overwrite edits made in the meantime.', 'albert-ai-butler' ), 'suffix' => __( 'minutes', 'albert-ai-butler' ), 'option_name' => Interceptor::TTL_OPTION, 'default' => Interceptor::DEFAULT_TTL_MINUTES, 'attributes' => [ 'min' => Interceptor::MIN_TTL_MINUTES, 'max' => Interceptor::MAX_TTL_MINUTES, 'step' => 1, ], 'sanitize_callback' => [ Interceptor::class, 'clamp_minutes' ], 'disabled' => [ self::class, 'safe_mode_unavailable' ], ], ], ], [ 'id' => 'albert/privacy', 'title' => __( 'Privacy', 'albert-ai-butler' ), 'priority' => 60, 'icon' => 'privacy', 'description' => __( 'What AI assistants may see of your customers and users.', 'albert-ai-butler' ), 'fields' => [ [ 'id' => 'mode', 'type' => 'radio-cards', 'label' => __( 'Privacy mode', 'albert-ai-butler' ), // Balanced, and deliberately not Strict, even though a // new install is set to Strict on activation // ({@see \Albert\Core\Plugin::activate()}). This is the // value a site falls back to when nothing is stored, and // the sites in that position are the ones that installed // Albert before the option existed. Changing what *they* // do without anybody choosing it is the thing // docs/features/70-admin-design-system.md ยง4 forbids. // A new install never reaches this: it has a stored value // from its first request. 'default' => PrivacyMode::Balanced->value, 'options' => [ PrivacyMode::Strict->value => [ 'label' => __( 'Strict', 'albert-ai-butler' ), 'description' => __( 'Personal data is always anonymised, in every request.', 'albert-ai-butler' ), 'recommended' => true, ], PrivacyMode::Balanced->value => [ 'label' => __( 'Balanced', 'albert-ai-butler' ), 'description' => __( 'Anonymised by default, but an authorised request can reveal it.', 'albert-ai-butler' ), ], PrivacyMode::Off->value => [ 'label' => __( 'Off', 'albert-ai-butler' ), 'description' => __( 'Personal data is passed through as-is. Payment data is still always removed.', 'albert-ai-butler' ), ], ], 'sanitize_callback' => [ PrivacyMode::class, 'sanitize' ], ], ], ], [ 'id' => 'albert/connections', 'title' => __( 'Connections', 'albert-ai-butler' ), 'priority' => 65, 'icon' => 'admin-users', 'description' => __( 'Standing invitations and unused connections.', 'albert-ai-butler' ), 'fields' => [ [ 'id' => 'invitation_expiry_days', 'type' => 'number', 'label' => __( 'Invitation expiry', 'albert-ai-butler' ), 'suffix' => __( 'days', 'albert-ai-butler' ), 'description' => __( 'How long someone has to approve their first assistant.', 'albert-ai-butler' ), 'info' => __( 'Once they approve one, their access no longer expires. Set to 0 for no deadline.', 'albert-ai-butler' ), 'option_name' => AllowedUsers::EXPIRY_OPTION, 'default' => AllowedUsers::DEFAULT_EXPIRY_DAYS, 'attributes' => [ 'min' => 0, 'max' => 3650, 'step' => 1, ], ], [ 'id' => 'connection_never_used_days', 'type' => 'number', 'label' => __( 'Drop never-used connections', 'albert-ai-butler' ), 'suffix' => __( 'days', 'albert-ai-butler' ), 'description' => __( 'Removes connections that were approved but never used.', 'albert-ai-butler' ), 'info' => __( 'Once a connection has been used, this rule stops applying to it. Set to 0 to keep them.', 'albert-ai-butler' ), 'option_name' => ConnectionRetention::NEVER_USED_OPTION, 'default' => ConnectionRetention::DEFAULT_NEVER_USED_DAYS, 'attributes' => [ 'min' => 0, 'max' => 3650, 'step' => 1, ], ], [ 'id' => 'connection_idle_days', 'type' => 'number', 'label' => __( 'Expire idle connections', 'albert-ai-butler' ), 'suffix' => __( 'days', 'albert-ai-butler' ), 'description' => __( 'Removes connections that have gone quiet.', 'albert-ai-butler' ), 'info' => __( 'Off by default, because a quiet assistant is not always an abandoned one. Set to 0 to keep them.', 'albert-ai-butler' ), 'option_name' => ConnectionRetention::IDLE_OPTION, 'default' => ConnectionRetention::DEFAULT_IDLE_DAYS, 'attributes' => [ 'min' => 0, 'max' => 3650, 'step' => 1, ], ], ], ], [ 'id' => 'albert/media', 'title' => __( 'Uploads', 'albert-ai-butler' ), 'priority' => 68, 'icon' => 'upload', 'description' => __( 'How assistants send files to your site.', 'albert-ai-butler' ), 'fields' => [ [ // An ordinary number field. It used to be 'custom' purely // because the renderer had no way to express "disabled, // because a filter is overriding this". It now works that // out itself from Settings\Value, so this field declares // no `disabled` and no `display_value` at all. The `hint` // stays: the generated one names the filter, but only this // field can give the exact size in force, which matters // because the control rounds up to whole megabytes. 'id' => 'default_max_mb', 'type' => 'number', 'label' => __( 'Default upload size limit', 'albert-ai-butler' ), 'suffix' => __( 'MB', 'albert-ai-butler' ), 'description' => __( 'Used when an assistant does not ask for a specific limit.', 'albert-ai-butler' ), 'info' => sprintf( /* translators: %s: this server's own upload size ceiling, human-readable (e.g. "64 MB") */ __( 'An assistant can ask for a smaller or larger limit per upload. This server never accepts more than %s, whatever is set here.', 'albert-ai-butler' ), self::server_upload_ceiling() ), 'option_name' => UploadLinkService::MAX_BYTES_OPTION, 'default' => UploadLinkService::DEFAULT_MAX_MB, 'attributes' => [ 'min' => 1, 'max' => UploadLinkService::MAX_SETTABLE_MB, 'step' => 1, ], 'hint' => [ self::class, 'max_mb_hint' ], 'sanitize_callback' => [ self::class, 'sanitize_max_mb' ], ], ], ], [ // Always last โ€” add-ons sit between the shared Settings card (50) and Licenses (9000). 'id' => 'albert/licenses', 'title' => __( 'Licenses', 'albert-ai-butler' ), 'priority' => 9000, 'icon' => 'admin-network', 'fields' => [ [ 'id' => 'licenses_table', 'type' => 'custom', 'label' => '', 'render_callback' => [ self::class, 'render_licenses_block' ], 'sanitize_callback' => '__return_null', ], ], ], ]; } /** * This server's own upload ceiling, human-readable, for the Uploads field description. * * @since 1.4.0 * * @return string e.g. "64 MB". */ private static function server_upload_ceiling(): string { // size_format() can return false; wp_max_upload_size() can't actually trigger that. $formatted = size_format( wp_max_upload_size() ); return is_string( $formatted ) ? $formatted : ''; } /** * Whether this WordPress cannot enforce safe mode at all. * * The gate is `wp_pre_execute_ability`, which arrived in WordPress 7.1. * Albert supports 6.9, where the filter never fires and nothing is held. The * control is locked there rather than left inviting, because a switch that * reads On while every deletion runs unattended is the exact false * reassurance safe mode exists to prevent. * * @since 1.5.0 * * @return bool */ public static function safe_mode_unavailable(): bool { return ! WpCompat::supports_execution_lifecycle(); } /** * Why the safe mode control is locked, when it is. * * @since 1.5.0 * * @return array{text: string, tone: string}|null */ public static function safe_mode_hint(): ?array { if ( ! self::safe_mode_unavailable() ) { return null; } return [ 'text' => sprintf( /* translators: %s: the WordPress version this site is running. */ __( 'Safe mode needs WordPress 7.1, which added the check it relies on. This site runs %s, so destructive actions are not being held. Update WordPress to switch it on.', 'albert-ai-butler' ), get_bloginfo( 'version' ) ), 'tone' => 'warning', ]; } /** * What to say under the control when a filter is involved, if anything. * * Returns null in the ordinary case โ€” most of the time there is no filter * and the field needs no hint at all. * * @since 1.4.0 * * @return array{text: string, tone: string}|null */ public static function max_mb_hint(): ?array { $state = UploadLinkService::get_default_max_bytes_filter_state(); if ( $state['state'] !== 'active' ) { return null; } if ( $state['requested'] > $state['value'] ) { return [ 'text' => sprintf( /* translators: 1: opening , 2: closing wrapping the filter name, 3: the value the filter requested (MB), 4: the maximum allowed (MB) */ __( 'The %1$salbert/media/upload_link_max_bytes%2$s filter is requesting %3$d MB, above the %4$d MB maximum, so %4$d MB is being used instead.', 'albert-ai-butler' ), '', '', (int) round( $state['requested'] / UploadLinkService::BYTES_PER_MB ), UploadLinkService::MAX_SETTABLE_MB ), 'tone' => 'warning', ]; } return [ 'text' => sprintf( /* translators: 1: opening , 2: closing wrapping the filter name, 3: the effective size, human-readable (e.g. "500 B") */ __( 'The %1$salbert/media/upload_link_max_bytes%2$s filter is overriding what\'s saved here. The limit in effect is %3$s.', 'albert-ai-butler' ), '', '', size_format( $state['value'] ) ), 'tone' => 'info', ]; } /** * Sanitize the Settings-screen field for {@see UploadLinkService::MAX_BYTES_OPTION}. * * While the filter is active the field renders disabled, so it's never * submitted; return the stored value as a no-op rather than resetting it. * * @param mixed $value Raw value from the settings form. * * @return int MB, clamped to [1, UploadLinkService::MAX_SETTABLE_MB]. * @since 1.4.0 */ public static function sanitize_max_mb( $value ): int { $stored = (int) get_option( UploadLinkService::MAX_BYTES_OPTION, UploadLinkService::DEFAULT_MAX_MB ); // null means the field was not submitted, which is what a disabled // field does. Keyed on the value rather than only on the filter's // state at save time: the filter can go inactive between the render // that disabled the field and the save, and treating "absent" as // "invalid" would then overwrite a stored value nobody touched. if ( $value === null || UploadLinkService::get_default_max_bytes_filter_state()['state'] === 'active' ) { return $stored; } // (int) cast, not absint(): a negative input must fall through to the default below. $mb = is_scalar( $value ) ? (int) $value : 0; if ( $mb < 1 ) { // Say so, exactly as an over-range value does. Silently rewriting // a 0 to 10 leaves somebody staring at a number they did not type. add_settings_error( 'albert_settings', 'upload_link_max_mb_too_low', sprintf( /* translators: %d: the default that was saved instead (MB) */ __( 'The default upload size limit must be at least 1 MB, so %d MB was saved instead.', 'albert-ai-butler' ), UploadLinkService::DEFAULT_MAX_MB ), 'warning' ); return UploadLinkService::DEFAULT_MAX_MB; } if ( $mb > UploadLinkService::MAX_SETTABLE_MB ) { // Reuses the page's own "Settings saved" notice mechanism. add_settings_error( 'albert_settings', 'upload_link_max_mb_clamped', sprintf( /* translators: 1: the value that was requested (MB), 2: the maximum allowed (MB) */ __( 'The default upload size limit can\'t be set above %2$d MB. %1$d MB was requested, so %2$d MB was saved instead.', 'albert-ai-butler' ), $mb, UploadLinkService::MAX_SETTABLE_MB ), 'warning' ); } return min( $mb, UploadLinkService::MAX_SETTABLE_MB ); } /** * Render the licenses block (table or empty state). * * Delegates to {@see Settings} for the table/empty-state markup so the * AJAX refresh handler keeps a single source of truth. * * @since 1.1.0 * * @param array $field Field definition (unused). * @param mixed $current_value Current value (unused). * * @return void */ public static function render_licenses_block( array $field, $current_value ): void { unset( $field, $current_value ); $has_addons = class_exists( '\Albert\Abstracts\AbstractAddon' ) && ! empty( \Albert\Abstracts\AbstractAddon::get_registered_addons() ); if ( $has_addons ) { ?>