|null */ private $settings = null; /** * Constructor. * * @since 1.0.0 * @param Settings $store Settings store. */ public function __construct( Settings $store ) { $this->store = $store; } /** * Unique slug, e.g. `order-total-search`. * * @since 1.0.0 * @return string */ abstract public function id(); /** * Human title. * * @since 1.0.0 * @return string */ abstract public function title(); /** * One-sentence description shown on the module card. * * @since 1.0.0 * @return string */ abstract public function description(); /** * Register runtime hooks. Called only when the module is enabled. * * @since 1.0.0 * @return void */ abstract public function boot(); /** * Category key used to group modules: orders, customers, emails, operations, tools, developer or * performance. Developer and Performance power-ups are admin-only (see is_admin_only()). * * @since 1.0.0 * @return string */ public function category() { return 'tools'; } /** * Whether this power-up is for site admins only: its category is one of * Registry::admin_only_categories() (Developer, Performance, or one an add-on adds). Store * managers never see it: not on the list, not by URL, not through REST. * * @since 1.0.0 * @return bool */ final public function is_admin_only(): bool { // Case and spaces don't matter: a `Developer` typo must not make a tool public. return in_array( strtolower( trim( (string) $this->category() ) ), Registry::admin_only_categories(), true ); } /** * The capability needed to see and use this power-up: `manage_options` for admin-only * power-ups, `manage_woocommerce` for the rest. Final, so a module can't lower it; routes check * extra object capabilities (for example `edit_shop_orders`) themselves. * * @since 1.0.0 * @return string */ final public function required_capability(): string { return $this->is_admin_only() ? 'manage_options' : 'manage_woocommerce'; } /** * Whether the current user may see and use this power-up (list row, page, REST). The panel * itself always needs `manage_woocommerce`. * * @since 1.0.0 * @return bool */ final public function user_can_access(): bool { return current_user_can( 'manage_woocommerce' ) && current_user_can( $this->required_capability() ); } /** * Risk of this power-up's strongest action, for the badge on its list row and page header: * `safe`, `review` (Review recommended) or `advanced`. See Support\DevGuard. * * @since 1.0.0 * @return string */ public function risk(): string { return \SmartWP\AdminPowerups\Support\DevGuard::SAFE; } /** * Where the module shows up, as a short phrase ("Orders list", "Users screen"). * * @since 1.0.0 * @return string */ public function location() { return ''; } /** * Settings schema. * * Each entry: [ 'id', 'type', 'label', 'description'?, 'default', 'options'? , 'min'?, 'max'?, 'step'? ]. * * @since 1.0.0 * @return array> */ public function fields() { return array(); } /** * Whether this module's settings are read on (nearly) every request while it is on. * * True: the settings option autoloads while the module is on (tiny, avoids a query). * False: settings load on demand, e.g. modules whose settings are only used in wp-admin. * Settings of modules that are off never autoload. * * @since 1.0.0 * @return bool */ public function autoload_settings() { return true; } /** * Whether the settings option should autoload right now. * * @since 1.0.0 * @return bool */ final public function should_autoload() { return $this->is_enabled() && $this->autoload_settings(); } /** * Data this power-up stores, for its Data section and for uninstall. Override to add items * (see Support\Cleanup for the item shape and builders); keep the parent's items. * * @since 1.0.0 * @return array> */ public function data() { // fields() can call WooCommerce; during uninstall WooCommerce may be inactive, so include the item then. if ( did_action( 'woocommerce_loaded' ) && ! $this->fields() ) { return array(); } return array( array( 'id' => 'settings', 'kind' => 'settings', 'label' => __( 'Settings', 'admin-powerups-for-woocommerce' ), 'description' => __( 'This power-up’s saved settings. Deleting them brings back the defaults.', 'admin-powerups-for-woocommerce' ), 'count' => fn() => $this->store->get_module_settings( $this->id() ) ? 1 : 0, 'summary' => static fn( $count ) => $count ? __( 'Saved', 'admin-powerups-for-woocommerce' ) : __( 'Defaults', 'admin-powerups-for-woocommerce' ), 'delete' => function () { $this->reset_settings(); return 0; }, ), ); } /** * Data items, normalized and open to add-ons. * * @since 1.0.0 * @return array> Keyed by item ID. */ final public function data_items() { /** * Filter the data a power-up stores (its Data section and uninstall). See Support\Cleanup. * * @since 1.0.0 * @param array[] $items Items. * @param AbstractModule $module Module. */ $items = (array) apply_filters( 'apwc_module_data', $this->data(), $this ); $out = array(); foreach ( $items as $item ) { $item = is_array( $item ) ? \SmartWP\AdminPowerups\Support\Cleanup::normalize( $item ) : null; if ( $item ) { $out[ $item['id'] ] = $item; } } return $out; } /** * Forget saved settings (defaults apply again). * * @since 1.0.0 * @return void */ public function reset_settings() { delete_option( \SmartWP\AdminPowerups\Settings::option_name( $this->id() ) ); if ( $this->should_autoload() ) { $this->store->ensure_module_option( $this->id() ); } $this->settings = null; } /** * Runs when this power-up's page in our panel is opened, whether it's on or off (settings must * always work). Register custom field renderers and enqueue settings assets here, not in * boot(): boot() only runs while the power-up is on. * * @since 1.0.0 * @param string $tab Current tab ('settings' or a view slug). * @return void */ public function admin_screen( $tab ) {} /** * A tab staff open often enough to deserve a direct link on the Powerups list (shown while * the power-up is on). Return a slug from views(), or '' for none. Keep it for views used * regularly; one-off tools don't get one. * * @since 1.0.0 * @return string */ public function shortcut() { return ''; } /** * Runs when the module is switched on (or the plugin is activated with it on): create tables, * schedule jobs. Must be safe to run more than once. * * @since 1.0.0 * @return void */ public function activate() {} /** * Runs when the module is switched off (or the plugin is deactivated): unschedule jobs. * Never deletes data; that's the Data section's job. * * @since 1.0.0 * @return void */ public function deactivate() {} /** * Other options this module owns that must load on every request while it's on (for example * definitions needed at `init`). They autoload only while the module is on; the Registry flips * them on toggle, activation and deactivation. * * @since 1.0.0 * @return string[] Option names. */ public function autoload_options() { return array(); } /** * Icon name for the module (a file in assets/icons, or one an add-on registers via `apwc_icon_dirs`). * * @since 1.0.0 * @return string */ public function icon() { return 'puzzle'; } /** * Short labels shown next to the title, e.g. "New". Filterable with `apwc_module_badges`. * * @since 1.0.0 * @return string[] */ public function badges() { return array(); } /** * Badges after the `apwc_module_badges` filter. * * @since 1.0.0 * @return string[] */ final public function get_badges() { /** * Filter the badges shown next to a module title. * * @since 1.0.0 * @param string[] $badges Labels. * @param AbstractModule $module Module. */ return array_values( array_filter( (array) apply_filters( 'apwc_module_badges', $this->badges(), $this ), 'is_string' ) ); } /** * Dedicated admin views (tools, reports) shown as tabs beside Settings. * * @since 1.0.0 * @return array View slug => tab label. */ public function views() { return array(); } /** * Whether the module has at least one dedicated view. * * @since 1.0.0 * @return bool */ public function has_view() { return (bool) $this->views(); } /** * Render a dedicated view. Output must be escaped. * * @since 1.0.0 * @param string $view View slug from views(). * @return void */ public function render_view( $view ) {} /** * Enqueue assets for a dedicated view. * * @since 1.0.0 * @param string $view View slug from views(). * @return void */ public function enqueue_view_assets( $view ) {} /** * A reason the module cannot run in this environment, or empty string when it can. * * @since 1.0.0 * @return string */ public function unmet_requirement() { return ''; } /** * Whether the module is switched on. * * @since 1.0.0 * @return bool */ public function is_enabled() { return $this->store->is_enabled( $this->id() ); } /** * Whether the module is switched on right now, read from the database (for background jobs: * another request may have switched it off since this one started). * * @since 1.0.0 * @return bool */ final public function is_enabled_fresh(): bool { return $this->store->is_enabled_fresh( $this->id() ); } /** * Default values from the schema. * * @since 1.0.0 * @return array */ public function defaults() { $defaults = array(); foreach ( $this->fields() as $field ) { if ( 'heading' === $field['type'] ) { continue; // Display only. } $defaults[ $field['id'] ] = $field['default'] ?? null; } return $defaults; } /** * Resolved settings (stored values over defaults, restricted to known keys). Stored values of * the wrong type for their field (saved by an older version, another plugin or a manual edit) * are brought back to the field's type, so forms and runtime code can trust them. * * @since 1.0.0 * @return array */ public function settings() { if ( null === $this->settings ) { $defaults = $this->defaults(); $stored = array_intersect_key( $this->store->get_module_settings( $this->id() ), $defaults ); foreach ( $this->fields() as $field ) { if ( array_key_exists( $field['id'], $stored ) ) { $stored[ $field['id'] ] = $this->type_stored( $field, $stored[ $field['id'] ] ); } } $this->settings = array_merge( $defaults, $stored ); } return $this->settings; } /** * Bring a stored value back to its field's type (a light check on read, not a full sanitize: * no lookups). Values of the right type are returned untouched; custom field types are left to * their module. * * @since 1.0.0 * @param array $field Field schema. * @param mixed $value Stored value. * @return mixed */ protected function type_stored( array $field, $value ) { $default = $field['default'] ?? null; switch ( $field['type'] ) { case 'toggle': return is_bool( $value ) ? $value : in_array( $value, array( 1, '1', 'true', 'yes', 'on' ), true ); case 'select': case 'text': case 'textarea': case 'date': if ( is_string( $value ) ) { return $value; } return is_int( $value ) || is_float( $value ) ? (string) $value : $default; case 'number': if ( is_int( $value ) || is_float( $value ) ) { return $value; } return is_string( $value ) && is_numeric( $value ) ? 0 + $value : $default; case 'checkboxes': case 'sortable': case 'search': return is_array( $value ) ? array_values( array_filter( $value, 'is_scalar' ) ) : ( is_array( $default ) ? $default : array() ); default: // Custom types: at least keep lists as lists and text as text; the module checks the rest. if ( is_array( $default ) && ! is_array( $value ) ) { return $default; } if ( is_string( $default ) && ! is_scalar( $value ) ) { return $default; } return $value; } } /** * Drop cached settings so the next read hits the store. * * @since 1.0.0 * @return void */ public function refresh() { $this->settings = null; } /** * A single resolved setting. * * @since 1.0.0 * @param string $key Field ID. * @param mixed $fallback Value when the key is unknown. * @return mixed */ public function setting( $key, $fallback = null ) { $settings = $this->settings(); return array_key_exists( $key, $settings ) ? $settings[ $key ] : $fallback; } /** * Sanitize raw input against the schema and persist it. * * Partial updates keep other settings: a field missing from `$input` keeps its current value * (the admin form always sends every field, so an unticked list arrives as an empty list). * * @since 1.0.0 * @param array $input Raw input keyed by field ID. * @return array Sanitized settings as saved. */ public function save_settings( array $input ) { $current = $this->settings(); $clean = array(); foreach ( $this->fields() as $field ) { if ( 'heading' === $field['type'] ) { continue; } if ( ! array_key_exists( $field['id'], $input ) && array_key_exists( $field['id'], $current ) ) { $clean[ $field['id'] ] = $current[ $field['id'] ]; // Already clean: settings() typed it. continue; } $clean[ $field['id'] ] = $this->sanitize_field( $field, $input[ $field['id'] ] ?? null ); } $this->store->set_module_settings( $this->id(), $clean, $this->should_autoload() ); $this->settings = null; return $this->settings(); } /** * Sanitize a single value for its field type. Unknown/invalid values fall back to the default. * * @since 1.0.0 * @param array $field Field schema. * @param mixed $value Raw value. * @return mixed */ protected function sanitize_field( array $field, $value ) { $default = $field['default'] ?? null; switch ( $field['type'] ) { case 'toggle': return in_array( $value, array( true, 1, '1', 'true', 'yes', 'on' ), true ); case 'select': $value = Str::scalar( $value ); return array_key_exists( $value, $field['options'] ?? array() ) ? $value : $default; case 'checkboxes': case 'sortable': // Order of the submitted list is kept. if ( ! is_array( $value ) ) { return array(); } $allowed = array_keys( $field['options'] ?? array() ); $value = array_filter( $value, 'is_scalar' ); return array_values( array_unique( array_intersect( array_map( 'strval', $value ), array_map( 'strval', $allowed ) ) ) ); case 'number': if ( ! is_numeric( $value ) ) { return $default; } $value = 0 + $value; if ( ! is_finite( (float) $value ) ) { return $default; // "1e999" is numeric but INF, which can't be saved as JSON. } if ( isset( $field['min'] ) ) { $value = max( $field['min'], $value ); } if ( isset( $field['max'] ) ) { $value = min( $field['max'], $value ); } return $value; case 'search': if ( ! is_array( $value ) ) { return array(); } $ids = \SmartWP\AdminPowerups\Admin\Lookup::clean_ids( (string) ( $field['source'] ?? '' ), $value ); return \SmartWP\AdminPowerups\Admin\Lookup::existing( (string) ( $field['source'] ?? '' ), $ids ); case 'textarea': return is_string( $value ) ? sanitize_textarea_field( $value ) : $default; case 'date': $value = is_string( $value ) ? trim( $value ) : ''; if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $m ) && checkdate( (int) $m[2], (int) $m[3], (int) $m[1] ) ) { return $value; } return ''; case 'text': default: return is_string( $value ) ? sanitize_text_field( $value ) : $default; } } /** * Data exposed to the admin UI / REST. * * @since 1.0.0 * @return array */ public function to_array() { return array( 'id' => $this->id(), 'title' => $this->title(), 'description' => $this->description(), 'category' => $this->category(), 'location' => $this->location(), 'enabled' => $this->is_enabled(), 'icon' => $this->icon(), 'badges' => $this->get_badges(), 'views' => $this->views(), 'unmet' => $this->unmet_requirement(), 'settings' => $this->settings(), 'admin_only' => $this->is_admin_only(), 'risk' => \SmartWP\AdminPowerups\Support\DevGuard::level( $this->risk() ), ); } }