├── .nvmrc ├── .gitattributes ├── src ├── Exceptions │ ├── CustomFieldsException.php │ └── MissingArgumentException.php ├── Integrations │ ├── ItemsIntegration.php │ ├── MenuItem.php │ ├── User.php │ ├── Taxonomy.php │ ├── Comment.php │ ├── BaseIntegration.php │ ├── OptionsIntegration.php │ ├── Metabox.php │ ├── WooCommerceSettings.php │ ├── OrderMetabox.php │ ├── SubscriptionMetabox.php │ ├── ProductVariationOptions.php │ ├── WcMembershipPlanOptions.php │ ├── CouponOptions.php │ ├── ProductOptions.php │ ├── GutenbergBlock.php │ └── SiteOptions.php ├── Fields │ └── DirectFileField.php ├── Api.php └── Helpers.php ├── readme.txt ├── composer.json ├── custom-fields.php ├── readme.md ├── phpcs.xml └── CLAUDE.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v22 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .githooks export-ignore 2 | .github export-ignore 3 | assets export-ignore 4 | docs export-ignore 5 | .gitignore export-ignore 6 | package.json export-ignore 7 | package-lock.json export-ignore 8 | webpack.config.js export-ignore 9 | -------------------------------------------------------------------------------- /src/Exceptions/CustomFieldsException.php: -------------------------------------------------------------------------------- 1 | get_item_id() ); 32 | } elseif ( ! empty( $this->option_name ) ) { 33 | $meta_value = $this->get_option_value( $this->option_name, array() ); 34 | 35 | return $meta_value[ $name ] ?? $this->custom_fields->get_default_value( $item ); 36 | } else { 37 | return $this->get_option_value( $name, $this->custom_fields->get_default_value( $item ) ); 38 | } 39 | } 40 | 41 | /** 42 | * Sets the value of a specified field. 43 | * 44 | * @param string $name The name of the field to set. 45 | * @param mixed $value The value to set for the specified field. 46 | * @param array $item Optional. An array of item data which may include a 'callback_set' callable. 47 | * 48 | * @return mixed The result of setting the specified field. 49 | */ 50 | public function set_field( string $name, mixed $value, array $item = array() ): mixed { 51 | if ( ! empty( $item['callback_set'] ) ) { 52 | return call_user_func( $item['callback_set'], $item, $this->get_item_id(), $value ); 53 | } elseif ( ! empty( $this->option_name ) ) { 54 | $meta_value = $this->get_option_value( $this->option_name, array() ); 55 | $meta_value[ $name ] = $value; 56 | 57 | return $this->set_option_value( $this->option_name, $meta_value ); 58 | } else { 59 | return $this->set_option_value( $name, $value ); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Integrations/MenuItem.php: -------------------------------------------------------------------------------- 1 | id = $args['id'] ?? 'menu_item__' . wp_generate_uuid4(); 69 | $this->tabs = $args['tabs'] ?? array(); 70 | $this->option_name = $args['meta_key'] ?? ''; 71 | $this->items = $args['items'] ?? array(); 72 | 73 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 74 | add_action( 'wp_nav_menu_item_custom_fields', array( $this, 'render' ) ); 75 | add_action( 'wp_update_nav_menu_item', array( $this, 'save' ), 10, 2 ); 76 | add_action( 'current_screen', array( $this, 'maybe_enqueue' ) ); 77 | } 78 | } 79 | 80 | /** 81 | * Maybe enqueue scripts and styles based on the current screen. 82 | * 83 | * @param WP_Screen $current_screen The current screen object. 84 | * 85 | * @return void 86 | */ 87 | public function maybe_enqueue( WP_Screen $current_screen ): void { 88 | if ( 'nav-menus' === $current_screen->id ) { 89 | $this->enqueue(); 90 | } 91 | } 92 | 93 | /** 94 | * Renders the menu item with the specified configurations. 95 | * 96 | * @param string $item_id The ID of the menu item. 97 | * 98 | * @return void 99 | */ 100 | public function render( string $item_id ): void { 101 | $this->item_id = $item_id; 102 | $this->enqueue(); 103 | $this->print_app( 104 | 'menu-item', 105 | $this->tabs, 106 | array( 'loop' => $item_id ), 107 | ); 108 | 109 | $items = $this->normalize_items( $this->items ); 110 | 111 | foreach ( $items as $item ) { 112 | $this->print_field( 113 | $item, 114 | array( 'loop' => $item_id ), 115 | ); 116 | } 117 | } 118 | 119 | /** 120 | * Saves the menu item with the specified configurations. 121 | * 122 | * @param int $menu_id The ID of the menu. 123 | * @param int $menu_item_db_id The ID of the menu item in the database. 124 | * 125 | * @return void 126 | */ 127 | public function save( int $menu_id, int $menu_item_db_id ): void { 128 | $this->item_id = $menu_item_db_id; 129 | 130 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ), $menu_item_db_id ); 131 | } 132 | 133 | /** 134 | * Retrieves the value of a specified option, or returns a default value if the option is not set. 135 | * 136 | * @param string $name The name of the option to retrieve. 137 | * @param mixed $default_value The default value to return if the option is not set. 138 | */ 139 | public function get_option_value( string $name, mixed $default_value ): mixed { 140 | return get_post_meta( $this->get_item_id(), $name, true ) ?? $default_value; 141 | } 142 | 143 | /** 144 | * Sets the value of a specified option. 145 | * 146 | * @param string $name The name of the option to set. 147 | * @param mixed $value The value to set for the option. 148 | * 149 | * @return bool True on success, false on failure. 150 | */ 151 | public function set_option_value( string $name, mixed $value ): bool { 152 | return update_post_meta( $this->get_item_id(), $name, $value ); 153 | } 154 | 155 | /** 156 | * Retrieves the ID of the item. 157 | * 158 | * @return int The ID of the item. 159 | */ 160 | public function get_item_id(): int { 161 | return $this->item_id; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # WPify Custom Fields 2 | 3 | WPify Custom Fields is a powerful, developer-oriented WordPress library for creating custom fields. It provides a comprehensive solution for integrating custom fields into various parts of WordPress and WooCommerce, from post metaboxes to product options, options pages, taxonomies, and much more. 4 | 5 | Built with modern React.js and PHP 8.1+, this library offers maximum flexibility for developers while maintaining a clean, intuitive interface for end-users. 6 | 7 | ## Key Features 8 | 9 | - **Extensive Integration Options**: Add custom fields to 14+ different contexts: 10 | - WordPress Core: Post Metaboxes, Taxonomies, Options Pages, Menu Items, Gutenberg Blocks, User Profiles, Comments 11 | - WooCommerce: Product Options, Product Variations, Order Metaboxes, Settings Pages, Subscriptions, Membership Plans 12 | - Multisite: Site Options, Network Options 13 | 14 | - **50+ Field Types**: Build anything from simple forms to complex interfaces: 15 | - Simple Fields: Text, Textarea, Number, Select, Toggle, Checkbox, Date/Time, Color, etc. 16 | - Relational Fields: Post, Term, Attachment, Links 17 | - Complex Fields: Group, Code Editor, WYSIWYG, Map integration 18 | - Repeater Fields: Multi versions of all field types 19 | - Static Fields: HTML, Button, Title, Hidden 20 | 21 | - **Powerful Conditional Logic**: Dynamically show/hide fields based on complex conditions: 22 | - Multiple comparison operators (equals, contains, greater than, etc.) 23 | - Complex AND/OR logic and nested condition groups 24 | - Advanced path references with dot notation for nested fields 25 | 26 | - **Organized Field Groups**: Create better user experiences: 27 | - Tabbed interface for organizing related fields 28 | - Nested tabs for complex hierarchies 29 | - Collapsible field groups 30 | 31 | - **Developer-Friendly**: 32 | - Strong typing with PHP 8.1+ features 33 | - Clean, standardized API 34 | - Extendable architecture for custom field types 35 | - Well-documented with consistent examples 36 | 37 | ## Requirements 38 | 39 | - PHP 8.1 or later 40 | - WordPress 6.2 or later 41 | - Modern browser (Chrome, Firefox, Safari, Edge) 42 | 43 | ## Installation 44 | 45 | ### Via Composer (Recommended) 46 | 47 | ```bash 48 | composer require wpify/custom-fields 49 | ``` 50 | 51 | ### Manual Installation 52 | 53 | 1. Download the latest release from the [Releases page](https://github.com/wpify/custom-fields/releases) 54 | 2. Upload to your `/wp-content/plugins/` directory 55 | 3. Activate through the WordPress admin interface 56 | 57 | ## Quick Example 58 | 59 | ```php 60 | // Create a custom metabox for posts 61 | wpify_custom_fields()->create_metabox( 62 | array( 63 | 'id' => 'demo_metabox', 64 | 'title' => __( 'Demo Metabox', 'textdomain' ), 65 | 'post_type' => 'post', 66 | 'items' => array( 67 | 'text_field' => array( 68 | 'type' => 'text', 69 | 'label' => __( 'Text Field', 'textdomain' ), 70 | 'description' => __( 'This is a simple text field', 'textdomain' ), 71 | 'required' => true, 72 | ), 73 | 'select_field' => array( 74 | 'type' => 'select', 75 | 'label' => __( 'Select Field', 'textdomain' ), 76 | 'options' => array( 77 | 'option1' => __( 'Option 1', 'textdomain' ), 78 | 'option2' => __( 'Option 2', 'textdomain' ), 79 | ), 80 | 'conditions' => array( 81 | array( 82 | 'field' => 'text_field', 83 | 'condition' => '!=', 84 | 'value' => '', 85 | ), 86 | ), 87 | ), 88 | ), 89 | ) 90 | ); 91 | ``` 92 | 93 | ## Why Choose WPify Custom Fields? 94 | 95 | - **Flexible API**: Provides a consistent API across all WordPress and WooCommerce contexts 96 | - **Modern Architecture**: Built with React and modern PHP principles 97 | - **Performance Optimized**: Loads only the resources needed for each context 98 | - **Comprehensive Solution**: No need for multiple plugins to handle different field contexts 99 | - **Future-Proof**: Regularly updated and maintained 100 | - **Extendable**: Create custom field types when needed 101 | 102 | ## Documentation 103 | 104 | For comprehensive documentation, visit: 105 | 106 | - [Main Documentation](docs/index.md) 107 | - [Field Types](docs/field-types.md) 108 | - [Integrations](docs/integrations.md) 109 | - [Conditional Logic](docs/features/conditions.md) 110 | - [Tabs System](docs/features/tabs.md) 111 | - [Extending](docs/features/extending.md) 112 | - [Migration from 3.x to 4.x](docs/migration-3-to-4.md) 113 | 114 | ## Support & Issues 115 | 116 | If you encounter any issues or have questions, please [open an issue](https://github.com/wpify/custom-fields/issues) on our GitHub repository. 117 | 118 | ## License 119 | 120 | WPify Custom Fields is released under the GPL v2 or later license. 121 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A custom set of rules to check for a WPized WordPress project 5 | 6 | 12 | 13 | . 14 | 15 | 16 | /vendor/* 17 | 18 | 19 | /node_modules/* 20 | 21 | 22 | /build/* 23 | 24 | 25 | *.js 26 | 27 | 28 | *.scss 29 | *.css 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 43 | 44 | 45 | 46 | 57 | 58 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 74 | 75 | 76 | 84 | 85 | 86 | *\.php 87 | 88 | 89 | 90 | 95 | 96 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 130 | 131 | 147 | 155 | 156 | -------------------------------------------------------------------------------- /src/Integrations/User.php: -------------------------------------------------------------------------------- 1 | items = $args['items'] ?? array(); 89 | $this->user_id = $args['user_id'] ?? null; 90 | $this->title = $args['title'] ?? ''; 91 | $this->init_priority = $args['init_priority'] ?? 10; 92 | $this->tabs = $args['tabs'] ?? array(); 93 | $this->option_name = $args['meta_key'] ?? ''; 94 | 95 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 96 | $this->display = $args['display']; 97 | } else { 98 | $this->display = function () use ( $args ) { 99 | return $args['display'] ?? true; 100 | }; 101 | } 102 | 103 | $this->id = $args['id'] ?? 'user__' . sanitize_title( $this->title ) . '__' . wp_generate_uuid4(); 104 | 105 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 106 | add_action( 'show_user_profile', array( $this, 'render_edit_form' ) ); 107 | add_action( 'edit_user_profile', array( $this, 'render_edit_form' ) ); 108 | add_action( 'personal_options_update', array( $this, 'save' ) ); 109 | add_action( 'edit_user_profile_update', array( $this, 'save' ) ); 110 | add_action( 'init', array( $this, 'register_meta' ), $this->init_priority ); 111 | } 112 | } 113 | 114 | /** 115 | * Registers meta fields for user profiles. 116 | * 117 | * Normalizes the items and registers each meta field with specific properties 118 | * such as type, description, default value, and sanitize callback. 119 | * 120 | * @return void 121 | */ 122 | public function register_meta(): void { 123 | $items = $this->normalize_items( $this->items ); 124 | 125 | foreach ( $items as $item ) { 126 | register_meta( 127 | 'user', 128 | $item['id'], 129 | array( 130 | 'type' => $this->custom_fields->get_wp_type( $item ), 131 | 'description' => $item['label'], 132 | 'single' => true, 133 | 'default' => $this->custom_fields->get_default_value( $item ), 134 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 135 | ), 136 | ); 137 | } 138 | } 139 | 140 | /** 141 | * Renders the edit form for a user. 142 | * 143 | * @param WP_User $user The user object whose profile is being edited. 144 | * 145 | * @return void 146 | */ 147 | public function render_edit_form( WP_User $user ): void { 148 | $display_callback = $this->display; 149 | 150 | if ( ! $display_callback() ) { 151 | return; 152 | } 153 | 154 | $this->user_id = $user->ID; 155 | $items = $this->normalize_items( $this->items ); 156 | 157 | $this->enqueue(); 158 | 159 | if ( ! empty( $this->title ) ) { 160 | ?> 161 |

title ); ?>

162 | print_app( 'user', $this->tabs ); 165 | ?> 166 | 167 | 168 | 171 | 172 | 177 | 180 | 181 | 184 | 185 | 186 | user_id = $user_id; 198 | 199 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 200 | } 201 | 202 | /** 203 | * Retrieves an option value for a given name. If the option does not exist, 204 | * returns the provided default value. 205 | * 206 | * @param string $name The name of the option to retrieve. 207 | * @param mixed $default_value The value to return if the option is not found. 208 | * 209 | * @return mixed The value of the option if it exists, otherwise the default value. 210 | */ 211 | public function get_option_value( string $name, mixed $default_value ): mixed { 212 | return get_user_meta( $this->get_item_id(), $name, true ) ?? $default_value; 213 | } 214 | 215 | /** 216 | * Sets a value for the specified option name. 217 | * 218 | * @param string $name The name of the option to set. 219 | * @param mixed $value The value to set for the option. 220 | * 221 | * @return bool|int True on success, false on failure. 222 | */ 223 | public function set_option_value( string $name, mixed $value ): bool|int { 224 | return update_user_meta( $this->get_item_id(), $name, $value ); 225 | } 226 | 227 | /** 228 | * Retrieves the item ID. 229 | * 230 | * @return int The ID of the item. 231 | */ 232 | public function get_item_id(): int { 233 | return $this->user_id; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Build/Test Commands 6 | - Start dev server: `npm run start` 7 | - Build for production: `npm run build` 8 | - Analyze bundle: `npm run build:analyze` 9 | - PHP code standards check: `composer run phpcs` 10 | - PHP code beautifier: `composer run phpcbf` 11 | 12 | ## Code Style Guidelines 13 | - PHP: WordPress Coding Standards (WPCS) with customizations in phpcs.xml 14 | - PHP version: 8.1+ 15 | - WordPress version: 6.2+ 16 | - JS: Use WordPress scripts standards 17 | - Prefix PHP globals with `wpifycf` 18 | - Translation text domain: `wpify-custom-fields` 19 | - React components use PascalCase 20 | - JS helpers use camelCase 21 | - Namespace: `Wpify\CustomFields` 22 | - PHP class files match class name (PSR-4) 23 | - Import paths: Use `@` alias for assets directory in JS 24 | - Error handling: Use custom exceptions in `Exceptions` directory 25 | - Documentation is in PHPDoc format and in docs folder in md format 26 | - When generating PHP code, always use WordPress Coding Standards 27 | 28 | ## Extending Field Types 29 | To create a custom field type, the following components are required: 30 | 31 | 1. **PHP Filters**: 32 | - `wpifycf_sanitize_{type}` - For sanitizing field values 33 | - `wpifycf_wp_type_{type}` - To specify WordPress data type (integer, number, boolean, object, array, string) 34 | - `wpifycf_default_value_{type}` - To define default values 35 | 36 | 2. **JavaScript Components**: 37 | - Create a React component for the field 38 | - Add validation method to the component (`YourComponent.checkValidity`) 39 | - Register the field via `addFilter('wpifycf_field_{type}', 'wpify_custom_fields', () => YourComponent)` 40 | 41 | 3. **Multi-field Types**: 42 | - Custom field types can have multi-versions by prefixing with `multi_` 43 | - Leverage the existing `MultiField` component for implementation 44 | - Use `checkValidityMultiFieldType` helper for validation 45 | 46 | 4. **Field Component Structure**: 47 | - Field components receive props like `id`, `htmlId`, `onChange`, `value`, etc. 48 | - CSS classes should follow pattern: `wpifycf-field-{type}` 49 | - Return JSX with appropriate HTML elements 50 | 51 | ## Documentation Standards 52 | When writing or updating documentation: 53 | 54 | ### PHP Code Examples 55 | - Use tabs for indentation, not spaces 56 | - Follow WordPress Coding Standards for all PHP examples: 57 | - Add spaces inside parentheses for conditions: `if ( ! empty( $var ) )` 58 | - Add spaces after control structure keywords: `if (...) {` 59 | - Add spaces around logical operators: `$a && $b`, `! $condition` 60 | - Add spaces around string concatenation: `$a . ' ' . $b` 61 | - Add spaces for function parameters: `function_name( $param1, $param2 )` 62 | - Use proper array formatting with tabs for indentation: 63 | ```php 64 | array( 65 | 'key1' => 'value1', 66 | 'key2' => 'value2', 67 | ) 68 | ``` 69 | - Maintain consistent spacing around array arrow operators: `'key' => 'value'` 70 | - Use spaces in associative array access: `$array[ 'key' ]` 71 | 72 | ### Documentation Structure for Field Types 73 | Field type documentation should follow this consistent structure: 74 | 1. **Title and Description** - Clear explanation of the field's purpose 75 | 2. **Field Type Definition** - Example code following WordPress coding standards 76 | 3. **Properties Section**: 77 | - Default field properties 78 | - Specific properties unique to the field type 79 | 4. **Stored Value** - Explanation of how data is stored in the database 80 | 5. **Example Usage** - Real-world examples with WordPress coding standards 81 | 6. **Notes** - Important details about the field's behavior and uses 82 | 83 | ### Documentation Structure for Integrations 84 | Integration documentation should follow this consistent structure: 85 | 1. **Title and Overview** - Clear explanation of the integration's purpose 86 | 2. **Requirements** - Any specific plugins or dependencies required (if applicable) 87 | 3. **Usage Example** - PHP code example following WordPress coding standards 88 | 4. **Parameters Section**: 89 | - Required parameters with descriptions 90 | - Optional parameters with descriptions and default values 91 | 5. **Data Storage** - How and where the integration stores its data 92 | 6. **Retrieving Data** - How to access stored data programmatically 93 | 7. **Advanced Usage** - Examples of tabs, conditional display, etc. (as applicable) 94 | 95 | ### Security in Examples 96 | - Always include proper data escaping in examples: 97 | - `esc_html()` for plain text output 98 | - `esc_attr()` for HTML attributes 99 | - `esc_url()` for URLs 100 | - `wp_kses()` for allowing specific HTML 101 | 102 | ### Consistency 103 | - Maintain consistent terminology across all documentation files 104 | - Use consistent formatting for property descriptions 105 | - Keep parameter documentation format consistent: `name` _(type)_ - description 106 | - When documenting integrations, use consistent parameter naming and structure 107 | 108 | ### Integration-Specific Notes 109 | - For WooCommerce integrations, always mention compatibility with HPOS when applicable 110 | - Product Options integrations should list common tab IDs from WooCommerce 111 | - Order and Subscription integrations should include examples of retrieving meta 112 | - All integration documentation should include examples of tabs and conditional display 113 | - When documenting options pages, always include proper menu/page configuration 114 | 115 | ### File Organization 116 | - Field type documentation goes in `docs/field-types/` 117 | - Integration documentation goes in `docs/integrations/` 118 | - Feature documentation goes in `docs/features/` 119 | - All documentation files should use `.md` extension 120 | - Main index files (integrations.md, field-types.md) should link to all related docs 121 | 122 | ## Conditional Fields 123 | The plugin provides a robust conditional logic system for dynamically showing/hiding fields: 124 | 125 | ### Condition Structure 126 | Each condition requires: 127 | - `field`: The ID of the field to check (can use path references) 128 | - `condition`: The comparison operator 129 | - `value`: The value to compare against 130 | 131 | ### Available Operators 132 | - `==`: Equal (default) 133 | - `!=`: Not equal 134 | - `>`, `>=`, `<`, `<=`: Comparison operators 135 | - `between`: Value is between two numbers, inclusive 136 | - `contains`, `not_contains`: String contains/doesn't contain value 137 | - `in`, `not_in`: Value is/isn't in an array 138 | - `empty`, `not_empty`: Value is/isn't empty 139 | 140 | ### Multiple Conditions 141 | - Combine with `'and'` (default) or `'or'` between conditions 142 | - Create nested condition groups with sub-arrays for complex logic 143 | 144 | ### Path References 145 | - Dot notation for nested fields: `parent.child` 146 | - Hash symbols for relative paths: `#` (parent), `##` (grandparent) 147 | - Array access: `multi_field[0]` for specific items 148 | 149 | ### Technical Implementation 150 | - Conditional logic lives in `Field.js`, `hooks.js` (useConditions), and `functions.js` 151 | - Hidden fields are still submitted but have `data-hide-field="true"` attribute 152 | - Conditions are evaluated in real-time as users interact with the form 153 | -------------------------------------------------------------------------------- /src/Integrations/Taxonomy.php: -------------------------------------------------------------------------------- 1 | taxonomy = $args['taxonomy'] ?? ''; 91 | $this->items = $args['items'] ?? array(); 92 | 93 | if ( empty( $this->taxonomy ) ) { 94 | throw new MissingArgumentException( 95 | sprintf( 96 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 97 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 98 | esc_html( implode( ', ', array( 'taxonomy' ) ) ), 99 | __CLASS__, 100 | ), 101 | ); 102 | } 103 | 104 | $this->id = $args['id'] ?? sanitize_title( $this->taxonomy ) . '_' . wp_generate_uuid4(); 105 | $this->hook_priority = $args['hook_priority'] ?? 10; 106 | $this->init_priority = $args['init_priority'] ?? 10; 107 | $this->tabs = $args['tabs'] ?? array(); 108 | $this->option_name = $args['meta_key'] ?? ''; 109 | 110 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 111 | add_action( $this->taxonomy . '_add_form_fields', array( $this, 'render_add_form' ), $this->hook_priority ); 112 | add_action( 113 | $this->taxonomy . '_edit_form_fields', 114 | array( 115 | $this, 116 | 'render_edit_form', 117 | ), 118 | $this->hook_priority 119 | ); 120 | add_action( 'created_' . $this->taxonomy, array( $this, 'save' ) ); 121 | add_action( 'edited_' . $this->taxonomy, array( $this, 'save' ) ); 122 | add_action( 'init', array( $this, 'register_meta' ), $this->init_priority ); 123 | } 124 | } 125 | 126 | /** 127 | * Renders the form for adding a new term. 128 | * 129 | * @return void 130 | */ 131 | public function render_add_form(): void { 132 | $this->term_id = 0; 133 | $this->enqueue(); 134 | $this->print_app( 'add_term', $this->tabs ); 135 | 136 | $items = $this->normalize_items( $this->items ); 137 | 138 | foreach ( $items as $item ) { 139 | $this->print_field( $item, array(), 'div', 'form-field' ); 140 | } 141 | } 142 | 143 | /** 144 | * Renders the edit form for a given term. 145 | * 146 | * @param WP_Term $term The term object for which the edit form is being rendered. 147 | * 148 | * @return void 149 | */ 150 | public function render_edit_form( WP_Term $term ): void { 151 | $this->term_id = $term->term_id; 152 | $this->enqueue(); 153 | ?> 154 | 155 | 156 | print_app( 'edit_term', $this->tabs ); ?> 157 | 158 | 159 | normalize_items( $this->items ); 162 | 163 | foreach ( $items as $item ) { 164 | $this->print_field( $item, array(), 'tr' ); 165 | } 166 | } 167 | 168 | /** 169 | * Registers custom metadata for the specified taxonomy using the provided items. 170 | * 171 | * This method normalizes the items and registers each one as term meta for the given taxonomy. 172 | * It sets various properties such as type, description, default value, and sanitize callback. 173 | * 174 | * @return void 175 | */ 176 | public function register_meta(): void { 177 | $items = $this->normalize_items( $this->items ); 178 | 179 | foreach ( $items as $item ) { 180 | register_term_meta( 181 | $this->taxonomy, 182 | $item['id'], 183 | array( 184 | 'type' => $this->custom_fields->get_wp_type( $item ), 185 | 'description' => $item['label'], 186 | 'single' => true, 187 | 'default' => $this->custom_fields->get_default_value( $item ), 188 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 189 | 'show_in_rest' => false, 190 | ), 191 | ); 192 | } 193 | } 194 | 195 | /** 196 | * Save method for handling term data and updating custom fields. 197 | * 198 | * @param int $term_id The ID of the term being saved. 199 | * 200 | * @return void 201 | */ 202 | public function save( int $term_id ): void { 203 | $this->term_id = $term_id; 204 | 205 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 206 | } 207 | 208 | /** 209 | * Retrieves the value of a specified option, if available. 210 | * 211 | * @param string $name The name of the option to retrieve. 212 | * @param mixed $default_value The default value to return if the option is not found. 213 | * 214 | * @return mixed The value of the specified option, or the default value if the option is not found. 215 | */ 216 | public function get_option_value( string $name, mixed $default_value ): mixed { 217 | if ( $this->get_item_id() ) { 218 | return get_term_meta( $this->get_item_id(), $name, true ) ?? $default_value; 219 | } else { 220 | return $default_value; 221 | } 222 | } 223 | 224 | /** 225 | * Sets the value of a specified option. 226 | * 227 | * @param string $name The name of the option to set. 228 | * @param mixed $value The value to set for the specified option. 229 | * 230 | * @return WP_Error|bool|int The result of attempting to update the option value. It returns true on success, false on failure, or an error object on error. 231 | */ 232 | public function set_option_value( string $name, mixed $value ): WP_Error|bool|int { 233 | return update_term_meta( $this->get_item_id(), $name, $value ); 234 | } 235 | 236 | /** 237 | * Retrieves the item ID. 238 | * 239 | * @return int The ID of the item. 240 | */ 241 | public function get_item_id(): int { 242 | return $this->term_id; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /src/Fields/DirectFileField.php: -------------------------------------------------------------------------------- 1 | custom_fields = $custom_fields; 40 | $this->helpers = $custom_fields->helpers; 41 | 42 | $this->init_hooks(); 43 | } 44 | 45 | /** 46 | * Initialize WordPress hooks. 47 | * 48 | * @return void 49 | */ 50 | private function init_hooks(): void { 51 | add_filter( 'wpifycf_sanitize_direct_file', array( $this, 'sanitize_direct_file' ), 10, 3 ); 52 | add_filter( 'wpifycf_sanitize_multi_direct_file', array( $this, 'sanitize_multi_direct_file' ), 10, 3 ); 53 | add_filter( 'wpifycf_wp_type_direct_file', array( $this, 'get_wp_type' ), 10, 2 ); 54 | add_filter( 'wpifycf_wp_type_multi_direct_file', array( $this, 'get_wp_type_multi' ), 10, 2 ); 55 | add_filter( 'wpifycf_default_value_direct_file', array( $this, 'get_default_value' ), 10, 2 ); 56 | add_filter( 'wpifycf_default_value_multi_direct_file', array( $this, 'get_default_value_multi' ), 10, 2 ); 57 | } 58 | 59 | /** 60 | * Sanitizes the direct_file field value. 61 | * 62 | * @param mixed $value The sanitized value. 63 | * @param mixed $original_value The original value. 64 | * @param array $item The field definition. 65 | * 66 | * @return string The absolute file path or empty string. 67 | */ 68 | public function sanitize_direct_file( $value, $original_value, array $item ): string { 69 | // If value is empty, handle deletion if needed. 70 | if ( empty( $value ) ) { 71 | $this->maybe_delete_old_file( $original_value, $item ); 72 | return ''; 73 | } 74 | 75 | // If value is the same as original, no change needed. 76 | if ( $value === $original_value ) { 77 | return sanitize_text_field( $value ); 78 | } 79 | 80 | // Check if this is a temp file that needs to be moved. 81 | $temp_dir = $this->helpers->get_direct_file_temp_dir(); 82 | if ( strpos( $value, $temp_dir ) === 0 ) { 83 | // This is a temp file, move it to the target directory. 84 | $target_directory = $this->get_target_directory( $item ); 85 | $filename = $this->helpers->get_filename_from_path( $value ); 86 | $replace_existing = $item['replace_existing'] ?? false; 87 | 88 | $moved_path = $this->helpers->move_temp_to_directory( 89 | $value, 90 | $target_directory, 91 | $filename, 92 | $replace_existing 93 | ); 94 | 95 | if ( $moved_path ) { 96 | // Delete old file if it exists and is different. 97 | if ( ! empty( $original_value ) && $original_value !== $moved_path ) { 98 | $this->maybe_delete_old_file( $original_value, $item ); 99 | } 100 | 101 | return $moved_path; 102 | } 103 | 104 | // If move failed, return empty string. 105 | return ''; 106 | } 107 | 108 | // Value is already a final path (e.g., editing existing record). 109 | // Validate it exists. 110 | if ( file_exists( $value ) ) { 111 | return sanitize_text_field( $value ); 112 | } 113 | 114 | return ''; 115 | } 116 | 117 | /** 118 | * Sanitizes the multi_direct_file field value. 119 | * 120 | * @param mixed $value The sanitized value. 121 | * @param mixed $original_value The original value. 122 | * @param array $item The field definition. 123 | * 124 | * @return array Array of file paths. 125 | */ 126 | public function sanitize_multi_direct_file( $value, $original_value, array $item ): array { 127 | // Decode JSON or convert to array, handling edge cases. 128 | if ( is_string( $value ) ) { 129 | $decoded = json_decode( $value, true ); 130 | $value = is_array( $decoded ) ? $decoded : array( $value ); 131 | } else { 132 | $value = (array) $value; 133 | } 134 | 135 | if ( is_string( $original_value ) ) { 136 | $decoded = json_decode( $original_value, true ); 137 | $original_value = is_array( $decoded ) ? $decoded : array( $original_value ); 138 | } else { 139 | $original_value = (array) $original_value; 140 | } 141 | 142 | $sanitized_value = array(); 143 | 144 | if ( ! is_array( $value ) ) { 145 | return array(); 146 | } 147 | 148 | // Track which original files are still present. 149 | $original_files = is_array( $original_value ) ? $original_value : array(); 150 | $kept_files = array(); 151 | 152 | foreach ( $value as $single_value ) { 153 | $sanitized = $this->sanitize_direct_file( $single_value, '', $item ); 154 | if ( ! empty( $sanitized ) ) { 155 | $sanitized_value[] = $sanitized; 156 | $kept_files[] = $sanitized; 157 | } 158 | } 159 | 160 | // Delete files that were removed. 161 | foreach ( $original_files as $original_file ) { 162 | if ( ! in_array( $original_file, $kept_files, true ) ) { 163 | $this->maybe_delete_old_file( $original_file, $item ); 164 | } 165 | } 166 | 167 | return $sanitized_value; 168 | } 169 | 170 | /** 171 | * Gets the target directory for the field. 172 | * 173 | * @param array $item The field definition. 174 | * 175 | * @return string The absolute target directory path. 176 | */ 177 | private function get_target_directory( array $item ): string { 178 | $directory = $item['directory'] ?? 'wp-content/uploads/direct-files/'; 179 | return $this->helpers->sanitize_directory_path( $directory ); 180 | } 181 | 182 | /** 183 | * Maybe deletes the old file based on the field's on_delete setting. 184 | * 185 | * @param string $file_path The file path to delete. 186 | * @param array $item The field definition. 187 | * 188 | * @return void 189 | */ 190 | private function maybe_delete_old_file( string $file_path, array $item ): void { 191 | if ( empty( $file_path ) || ! file_exists( $file_path ) ) { 192 | return; 193 | } 194 | 195 | $on_delete = $item['on_delete'] ?? 'keep'; 196 | 197 | if ( 'delete' === $on_delete ) { 198 | $this->helpers->delete_direct_file( $file_path ); 199 | } 200 | } 201 | 202 | /** 203 | * Returns the WordPress data type for direct_file field. 204 | * 205 | * @return string The WordPress data type. 206 | */ 207 | public function get_wp_type(): string { 208 | return 'string'; 209 | } 210 | 211 | /** 212 | * Returns the WordPress data type for multi_direct_file field. 213 | * 214 | * @return string The WordPress data type. 215 | */ 216 | public function get_wp_type_multi(): string { 217 | return 'array'; 218 | } 219 | 220 | /** 221 | * Returns the default value for direct_file field. 222 | * 223 | * @return string Empty string as default. 224 | */ 225 | public function get_default_value(): string { 226 | return ''; 227 | } 228 | 229 | /** 230 | * Returns the default value for multi_direct_file field. 231 | * 232 | * @return array Empty array as default. 233 | */ 234 | public function get_default_value_multi(): array { 235 | return array(); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/Api.php: -------------------------------------------------------------------------------- 1 | register_rest_route( 43 | 'url-title', 44 | WP_REST_Server::READABLE, 45 | fn( WP_REST_Request $request ) => $this->helpers->get_url_title( $request->get_param( 'url' ) ), 46 | array( 47 | 'url' => array( 'required' => true ), 48 | ), 49 | ); 50 | 51 | $this->register_rest_route( 52 | 'posts', 53 | WP_REST_Server::READABLE, 54 | fn( WP_REST_Request $request ) => $this->helpers->get_posts( $request->get_params() ), 55 | array( 56 | 'post_type' => array( 'required' => true ), 57 | ), 58 | ); 59 | 60 | $this->register_rest_route( 61 | 'terms', 62 | WP_REST_Server::READABLE, 63 | fn( WP_REST_Request $request ) => $this->helpers->get_terms( $request->get_params() ), 64 | array( 65 | 'taxonomy' => array( 'required' => true ), 66 | ), 67 | ); 68 | 69 | $this->register_rest_route( 70 | 'mapycz-api-key', 71 | WP_REST_Server::EDITABLE, 72 | fn( WP_REST_Request $request ) => update_option( 'mapy_cz_api_key', $request->get_param( 'api_key' ) ), 73 | array( 'api_key' => array( 'required' => true ) ), 74 | ); 75 | 76 | $this->register_rest_route( 77 | 'mapycz-api-key', 78 | WP_REST_Server::READABLE, 79 | fn() => get_option( 'mapy_cz_api_key' ), 80 | ); 81 | 82 | $this->register_rest_route( 83 | 'direct-file-upload', 84 | WP_REST_Server::CREATABLE, 85 | array( $this, 'handle_direct_file_upload' ), 86 | array( 87 | 'field_id' => array( 'required' => false ), 88 | ), 89 | ); 90 | 91 | $this->register_rest_route( 92 | 'direct-file-info', 93 | WP_REST_Server::READABLE, 94 | array( $this, 'handle_direct_file_info' ), 95 | array( 96 | 'file_path' => array( 'required' => true ), 97 | ), 98 | ); 99 | } 100 | 101 | /** 102 | * Registers a REST API route with specified parameters. 103 | * 104 | * @param string $route The endpoint route. 105 | * @param string $method The HTTP method (GET, POST, etc.) for this route. 106 | * @param callable $callback The callback function to handle the request. 107 | * @param array $args Optional. Array of arguments for the route. 108 | * 109 | * @return void 110 | */ 111 | public function register_rest_route( string $route, string $method, callable $callback, array $args = array() ): void { 112 | register_rest_route( 113 | $this->get_rest_namespace(), 114 | $route, 115 | array( 116 | 'methods' => $method, 117 | 'callback' => $callback, 118 | 'permission_callback' => array( $this, 'permissions_callback' ), 119 | 'args' => $args, 120 | ), 121 | ); 122 | } 123 | 124 | /** 125 | * Retrieves the REST namespace for the plugin. 126 | * 127 | * @return string The REST namespace string constructed from the plugin's basename. 128 | */ 129 | public function get_rest_namespace(): string { 130 | return $this->custom_fields->get_api_basename() . '/wpifycf/v1'; 131 | } 132 | 133 | /** 134 | * Checks if the current user has permission to edit posts. 135 | * 136 | * @return bool True if the current user can edit posts, false otherwise. 137 | */ 138 | public function permissions_callback(): bool { 139 | return current_user_can( 'edit_posts' ); 140 | } 141 | 142 | /** 143 | * Handles direct file upload to temporary directory. 144 | * 145 | * @return array|\WP_Error Response array with temp_path or WP_Error on failure. 146 | */ 147 | public function handle_direct_file_upload() { 148 | // Check if file was uploaded. 149 | if ( empty( $_FILES['file'] ) ) { // phpcs:ignore 150 | return new \WP_Error( 'no_file', __( 'No file was uploaded.', 'wpify-custom-fields' ), array( 'status' => 400 ) ); 151 | } 152 | 153 | $file = $_FILES['file']; // phpcs:ignore 154 | 155 | // Check for upload errors. 156 | if ( UPLOAD_ERR_OK !== $file['error'] ) { 157 | return new \WP_Error( 'upload_error', __( 'File upload failed.', 'wpify-custom-fields' ), array( 'status' => 400 ) ); 158 | } 159 | 160 | // Validate file size. 161 | $max_upload_size = wp_max_upload_size(); 162 | if ( $file['size'] > $max_upload_size ) { 163 | return new \WP_Error( 164 | 'file_too_large', 165 | sprintf( 166 | /* translators: %s: maximum file size */ 167 | __( 'File size exceeds maximum allowed size of %s.', 'wpify-custom-fields' ), 168 | size_format( $max_upload_size ) 169 | ), 170 | array( 'status' => 400 ) 171 | ); 172 | } 173 | 174 | // Sanitize filename. 175 | $filename = sanitize_file_name( $file['name'] ); 176 | 177 | // Get temp directory. 178 | $temp_dir = $this->helpers->get_direct_file_temp_dir(); 179 | 180 | // Ensure temp directory exists. 181 | if ( ! file_exists( $temp_dir ) ) { 182 | if ( ! wp_mkdir_p( $temp_dir ) ) { 183 | return new \WP_Error( 'directory_creation_failed', __( 'Failed to create temporary directory.', 'wpify-custom-fields' ), array( 'status' => 500 ) ); 184 | } 185 | } 186 | 187 | // Generate unique filename to prevent conflicts. 188 | $unique_filename = wp_unique_filename( $temp_dir, $filename ); 189 | $temp_path = trailingslashit( $temp_dir ) . $unique_filename; 190 | 191 | // Move uploaded file to temp directory. 192 | if ( ! move_uploaded_file( $file['tmp_name'], $temp_path ) ) { 193 | return new \WP_Error( 'move_failed', __( 'Failed to save uploaded file.', 'wpify-custom-fields' ), array( 'status' => 500 ) ); 194 | } 195 | 196 | // Return temp path and metadata. 197 | return array( 198 | 'temp_path' => $temp_path, 199 | 'filename' => $unique_filename, 200 | 'size' => $file['size'], 201 | 'type' => $file['type'], 202 | ); 203 | } 204 | 205 | /** 206 | * Handles retrieving file information for a direct file. 207 | * 208 | * @param WP_REST_Request $request The REST API request object. 209 | * 210 | * @return array|\WP_Error File information or error. 211 | */ 212 | public function handle_direct_file_info( WP_REST_Request $request ) { 213 | $file_path = $request->get_param( 'file_path' ); 214 | 215 | if ( empty( $file_path ) ) { 216 | return new \WP_Error( 'no_file_path', __( 'No file path provided.', 'wpify-custom-fields' ), array( 'status' => 400 ) ); 217 | } 218 | 219 | // Sanitize the file path. 220 | $file_path = sanitize_text_field( $file_path ); 221 | 222 | // Check if file exists. 223 | if ( ! file_exists( $file_path ) ) { 224 | return new \WP_Error( 'file_not_found', __( 'File not found.', 'wpify-custom-fields' ), array( 'status' => 404 ) ); 225 | } 226 | 227 | // Get file information. 228 | $filesize = filesize( $file_path ); 229 | $filetype = wp_check_filetype( $file_path ); 230 | 231 | return array( 232 | 'size' => $filesize, 233 | 'type' => $filetype['type'], 234 | 'filename' => basename( $file_path ), 235 | ); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/Integrations/Comment.php: -------------------------------------------------------------------------------- 1 | title = $args['title'] ?? ''; 104 | $this->priority = $args['priority'] ?? self::PRIORITY_DEFAULT; 105 | $this->callback_args = $args['callback_args'] ?? array(); 106 | $this->id = $args['id'] ?? sanitize_title( 107 | join( 108 | '_', 109 | array( 110 | $this->title, 111 | 'comment', 112 | $this->priority, 113 | ), 114 | ), 115 | ); 116 | $this->nonce = $this->id . '_nonce'; 117 | $this->option_name = $args['meta_key'] ?? ''; 118 | $this->items = $args['items'] ?? array(); 119 | $this->tabs = $args['tabs'] ?? array(); 120 | 121 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 122 | add_action( 'add_meta_boxes_comment', array( $this, 'add_meta_box' ) ); 123 | add_action( 'edit_comment', array( $this, 'save_meta_box' ), 10, 2 ); 124 | } 125 | } 126 | 127 | /** 128 | * Adds a meta box to the comment editing screen. 129 | * 130 | * @param WP_Comment $comment The comment object for which the meta box is being added. 131 | * 132 | * @return void 133 | */ 134 | public function add_meta_box( WP_Comment $comment ): void { 135 | $this->set_comment( $comment->comment_ID ); 136 | add_meta_box( 137 | $this->id, 138 | $this->title, 139 | array( $this, 'render' ), 140 | 'comment', 141 | 'normal', 142 | $this->priority, 143 | $this->callback_args, 144 | ); 145 | } 146 | 147 | /** 148 | * Sets the comment ID for the current instance if it has not been set already. 149 | * 150 | * @param int $comment_id The ID of the comment to set. 151 | * 152 | * @return void 153 | */ 154 | public function set_comment( int $comment_id ): void { 155 | if ( empty( $this->comment_id ) ) { 156 | $this->comment_id = $comment_id; 157 | } 158 | } 159 | 160 | /** 161 | * Saves the meta box data for a given comment. 162 | * 163 | * @param int $comment_id The ID of the comment being saved. 164 | * 165 | * @return void We don't need the return value. 166 | */ 167 | public function save_meta_box( int $comment_id ): void { 168 | if ( ! isset( $_POST[ $this->nonce ] ) ) { 169 | return; 170 | } 171 | 172 | $nonce = sanitize_text_field( wp_unslash( $_POST[ $this->nonce ] ) ); 173 | 174 | if ( ! wp_verify_nonce( $nonce, $this->id ) ) { 175 | return; 176 | } 177 | 178 | $this->set_comment( $comment_id ); 179 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 180 | } 181 | 182 | /** 183 | * Registers metadata for each item in the list of normalized items. 184 | * 185 | * This method iterates over the items, normalizes them, and registers post metadata 186 | * for each post type with detailed settings including type, description, default values, 187 | * and sanitization callbacks. 188 | * 189 | * @return void 190 | */ 191 | public function register_meta(): void { 192 | $items = $this->normalize_items( $this->items ); 193 | 194 | foreach ( $items as $item ) { 195 | register_meta( 196 | 'comment', 197 | $item['id'], 198 | array( 199 | 'type' => $this->custom_fields->get_wp_type( $item ), 200 | 'description' => $item['label'], 201 | 'single' => true, 202 | 'default' => $this->custom_fields->get_default_value( $item ), 203 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 204 | 'show_in_rest' => false, 205 | ), 206 | ); 207 | } 208 | } 209 | 210 | /** 211 | * Renders the comment form with the normalized items, sets the current comment, enqueues necessary scripts, 212 | * prints the application structure, and prints each field. 213 | * 214 | * @param WP_Comment $comment The comment object for which the form is being rendered. 215 | * 216 | * @return void 217 | */ 218 | public function render( WP_Comment $comment ): void { 219 | $items = $this->normalize_items( $this->items ); 220 | 221 | $this->set_comment( $comment->comment_ID ); 222 | $this->enqueue(); 223 | $this->print_app( 'comment', $this->tabs ); 224 | 225 | wp_nonce_field( $this->id, $this->nonce ); 226 | 227 | foreach ( $items as $item ) { 228 | $this->print_field( $item ); 229 | } 230 | } 231 | 232 | /** 233 | * Retrieve the value of a specified option. 234 | * 235 | * @param string $name The name of the option. 236 | * @param mixed $default_value The default value to return if the option is not found. 237 | * 238 | * @return mixed The value of the specified option if it exists, otherwise the default value. 239 | */ 240 | public function get_option_value( string $name, mixed $default_value ): mixed { 241 | return get_comment_meta( $this->comment_id, $name, true ) ?? $default_value; 242 | } 243 | 244 | /** 245 | * Set the value for a specified option. 246 | * 247 | * @param string $name The name of the option. 248 | * @param mixed $value The value to set for the option. 249 | * @param array $item An optional array to specify additional item details. 250 | * 251 | * @return bool True if the option value was updated successfully, false otherwise. 252 | */ 253 | public function set_option_value( string $name, mixed $value, array $item = array() ): bool { 254 | return update_comment_meta( $this->comment_id, $name, $value ); 255 | } 256 | 257 | /** 258 | * Retrieve the item ID. 259 | * 260 | * @return int The ID of the item. 261 | */ 262 | public function get_item_id(): int { 263 | return $this->comment_id; 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/Integrations/BaseIntegration.php: -------------------------------------------------------------------------------- 1 | id; 61 | } 62 | 63 | foreach ( $items as $key => $item ) { 64 | if ( empty( $item['id'] ) && is_string( $key ) ) { 65 | $item['id'] = $key; 66 | } elseif ( empty( $item['id'] ) ) { 67 | $item['id'] = uniqid(); 68 | } 69 | 70 | $next_items[ $item['id'] ] = $this->normalize_item( $item, $global_id ); 71 | } 72 | 73 | return apply_filters( 'wpifycf_items', array_values( $next_items ), $this->id ); 74 | } 75 | 76 | /** 77 | * Normalizes a single item. 78 | * 79 | * @param array $item Item to normalize. 80 | * @param string $global_id A global identifier for the item. 81 | * 82 | * @return array Normalized item. 83 | */ 84 | protected function normalize_item( array $item, string $global_id ): array { 85 | $item['global_id'] = $global_id . '__' . $item['id']; 86 | 87 | if ( isset( $item['custom_attributes'] ) ) { 88 | $item['attributes'] = $item['custom_attributes']; 89 | unset( $item['custom_attributes'] ); 90 | } 91 | 92 | if ( ! empty( $item['title'] ) && empty( $item['label'] ) ) { 93 | $item['label'] = $item['title']; 94 | } 95 | 96 | if ( empty( $item['label'] ) ) { 97 | $item['label'] = ''; 98 | } 99 | 100 | if ( isset( $item['desc'] ) ) { 101 | $item['description'] = $item['desc']; 102 | unset( $item['desc'] ); 103 | } 104 | 105 | if ( ! isset( $item['default'] ) ) { 106 | $item['default'] = $this->custom_fields->get_default_value( $item ); 107 | } 108 | 109 | /* Compatibility with WPify Woo */ 110 | $type_aliases = array( 111 | 'multiswitch' => 'multi_toggle', 112 | 'switch' => 'toggle', 113 | 'multiselect' => 'multi_select', 114 | 'colorpicker' => 'color', 115 | 'gallery' => 'multi_attachment', 116 | 'repeater' => 'multi_group', 117 | ); 118 | 119 | foreach ( $type_aliases as $alias => $correct ) { 120 | if ( $item['type'] === $alias ) { 121 | $item['type'] = $correct; 122 | } 123 | } 124 | 125 | if ( isset( $item['items'] ) ) { 126 | $item['items'] = $this->normalize_items( $item['items'], $item['global_id'] ); 127 | } 128 | 129 | if ( isset( $item['options'] ) ) { 130 | if ( is_callable( $item['options'] ) && isset( $item['async'] ) && false === $item['async'] ) { 131 | $item['options'] = $this->normalize_options( $item['options']() ); 132 | } elseif ( is_callable( $item['options'] ) ) { 133 | if ( empty( $item['options_key'] ) ) { 134 | $item['options_key'] = $item['global_id']; 135 | } 136 | 137 | $item['options_callback'] = $item['options']; 138 | $item['options'] = array(); 139 | $item['async'] = true; 140 | } elseif ( is_array( $item['options'] ) ) { 141 | $item['options'] = $this->normalize_options( $item['options'] ); 142 | } 143 | } 144 | 145 | return $item; 146 | } 147 | 148 | /** 149 | * Normalizes an array of options. 150 | * 151 | * @param array $options Options to normalize. 152 | * 153 | * @return array Normalized options. 154 | */ 155 | public function normalize_options( array $options ): array { 156 | $next_options = array(); 157 | 158 | foreach ( $options as $key => $value ) { 159 | if ( is_array( $value ) && isset( $value['label'] ) && isset( $value['value'] ) ) { 160 | $next_options[] = array( 161 | ...$value, 162 | 'value' => strval( $value['value'] ), 163 | ); 164 | } elseif ( is_string( $value ) ) { 165 | $next_options[] = array( 166 | 'label' => $value, 167 | 'value' => strval( $key ), 168 | ); 169 | } 170 | } 171 | 172 | return $next_options; 173 | } 174 | 175 | /** 176 | * Enqueues scripts and styles necessary for the integration. 177 | * 178 | * Only enqueues on the admin side. 179 | */ 180 | public function enqueue(): void { 181 | if ( ! is_admin() ) { 182 | return; 183 | } 184 | 185 | $handle = $this->custom_fields->get_script_handle(); 186 | $js = $this->custom_fields->get_js_asset( 'wpify-custom-fields' ); 187 | $data = array( 188 | 'instance' => $this->custom_fields->get_script_handle(), 189 | 'stylesheet' => $this->custom_fields->get_css_asset( 'wpify-custom-fields' ), 190 | 'api_path' => $this->custom_fields->api->get_rest_namespace(), 191 | 'abspath' => ABSPATH, 192 | 'site_url' => get_site_url(), 193 | ); 194 | 195 | // Dependencies for WYSIWYG field. 196 | wp_enqueue_editor(); 197 | wp_tinymce_inline_scripts(); 198 | 199 | $current_screen = get_current_screen(); 200 | 201 | if ( ! $current_screen || ! $current_screen->is_block_editor() ) { 202 | // Dependencies for WYSIWYG field. 203 | wp_enqueue_script( 'wp-block-library' ); 204 | 205 | // Dependencies for Attachment field. 206 | wp_enqueue_media(); 207 | 208 | // Dependencies for Toggle field. 209 | wp_enqueue_style( 'wp-components' ); 210 | } 211 | 212 | wp_enqueue_script( 213 | $handle, 214 | $js['src'], 215 | $js['dependencies'], 216 | $js['version'], 217 | array( 'in_footer' => false ), 218 | ); 219 | 220 | wp_add_inline_script( 221 | $handle, 222 | 'window.wpifycf=' . wp_json_encode( $data ) . ';', 223 | 'before', 224 | ); 225 | } 226 | 227 | /** 228 | * Registers REST API for options recursively. 229 | */ 230 | public function register_rest_options(): void { 231 | $items = $this->normalize_items( $this->items ); 232 | 233 | $this->register_options_routes( $items ); 234 | } 235 | 236 | /** 237 | * Registers option routes for an array of items. 238 | * 239 | * @param array $items Array of items to register routes for. 240 | */ 241 | public function register_options_routes( array $items = array() ): void { 242 | foreach ( $items as $item ) { 243 | $this->register_options_route( $item ); 244 | } 245 | } 246 | 247 | /** 248 | * Registers a REST API route for a single item. 249 | * 250 | * @param array $item Item for which to register the route. 251 | */ 252 | public function register_options_route( array $item ): void { 253 | if ( ! empty( $item['options_key'] ) ) { 254 | $this->custom_fields->api->register_rest_route( 255 | 'options/' . $item['options_key'], 256 | WP_REST_Server::READABLE, 257 | function ( WP_REST_Request $request ) use ( $item ) { 258 | $options = $item['options_callback']( $request->get_params() ); 259 | 260 | if ( is_array( $options ) ) { 261 | return $this->normalize_options( $options ); 262 | } 263 | 264 | return array(); 265 | }, 266 | ); 267 | } elseif ( ! empty( $item['items'] ) ) { 268 | $this->register_options_routes( $item['items'] ); 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/Integrations/OptionsIntegration.php: -------------------------------------------------------------------------------- 1 | id . '__' . $loop : $this->id; 32 | ?> 33 |
$value ) { 41 | printf( ' data-%s="%s"', esc_attr( $key ), esc_attr( $value ) ); 42 | } 43 | ?> 44 | >
45 | option_name ) ) { 58 | $name = $item['id']; 59 | 60 | if ( isset( $data_attributes['loop'] ) ) { 61 | $name .= '[' . $data_attributes['loop'] . ']'; 62 | } 63 | } else { 64 | $name = $this->option_name; 65 | 66 | if ( isset( $data_attributes['loop'] ) ) { 67 | $name .= '[' . $data_attributes['loop'] . ']'; 68 | } 69 | 70 | $name .= '[' . $item['id'] . ']'; 71 | } 72 | 73 | $item['name'] = $name; 74 | $item['value'] = $this->get_field( $item['id'], $item ); 75 | $item['loop'] = $data_attributes['loop'] ?? ''; 76 | $integration_id = isset( $data_attributes['loop'] ) ? $this->id . '__' . $data_attributes['loop'] : $this->id; 77 | ?> 78 | < 79 | data-item="custom_fields->helpers->json_encode( $item ) ); ?>" 80 | data-integration-id="" 81 | data-instance="custom_fields->get_script_handle() ); ?>" 82 | class="wpifycf-field-instance wpifycf-field-instance--custom_fields->get_script_handle() ); ?>" 83 | $value ) { 85 | printf( ' data-%s="%s"', esc_attr( $key ), esc_attr( $value ) ); 86 | } 87 | ?> 88 | >> 89 | option_name ) ) { 104 | $data = $this->get_option_value( $this->option_name, array() ); 105 | 106 | return $data[ $name ] ?? $this->custom_fields->get_default_value( $item ); 107 | } else { 108 | return $this->get_option_value( $name, $this->custom_fields->get_default_value( $item ) ); 109 | } 110 | } 111 | 112 | /** 113 | * Sets the field value for a given name and item. 114 | * 115 | * @param string $name Field name. 116 | * @param mixed $value Field value. 117 | * @param array $item Optional. Field item data. 118 | */ 119 | public function set_field( string $name, mixed $value, array $item = array() ): mixed { 120 | if ( isset( $item['callback_set'] ) && is_callable( $item['callback_set'] ) ) { 121 | return call_user_func( $item['callback_set'], $item, $value ); 122 | } elseif ( ! empty( $this->option_name ) ) { 123 | $data = $this->get_option_value( $this->option_name, array() ); 124 | $data[ $name ] = $value; 125 | 126 | return $this->set_option_value( $this->option_name, $data ); 127 | } else { 128 | return $this->set_option_value( $name, $value ); 129 | } 130 | } 131 | 132 | /** 133 | * Sets fields for a given option name using sanitized values and item definitions. 134 | * 135 | * @param string $option_name The name of the option to set. 136 | * @param array $sanitized_values An array of sanitized values keyed by item IDs. 137 | * @param array $items An array of items where each item contains an 'id' and optionally a 'callback_set'. 138 | * 139 | * @return void 140 | */ 141 | public function set_fields( string $option_name, array $sanitized_values, array $items ): void { 142 | $data = array(); 143 | 144 | foreach ( $items as $item ) { 145 | if ( isset( $sanitized_values[ $item['id'] ] ) ) { 146 | if ( isset( $item['callback_set'] ) && is_callable( $item['callback_set'] ) ) { 147 | $data[ $item['id'] ] = call_user_func( $item['callback_set'], $item, $sanitized_values[ $item['id'] ] ); 148 | } else { 149 | $data[ $item['id'] ] = $sanitized_values[ $item['id'] ]; 150 | } 151 | } 152 | } 153 | 154 | $this->set_option_value( $option_name, $data ); 155 | } 156 | 157 | /** 158 | * Retrieves the value of a given option. 159 | * 160 | * @param string $name The name of the option to retrieve. 161 | * @param mixed $default_value The default value to return if the option is not set. 162 | * 163 | * @return mixed The value of the option or the default value if not set. 164 | */ 165 | abstract public function get_option_value( string $name, mixed $default_value ): mixed; 166 | 167 | /** 168 | * Sets the value of an option. 169 | * 170 | * @param string $name The name of the option to set. 171 | * @param mixed $value The value to assign to the option. 172 | */ 173 | abstract public function set_option_value( string $name, mixed $value ); 174 | 175 | /** 176 | * Set fields from $_POST request. 177 | * 178 | * @param array $items An array of items where each item contains an 'id' and optionally a 'callback_set'. 179 | * @param mixed|null $loop_id Optional. The loop ID to use for the fields. 180 | */ 181 | public function set_fields_from_post_request( array $items, mixed $loop_id = null ): void { 182 | // Sanitization is done via custom sanitizer. 183 | // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized 184 | 185 | // Nonce verification not needed here, is verified by caller. 186 | // phpcs:disable WordPress.Security.NonceVerification.Missing 187 | 188 | if ( ! empty( $this->option_name ) ) { 189 | if ( is_null( $loop_id ) ) { 190 | $post_data = isset( $_POST[ $this->option_name ] ) ? wp_unslash( $_POST[ $this->option_name ] ) : array(); 191 | } else { 192 | $post_data = isset( $_POST[ $this->option_name ][ $loop_id ] ) ? wp_unslash( $_POST[ $this->option_name ][ $loop_id ] ) : array(); 193 | } 194 | 195 | $this->set_fields( 196 | $this->option_name, 197 | $this->custom_fields->sanitize_option_value( $items )( $post_data ), 198 | $items 199 | ); 200 | } else { 201 | foreach ( $items as $item ) { 202 | $wp_type = $this->custom_fields->get_wp_type( $item ); 203 | 204 | if ( is_null( $loop_id ) ) { 205 | if ( ! isset( $_POST[ $item['id'] ] ) ) { 206 | continue; 207 | } 208 | 209 | $value = wp_unslash( $_POST[ $item['id'] ] ); 210 | } else { 211 | if ( ! isset( $_POST[ $item['id'] ][ $loop_id ] ) ) { 212 | continue; 213 | } 214 | 215 | $value = wp_unslash( $_POST[ $item['id'] ][ $loop_id ] ); 216 | } 217 | 218 | if ( 'string' !== $wp_type ) { 219 | $value = json_decode( $value, ARRAY_A ); 220 | } 221 | 222 | $this->set_field( 223 | $item['id'], 224 | $this->custom_fields->sanitize_item_value( $item )( $value ), 225 | $item 226 | ); 227 | } 228 | } 229 | // phpcs:enable 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/Integrations/Metabox.php: -------------------------------------------------------------------------------- 1 | title = $args['title'] ?? ''; 128 | $this->screen = $args['screen'] ?? null; 129 | $this->context = $args['context'] ?? self::CONTEXT_NORMAL; 130 | $this->priority = $args['priority'] ?? self::PRIORITY_DEFAULT; 131 | $this->callback_args = $args['callback_args'] ?? array(); 132 | $this->id = $args['id'] ?? sanitize_title( 133 | join( 134 | '_', 135 | array( 136 | $this->title, 137 | $this->screen, 138 | $this->context, 139 | $this->priority, 140 | ), 141 | ), 142 | ); 143 | $this->nonce = $this->id . '_nonce'; 144 | $this->option_name = $args['meta_key'] ?? ''; 145 | $this->items = $args['items'] ?? array(); 146 | $this->post_types = $args['post_types'] ?? array(); 147 | $this->tabs = $args['tabs'] ?? array(); 148 | 149 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 150 | add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); 151 | add_action( 'save_post', array( $this, 'save_meta_box' ), 10, 2 ); 152 | add_action( 'init', array( $this, 'register_meta' ) ); 153 | } 154 | } 155 | 156 | /** 157 | * Adds a meta box to the specified post type. 158 | * 159 | * @param string $post_type The post type to which the meta box should be added. 160 | * 161 | * @return void 162 | */ 163 | public function add_meta_box( string $post_type ): void { 164 | if ( in_array( $post_type, $this->post_types, true ) ) { 165 | add_meta_box( 166 | $this->id, 167 | $this->title, 168 | array( $this, 'render' ), 169 | $post_type, 170 | $this->context, 171 | $this->priority, 172 | $this->callback_args, 173 | ); 174 | } 175 | } 176 | 177 | /** 178 | * Sets the post property if it is not already set. 179 | * 180 | * @param WP_Post $post The post object to set. 181 | * 182 | * @return void 183 | */ 184 | public function set_post( WP_Post $post ): void { 185 | if ( empty( $this->post ) ) { 186 | $this->post = $post; 187 | } 188 | } 189 | 190 | /** 191 | * Saves the meta box associated with a given post. 192 | * 193 | * @param int $post_id The ID of the post to save the meta box for. 194 | * @param WP_Post $post The post object being saved. 195 | * 196 | * @return void Result not needed; 197 | */ 198 | public function save_meta_box( int $post_id, WP_Post $post ): void { 199 | if ( ! isset( $_POST[ $this->nonce ] ) ) { 200 | return; 201 | } 202 | 203 | $nonce = sanitize_text_field( wp_unslash( $_POST[ $this->nonce ] ) ); 204 | 205 | if ( ! wp_verify_nonce( $nonce, $this->id ) ) { 206 | return; 207 | } 208 | 209 | if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { 210 | return; 211 | } 212 | 213 | $post_id = $_POST['ID'] ?? $_POST['post_ID'] ?? $post_id; 214 | $post = get_post( $post_id ); 215 | 216 | if ( isset( $_POST['post_type'] ) && ! in_array( $_POST['post_type'], $this->post_types, true ) ) { 217 | return; 218 | } 219 | 220 | if ( ! current_user_can( 'edit_post', $post_id ) ) { 221 | return; 222 | } 223 | 224 | $this->set_post( $post ); 225 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 226 | } 227 | 228 | /** 229 | * Registers meta data for specified post types. 230 | * 231 | * This method normalizes items and registers each item as post meta for the 232 | * defined post types. Each item will have its type, description, default 233 | * value, and sanitize callback configured. 234 | * 235 | * @return void 236 | */ 237 | public function register_meta(): void { 238 | $items = $this->normalize_items( $this->items ); 239 | 240 | foreach ( $this->post_types as $post_type ) { 241 | foreach ( $items as $item ) { 242 | register_post_meta( 243 | $post_type, 244 | $item['id'], 245 | array( 246 | 'type' => $this->custom_fields->get_wp_type( $item ), 247 | 'description' => $item['label'], 248 | 'single' => true, 249 | 'default' => $this->custom_fields->get_default_value( $item ), 250 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 251 | 'show_in_rest' => false, 252 | ), 253 | ); 254 | } 255 | add_action( 'wpifycf_register_post_meta', $items, $post_type ); 256 | } 257 | } 258 | 259 | /** 260 | * Renders the metabox for a given post with the necessary components. 261 | * 262 | * @param WP_Post $post The post object for which the metabox is being rendered. 263 | * 264 | * @return void 265 | */ 266 | public function render( WP_Post $post ): void { 267 | $items = $this->normalize_items( $this->items ); 268 | 269 | $this->set_post( $post ); 270 | $this->enqueue(); 271 | $this->print_app( 'metabox', $this->tabs ); 272 | 273 | wp_nonce_field( $this->id, $this->nonce ); 274 | 275 | foreach ( $items as $item ) { 276 | $this->print_field( $item ); 277 | } 278 | } 279 | 280 | /** 281 | * Retrieves the value of a specified option for the current item. 282 | * 283 | * @param string $name The name of the option to retrieve. 284 | * @param mixed $default_value The default value to return if the option is not found. 285 | * 286 | * @return mixed The value of the specified option, or the default value if the option does not exist. 287 | */ 288 | public function get_option_value( string $name, mixed $default_value ): mixed { 289 | return get_post_meta( $this->get_item_id(), $name, true ) ?? $default_value; 290 | } 291 | 292 | /** 293 | * Sets the option value for a given item. 294 | * 295 | * @param string $name The name of the option to set. 296 | * @param mixed $value The value to set for the option. 297 | * 298 | * @return bool True on success, false on failure. 299 | */ 300 | public function set_option_value( string $name, mixed $value ): bool { 301 | return update_post_meta( $this->get_item_id(), $name, $value ); 302 | } 303 | 304 | /** 305 | * Retrieves the ID of the current post. 306 | * 307 | * @return int The ID of the current post. 308 | */ 309 | public function get_item_id(): int { 310 | return $this->post->ID; 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /src/Integrations/WooCommerceSettings.php: -------------------------------------------------------------------------------- 1 | tab = $args['tab'] ?? array(); 91 | $this->section = $args['section'] ?? array(); 92 | $this->items = $args['items'] ?? array(); 93 | $this->tabs = $args['tabs'] ?? array(); 94 | $this->option_name = $args['option_name'] ?? ''; 95 | 96 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 97 | $this->display = $args['display']; 98 | } else { 99 | $this->display = function () use ( $args ) { 100 | return $args['display'] ?? true; 101 | }; 102 | } 103 | 104 | $this->id = $args['id'] ?? join( 105 | '_', 106 | array( 107 | 'wc_settings_', 108 | $this->tabs['id'] ?? '', 109 | $this->section['id'] ?? '', 110 | wp_generate_uuid4(), 111 | ), 112 | ); 113 | 114 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 115 | add_filter( 'woocommerce_settings_tabs_array', array( $this, 'woocommerce_settings_tabs_array' ), 30 ); 116 | add_filter( 'woocommerce_get_sections_' . $this->tab['id'], array( $this, 'woocommerce_get_sections' ) ); 117 | add_action( 'woocommerce_settings_' . $this->tab['id'], array( $this, 'render' ), 11 ); 118 | add_action( 'woocommerce_settings_save_' . $this->tab['id'], array( $this, 'save' ) ); 119 | 120 | // Nonce verification is not needed here, we are just setting a current tab, section and displaying message. 121 | // phpcs:disable WordPress.Security.NonceVerification.Recommended 122 | $tab = sanitize_text_field( wp_unslash( $_REQUEST['tab'] ?? '' ) ); 123 | $section = sanitize_text_field( wp_unslash( $_REQUEST['section'] ?? '' ) ); 124 | $settings_updated = sanitize_text_field( wp_unslash( $_REQUEST['settings-updated'] ?? '' ) ); 125 | // phpcs:enable 126 | 127 | if ( $tab === $this->tab['id'] && $section === $this->section['id'] && '1' === $settings_updated ) { 128 | WC_Admin_Settings::add_message( __( 'Your settings have been saved.', 'wpify-custom-fields' ) ); 129 | } 130 | } 131 | } 132 | 133 | /** 134 | * Filters the WooCommerce settings tabs array to add a custom tab if necessary. 135 | * 136 | * @param array $tabs The existing array of WooCommerce settings tabs. 137 | * 138 | * @return array The modified array of WooCommerce settings tabs. 139 | */ 140 | public function woocommerce_settings_tabs_array( array $tabs ): array { 141 | $display_callback = $this->display; 142 | 143 | if ( ! $display_callback() ) { 144 | return $tabs; 145 | } 146 | 147 | if ( empty( $tabs[ $this->tab['id'] ] ) ) { 148 | $tabs[ $this->tab['id'] ] = $this->tab['label']; 149 | $this->is_new_tab = true; 150 | } 151 | 152 | return $tabs; 153 | } 154 | 155 | /** 156 | * Adds or updates WooCommerce sections based on the instance configuration. 157 | * 158 | * @param array $sections An array of existing sections. 159 | * 160 | * @return array Modified array of sections. 161 | */ 162 | public function woocommerce_get_sections( array $sections ): array { 163 | $display_callback = $this->display; 164 | 165 | if ( ! $display_callback() ) { 166 | return $sections; 167 | } 168 | 169 | if ( ! empty( $this->section ) ) { 170 | $sections[ $this->section['id'] ] = $this->section['label']; 171 | } 172 | 173 | if ( isset( $sections[''] ) ) { 174 | $empty_section = $sections['']; 175 | $reordered_sections = array( '' => $empty_section ); 176 | unset( $sections[''] ); 177 | 178 | foreach ( $sections as $id => $label ) { 179 | $reordered_sections[ $id ] = $label; 180 | } 181 | 182 | $sections = $reordered_sections; 183 | } 184 | 185 | return $sections; 186 | } 187 | 188 | /** 189 | * Render the settings page or section. 190 | * 191 | * @return void 192 | */ 193 | public function render(): void { 194 | global $current_section; 195 | 196 | $display_callback = $this->display; 197 | 198 | if ( ! $display_callback() ) { 199 | return; 200 | } 201 | 202 | if ( $this->is_new_tab ) { 203 | // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound 204 | $sections = apply_filters( 'woocommerce_get_sections_' . $this->tab['id'], array() ); 205 | 206 | if ( is_array( $sections ) && count( $sections ) > 1 ) { 207 | $array_keys = array_keys( $sections ); 208 | ?> 209 | 233 |
234 | section['id'] ) { 239 | return; 240 | } 241 | 242 | $this->enqueue(); 243 | $this->print_app( 'woocommerce-options', $this->tabs ); 244 | 245 | $items = $this->normalize_items( $this->items ); 246 | 247 | foreach ( $items as $item ) { 248 | $this->print_field( $item ); 249 | } 250 | } 251 | 252 | /** 253 | * Save the settings for the specified tab and section. 254 | * 255 | * @return void 256 | */ 257 | public function save(): void { 258 | // Nonce verification is not needed here, nonce already verified in WooCommerce. 259 | // phpcs:disable WordPress.Security.NonceVerification.Recommended 260 | $tab = sanitize_text_field( wp_unslash( $_REQUEST['tab'] ?? '' ) ); 261 | $section = sanitize_text_field( wp_unslash( $_REQUEST['section'] ?? '' ) ); 262 | // phpcs:enable 263 | 264 | if ( $tab === $this->tab['id'] && $section === $this->section['id'] ) { 265 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 266 | wp_safe_redirect( 267 | add_query_arg( 268 | array( 269 | 'page' => 'wc-settings', 270 | 'tab' => $this->tab['id'], 271 | 'section' => $this->section['id'], 272 | 'settings-updated' => true, 273 | ), 274 | admin_url( 'admin.php' ), 275 | ), 276 | ); 277 | 278 | exit; 279 | } 280 | } 281 | 282 | /** 283 | * Retrieves the value of the specified option. 284 | * 285 | * @param string $name The name of the option to retrieve. 286 | * @param mixed $default_value The default value to return if the option does not exist. 287 | * 288 | * @return mixed The value of the option or the default value if the option does not exist. 289 | */ 290 | public function get_option_value( string $name, mixed $default_value ): mixed { 291 | return get_option( $name, $default_value ); 292 | } 293 | 294 | /** 295 | * Sets the value of the specified option. 296 | * 297 | * @param string $name The name of the option to set. 298 | * @param mixed $value The value to set for the option. 299 | * 300 | * @return bool True on success, false on failure. 301 | */ 302 | public function set_option_value( string $name, mixed $value ): bool { 303 | return update_option( $name, $value ); 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/Integrations/OrderMetabox.php: -------------------------------------------------------------------------------- 1 | 0 ) { 145 | throw new MissingArgumentException( 146 | sprintf( 147 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 148 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 149 | esc_html( implode( ', ', $missing ) ), 150 | __CLASS__, 151 | ), 152 | ); 153 | } 154 | 155 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 156 | $this->display = $args['display']; 157 | } else { 158 | $this->display = function () use ( $args ) { 159 | return $args['display']; 160 | }; 161 | } 162 | 163 | $this->title = $args['title'] ?? ''; 164 | $this->context = $args['context'] ?? 'advanced'; 165 | $this->hook_priority = 10; 166 | $this->priority = $args['priority'] ?? 'default'; 167 | $this->option_name = $args['meta_key'] ?? ''; 168 | $this->items = $args['items'] ?? array(); 169 | $this->capability = $args['capability'] ?? 'manage_woocommerce'; 170 | $this->callback = $args['callback'] ?? null; 171 | $this->tabs = $args['tabs'] ?? array(); 172 | $this->id = sanitize_title( 173 | join( 174 | '-', 175 | array( 176 | 'order-meta', 177 | $this->hook_priority, 178 | $this->title, 179 | ), 180 | ), 181 | ); 182 | $this->nonce = $this->id . '-nonce'; 183 | 184 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 185 | add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); 186 | add_action( 'woocommerce_update_order', array( $this, 'save' ) ); 187 | } 188 | } 189 | 190 | 191 | /** 192 | * Adds a meta box to the specified post type. 193 | * 194 | * @param string $post_type The post type to which the meta box will be added. 195 | * 196 | * @return void 197 | */ 198 | public function add_meta_box( string $post_type ): void { 199 | if ( ! $this->display || ! function_exists( 'wc_get_container' ) || ! function_exists( 'wc_get_page_screen_id' ) ) { 200 | return; 201 | } 202 | 203 | $screen = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() 204 | ? wc_get_page_screen_id( 'shop-order' ) 205 | : 'shop_order'; 206 | 207 | if ( $screen !== $post_type ) { 208 | return; 209 | } 210 | 211 | add_meta_box( 212 | $this->id, 213 | $this->title, 214 | array( $this, 'render' ), 215 | $screen, 216 | $this->context, 217 | $this->priority, 218 | ); 219 | } 220 | 221 | /** 222 | * Renders the order meta and associated items. 223 | * 224 | * @param WP_Post|WC_Abstract_Order $post Post object. 225 | * 226 | * @return void 227 | */ 228 | public function render( WP_Post|WC_Abstract_Order $post ): void { 229 | if ( ! current_user_can( $this->capability ) ) { 230 | return; 231 | } 232 | 233 | $id = is_a( $post, 'WC_Abstract_Order' ) ? $post->get_id() : $post->ID; 234 | $order = wc_get_order( $id ); 235 | $this->order_id = $order->get_id(); 236 | $this->enqueue(); 237 | 238 | $items = $this->normalize_items( $this->items ); 239 | 240 | if ( is_callable( $this->callback ) ) { 241 | call_user_func( $this->callback ); 242 | } 243 | 244 | wp_nonce_field( $this->id, $this->nonce ); 245 | $this->print_app( 'order-meta', $this->tabs ); 246 | 247 | foreach ( $items as $item ) { 248 | ?> 249 |
250 | print_field( $item ); ?> 251 |
252 | order_id ); 267 | } 268 | 269 | /** 270 | * Saves the order metadata and updates the order items. 271 | * 272 | * @param int $post_id The post ID of the order being saved. 273 | * 274 | * @return void Result not needed. 275 | */ 276 | public function save( int $post_id ): void { 277 | remove_action( 'woocommerce_update_order', array( $this, 'save' ) ); 278 | 279 | if ( ! isset( $_POST[ $this->nonce ] ) ) { 280 | return; 281 | } 282 | 283 | $nonce = sanitize_text_field( wp_unslash( $_POST[ $this->nonce ] ) ); 284 | 285 | if ( ! wp_verify_nonce( $nonce, $this->id ) ) { 286 | return; 287 | } 288 | 289 | if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { 290 | return; 291 | } 292 | 293 | $this->order_id = $post_id; 294 | 295 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 296 | } 297 | 298 | /** 299 | * Retrieves the value of a specified option from the order metadata. 300 | * 301 | * @param string $name The name of the option whose value is to be retrieved. 302 | * @param mixed $default_value The default value to return if the option is not found. 303 | * 304 | * @return mixed The value of the specified option, or the default value if the option is not found. 305 | */ 306 | public function get_option_value( string $name, mixed $default_value ): mixed { 307 | return $this->get_order()->get_meta( $name ) ?? $default_value; 308 | } 309 | 310 | /** 311 | * Sets the value of a specified option in the order metadata. 312 | * 313 | * @param string $name The name of the option to set. 314 | * @param mixed $value The value to assign to the specified option. 315 | * 316 | * @return WC_Order|int Returns true if the metadata was successfully saved, false otherwise. 317 | */ 318 | public function set_option_value( string $name, mixed $value ): WC_Order|int { 319 | $order = $this->get_order(); 320 | $order->update_meta_data( $name, $value ); 321 | 322 | return $order->save(); 323 | } 324 | 325 | /** 326 | * Retrieves the item ID associated with the order. 327 | * 328 | * @return int The item ID. 329 | */ 330 | public function get_item_id(): int { 331 | return $this->order_id; 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/Integrations/SubscriptionMetabox.php: -------------------------------------------------------------------------------- 1 | 0 ) { 157 | throw new MissingArgumentException( 158 | sprintf( 159 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 160 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 161 | esc_html( implode( ', ', $missing ) ), 162 | __CLASS__, 163 | ), 164 | ); 165 | } 166 | 167 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 168 | $this->display = $args['display']; 169 | } else { 170 | $this->display = function () use ( $args ) { 171 | return $args['display']; 172 | }; 173 | } 174 | 175 | $this->title = $args['title'] ?? ''; 176 | $this->context = $args['context'] ?? 'advanced'; 177 | $this->hook_priority = 10; 178 | $this->priority = $args['priority'] ?? 'default'; 179 | $this->option_name = $args['meta_key'] ?? ''; 180 | $this->items = $args['items'] ?? array(); 181 | $this->capability = $args['capability'] ?? 'manage_woocommerce'; 182 | $this->callback = $args['callback'] ?? null; 183 | $this->tabs = $args['tabs'] ?? array(); 184 | $this->id = sanitize_title( 185 | join( 186 | '-', 187 | array( 188 | 'order-meta', 189 | $this->hook_priority, 190 | $this->title, 191 | ), 192 | ), 193 | ); 194 | $this->nonce = $this->id . '-nonce'; 195 | 196 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 197 | add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ) ); 198 | add_action( 'woocommerce_update_order', array( $this, 'save' ) ); 199 | } 200 | } 201 | 202 | /** 203 | * Adds a meta box to the specified post type screen. 204 | * 205 | * @param string $post_type The post type to which the meta box is added. 206 | * 207 | * @return void 208 | */ 209 | public function add_meta_box( string $post_type ): void { 210 | if ( ! $this->display || ! function_exists( 'wc_get_container' ) || ! function_exists( 'wc_get_page_screen_id' ) ) { 211 | return; 212 | } 213 | 214 | $screen = wc_get_container()->get( CustomOrdersTableController::class )->custom_orders_table_usage_is_enabled() 215 | ? wc_get_page_screen_id( 'shop-subscription' ) 216 | : 'shop_subscription'; 217 | 218 | if ( $screen !== $post_type ) { 219 | return; 220 | } 221 | 222 | add_meta_box( 223 | $this->id, 224 | $this->title, 225 | array( $this, 'render' ), 226 | $screen, 227 | $this->context, 228 | $this->priority, 229 | ); 230 | } 231 | 232 | /** 233 | * Renders the order meta details. 234 | * 235 | * @param WC_Order $order The order object containing order details. 236 | * 237 | * @return void 238 | */ 239 | public function render( WC_Order $order ): void { 240 | if ( ! current_user_can( $this->capability ) ) { 241 | return; 242 | } 243 | 244 | $this->order_id = $order->get_id(); 245 | $this->enqueue(); 246 | 247 | $items = $this->normalize_items( $this->items ); 248 | 249 | if ( is_callable( $this->callback ) ) { 250 | call_user_func( $this->callback ); 251 | } 252 | 253 | wp_nonce_field( $this->id, $this->nonce ); 254 | $this->print_app( 'order-meta', $this->tabs ); 255 | 256 | foreach ( $items as $item ) { 257 | ?> 258 |
259 | print_field( $item ); ?> 260 |
261 | get_item_id() ); 277 | } 278 | 279 | /** 280 | * Saves the order details. 281 | * 282 | * @param int $post_id The ID of the post (order) being saved. 283 | * 284 | * @return void 285 | */ 286 | public function save( int $post_id ): void { 287 | remove_action( 'woocommerce_update_order', array( $this, 'save' ) ); 288 | 289 | if ( ! isset( $_POST[ $this->nonce ] ) ) { 290 | return; 291 | } 292 | 293 | $nonce = sanitize_text_field( wp_unslash( $_POST[ $this->nonce ] ) ); 294 | 295 | if ( ! wp_verify_nonce( $nonce, $this->id ) ) { 296 | return; 297 | } 298 | 299 | if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { 300 | return; 301 | } 302 | 303 | $this->order_id = $post_id; 304 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 305 | } 306 | 307 | /** 308 | * Retrieves the value of a specified option. 309 | * 310 | * @param string $name The name of the option to retrieve. 311 | * @param mixed $default_value The default value to return if the option is not found. 312 | * 313 | * @return mixed The value of the specified option, or the default value if the option is not found. 314 | */ 315 | public function get_option_value( string $name, mixed $default_value ): mixed { 316 | return $this->get_order()->get_meta( $name ) ?? $default_value; 317 | } 318 | 319 | /** 320 | * Sets the value of a specified option. 321 | * 322 | * @param string $name The name of the option to set. 323 | * @param mixed $value The value to set for the specified option. 324 | * 325 | * @return bool True on success, false on failure. 326 | */ 327 | public function set_option_value( string $name, mixed $value ): bool { 328 | $order = $this->get_order(); 329 | $order->update_meta_data( $name, $value ); 330 | 331 | return $order->save(); 332 | } 333 | 334 | /** 335 | * Retrieves the item ID. 336 | * 337 | * @return int The ID of the item. 338 | */ 339 | public function get_item_id(): int { 340 | return $this->order_id; 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /src/Helpers.php: -------------------------------------------------------------------------------- 1 | (.*?)<\/title>/is', $body, $matches ); 37 | 38 | if ( isset( $matches[1] ) ) { 39 | return trim( $matches[1] ); 40 | } 41 | 42 | return ''; 43 | } 44 | 45 | /** 46 | * Retrieves posts based on specified arguments, ensuring some posts are included and others excluded. 47 | * 48 | * @param array $args Arguments for get_posts. 49 | * 50 | * @return array An array of post data arrays. 51 | */ 52 | public function get_posts( array $args = array() ): array { 53 | unset( $args['_locale'] ); 54 | 55 | if ( empty( $args['numberposts'] ) ) { 56 | $args['numberposts'] = 50; 57 | } 58 | 59 | if ( empty( $args['post_status'] ) ) { 60 | $args['post_status'] = 'any'; 61 | } 62 | 63 | $posts = array(); 64 | $ensured_posts = array(); 65 | $added_posts = array(); 66 | 67 | if ( ! empty( $args['exclude'] ) && is_array( $args['exclude'] ) ) { 68 | $exclude = array_values( array_filter( array_map( 'intval', $args['exclude'] ) ) ); 69 | } else { 70 | $exclude = array(); 71 | } 72 | 73 | unset( $args['exclude'] ); 74 | 75 | if ( ! empty( $args['ensure'] ) && is_array( $args['ensure'] ) ) { 76 | $ensured_posts = get_posts( 77 | array( 78 | ...$args, 79 | 'include' => array_values( array_filter( array_map( 'intval', $args['ensure'] ) ) ), 80 | ), 81 | ); 82 | } 83 | 84 | unset( $args['ensure'] ); 85 | 86 | if ( ! empty( $args['s'] ) ) { 87 | $args['orderby'] = 'relevance'; 88 | $args['order'] = 'DESC'; 89 | } 90 | 91 | $raw_posts = get_posts( 92 | array( 93 | ...$args, 94 | 'limit' => $args['numberposts'] + count( $ensured_posts ) + count( $exclude ), 95 | ), 96 | ); 97 | 98 | foreach ( $ensured_posts as $post ) { 99 | $posts[] = $post; 100 | $added_posts[] = $post->ID; 101 | } 102 | 103 | foreach ( $raw_posts as $post ) { 104 | if ( in_array( $post->ID, $exclude, true ) || in_array( $post->ID, $added_posts, true ) || count( $posts ) >= $args['numberposts'] ) { 105 | continue; 106 | } 107 | 108 | $posts[] = $post; 109 | } 110 | 111 | $placeholder = plugin_dir_url( __DIR__ ) . 'assets/images/placeholder-image.svg'; 112 | 113 | return array_map( 114 | fn( WP_Post $post ) => array( 115 | 'id' => $post->ID, 116 | 'title' => $post->post_title, 117 | 'post_type' => $post->post_type, 118 | 'post_status' => $post->post_status, 119 | 'post_status_label' => get_post_status_object( $post->post_status )->label, 120 | 'permalink' => get_permalink( $post ), 121 | 'thumbnail' => get_the_post_thumbnail_url( $post ) ?? $placeholder, 122 | 'excerpt' => get_the_excerpt( $post ), 123 | ), 124 | $posts, 125 | ); 126 | } 127 | 128 | /** 129 | * Retrieves terms based on the given arguments and arranges them into a hierarchical tree structure. 130 | * 131 | * @param array $args Arguments to be passed to the get_terms function. 132 | * 133 | * @return array An array representing the hierarchical tree of terms. 134 | */ 135 | public function get_terms( array $args ): array { 136 | $terms = get_terms( 137 | array( 138 | 'hide_empty' => false, 139 | ...$args, 140 | ), 141 | ); 142 | 143 | if ( is_wp_error( $terms ) || empty( $terms ) ) { 144 | return array(); 145 | } 146 | 147 | $terms_by_id = array(); 148 | 149 | foreach ( $terms as $term ) { 150 | $terms_by_id[ $term->term_id ] = array( 151 | 'id' => $term->term_id, 152 | 'name' => $term->name, 153 | 'parent' => $term->parent, 154 | ); 155 | } 156 | 157 | $tree = array(); 158 | 159 | foreach ( $terms_by_id as &$term ) { 160 | if ( 0 !== $term['parent'] && isset( $terms_by_id[ $term['parent'] ] ) ) { 161 | $parent =& $terms_by_id[ $term['parent'] ]; 162 | 163 | if ( ! isset( $parent['children'] ) ) { 164 | $parent['children'] = array(); 165 | } 166 | 167 | $parent['children'][] =& $term; 168 | } else { 169 | $tree[] =& $term; 170 | } 171 | } 172 | 173 | unset( $term ); 174 | 175 | return $tree; 176 | } 177 | 178 | /** 179 | * Encodes data as JSON, ensuring that all characters are properly escaped. 180 | * 181 | * This function is a wrapper around wp_json_encode with additional flags. The following flags are used: 182 | * - JSON_HEX_TAG 183 | * - JSON_HEX_APOS 184 | * - JSON_HEX_QUOT 185 | * - JSON_HEX_AMP 186 | * - JSON_UNESCAPED_UNICODE 187 | * 188 | * @param mixed $data The data to encode as JSON. 189 | * 190 | * @return string 191 | */ 192 | public function json_encode( $data ): string { 193 | $json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE ); 194 | 195 | return $json ? $json : ''; 196 | } 197 | 198 | /** 199 | * Gets the temporary directory for direct file uploads. 200 | * 201 | * @return string The absolute path to the temp directory. 202 | */ 203 | public function get_direct_file_temp_dir(): string { 204 | $upload_dir = wp_upload_dir(); 205 | return trailingslashit( $upload_dir['basedir'] ) . 'wpifycf-temp'; 206 | } 207 | 208 | /** 209 | * Sanitizes and resolves directory path (relative or absolute). 210 | * 211 | * @param string $directory The directory path from field definition. 212 | * 213 | * @return string The absolute, sanitized directory path. 214 | */ 215 | public function sanitize_directory_path( string $directory ): string { 216 | // Remove any traversal attempts. 217 | $directory = str_replace( array( '../', '..' ), '', $directory ); 218 | 219 | // If path is absolute, use it as-is. 220 | if ( path_is_absolute( $directory ) ) { 221 | return trailingslashit( $directory ); 222 | } 223 | 224 | // Otherwise, treat as relative to ABSPATH. 225 | return trailingslashit( ABSPATH ) . trailingslashit( $directory ); 226 | } 227 | 228 | /** 229 | * Generates a unique filename by appending -n if file exists and replace is false. 230 | * 231 | * @param string $directory The target directory. 232 | * @param string $filename The desired filename. 233 | * @param bool $replace Whether to replace existing files. 234 | * 235 | * @return string The final filename (unique if replace is false). 236 | */ 237 | public function generate_unique_filename( string $directory, string $filename, bool $replace = false ): string { 238 | if ( $replace ) { 239 | return $filename; 240 | } 241 | 242 | return wp_unique_filename( $directory, $filename ); 243 | } 244 | 245 | /** 246 | * Moves a file from temp directory to target directory. 247 | * 248 | * @param string $temp_path The temporary file path. 249 | * @param string $target_directory The target directory. 250 | * @param string $filename The desired filename. 251 | * @param bool $replace_existing Whether to replace existing files. 252 | * 253 | * @return string|false The absolute path to the moved file, or false on failure. 254 | */ 255 | public function move_temp_to_directory( string $temp_path, string $target_directory, string $filename, bool $replace_existing = false ) { 256 | // Ensure target directory exists. 257 | if ( ! file_exists( $target_directory ) ) { 258 | if ( ! wp_mkdir_p( $target_directory ) ) { 259 | return false; 260 | } 261 | } 262 | 263 | // Check if temp file exists. 264 | if ( ! file_exists( $temp_path ) ) { 265 | return false; 266 | } 267 | 268 | // Generate final filename. 269 | $final_filename = $this->generate_unique_filename( $target_directory, $filename, $replace_existing ); 270 | $target_path = trailingslashit( $target_directory ) . $final_filename; 271 | 272 | // If replacing and file exists, delete it first. 273 | if ( $replace_existing && file_exists( $target_path ) ) { 274 | wp_delete_file( $target_path ); 275 | } 276 | 277 | // Move file from temp to target. 278 | if ( rename( $temp_path, $target_path ) ) { // phpcs:ignore 279 | return $target_path; 280 | } 281 | 282 | return false; 283 | } 284 | 285 | /** 286 | * Deletes a direct file from the filesystem. 287 | * 288 | * @param string $file_path The absolute path to the file. 289 | * 290 | * @return bool True on success, false on failure. 291 | */ 292 | public function delete_direct_file( string $file_path ): bool { 293 | if ( ! file_exists( $file_path ) ) { 294 | return false; 295 | } 296 | 297 | return wp_delete_file( $file_path ); 298 | } 299 | 300 | /** 301 | * Extracts filename from a file path. 302 | * 303 | * @param string $file_path The file path. 304 | * 305 | * @return string The filename. 306 | */ 307 | public function get_filename_from_path( string $file_path ): string { 308 | return basename( $file_path ); 309 | } 310 | 311 | /** 312 | * Cleans up old temporary files from the temp directory. 313 | * 314 | * Deletes files older than the age threshold (default 24 hours). 315 | * The age threshold can be filtered using 'wpifycf_temp_file_age_threshold'. 316 | * 317 | * @return int Number of files deleted. 318 | */ 319 | public function cleanup_temp_files(): int { 320 | $temp_dir = $this->get_direct_file_temp_dir(); 321 | 322 | // Skip if directory doesn't exist. 323 | if ( ! is_dir( $temp_dir ) ) { 324 | return 0; 325 | } 326 | 327 | // Get age threshold in seconds (default 24 hours). 328 | $age_threshold = apply_filters( 'wpifycf_temp_file_age_threshold', DAY_IN_SECONDS ); 329 | $cutoff_time = time() - $age_threshold; 330 | $deleted_count = 0; 331 | 332 | // Get all files in temp directory. 333 | $files = glob( trailingslashit( $temp_dir ) . '*' ); 334 | 335 | if ( ! is_array( $files ) ) { 336 | return 0; 337 | } 338 | 339 | foreach ( $files as $file ) { 340 | // Skip if not a file. 341 | if ( ! is_file( $file ) ) { 342 | continue; 343 | } 344 | 345 | // Get file modification time. 346 | $file_mtime = filemtime( $file ); 347 | 348 | if ( false === $file_mtime ) { 349 | continue; 350 | } 351 | 352 | // Delete if older than threshold. 353 | if ( $file_mtime < $cutoff_time ) { 354 | if ( wp_delete_file( $file ) ) { 355 | ++$deleted_count; 356 | } else { 357 | // Log error but continue processing. 358 | error_log( sprintf( '[wpify-custom-fields] Failed to delete temp file: %s', $file ) ); // phpcs:ignore 359 | } 360 | } 361 | } 362 | 363 | return $deleted_count; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/Integrations/ProductVariationOptions.php: -------------------------------------------------------------------------------- 1 | 0 ) { 167 | throw new MissingArgumentException( 168 | sprintf( 169 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 170 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 171 | esc_html( implode( ', ', $missing ) ), 172 | __CLASS__, 173 | ), 174 | ); 175 | } 176 | 177 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 178 | $this->display = $args['display']; 179 | } else { 180 | $this->display = function () use ( $args ) { 181 | return $args['display'] ?? true; 182 | }; 183 | } 184 | 185 | $this->capability = $args['capability'] ?? 'manage_options'; 186 | $this->callback = $args['callback'] ?? null; 187 | $this->after = $args['after'] ?? ''; 188 | $this->hook_priority = $args['hook_priority'] ?? 10; 189 | $this->help_tabs = $args['help_tabs'] ?? array(); 190 | $this->help_sidebar = $args['help_sidebar'] ?? ''; 191 | $this->option_name = $args['meta_key'] ?? ''; 192 | $this->items = $args['items'] ?? array(); 193 | $this->tabs = $args['tabs'] ?? array(); 194 | 195 | $tab = $args['tab'] ?? array(); 196 | 197 | if ( empty( $tab['label'] ) ) { 198 | throw new MissingArgumentException( 199 | sprintf( 200 | /* translators: %1$s is the class name. */ 201 | esc_html( __( 'Missing argument $tab["label"] in class %2$s.', 'wpify-custom-fields' ) ), 202 | __CLASS__, 203 | ), 204 | ); 205 | } 206 | 207 | if ( empty( $tab['id'] ) ) { 208 | $tab['id'] = sanitize_title( $tab['label'] ); 209 | } 210 | 211 | if ( empty( $tab['target'] ) ) { 212 | $tab['target'] = $tab['id']; 213 | } 214 | 215 | if ( empty( $tab['priority'] ) ) { 216 | $tab['priority'] = 100; 217 | } 218 | 219 | if ( empty( $tab['class'] ) ) { 220 | $tab['class'] = array(); 221 | } 222 | 223 | $this->tab = $tab; 224 | $this->is_new_tab = false; 225 | $this->id = sanitize_title( 226 | join( 227 | '-', 228 | array( 229 | 'variation-options', 230 | $this->tab['id'], 231 | sanitize_title( $this->tab['label'] ), 232 | $this->hook_priority, 233 | ), 234 | ), 235 | ); 236 | 237 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 238 | if ( in_array( $this->after, array( 'pricing', 'inventory', 'dimensions', 'download' ), true ) ) { 239 | add_action( 'woocommerce_variation_options_' . $this->after, array( $this, 'render' ), 10, 3 ); 240 | } else { 241 | add_action( 'woocommerce_product_after_variable_attributes', array( $this, 'render' ), 10, 3 ); 242 | } 243 | 244 | add_action( 'woocommerce_save_product_variation', array( $this, 'save' ), 10, 2 ); 245 | add_action( 'init', array( $this, 'register_meta' ), $this->hook_priority ); 246 | add_action( 'admin_footer', array( $this, 'maybe_enqueue' ) ); 247 | } 248 | } 249 | 250 | /** 251 | * Renders the variation options group for the product. 252 | * 253 | * @param int $loop The current loop iteration. 254 | * @param array $variation_data Data associated with the variation. 255 | * @param WP_Post $variation The WordPress post object for the variation. 256 | * 257 | * @return void 258 | */ 259 | public function render( int $loop, array $variation_data, WP_Post $variation ): void { 260 | if ( ! current_user_can( $this->capability ) ) { 261 | return; 262 | } 263 | 264 | $this->variation_id = $variation->ID; 265 | $items = $this->normalize_items( $this->items ); 266 | 267 | if ( is_callable( $this->callback ) ) { 268 | call_user_func( $this->callback ); 269 | } 270 | ?> 271 |
272 | print_app( 'product-variation', $this->tabs, array( 'loop' => $loop ) ); 274 | 275 | foreach ( $items as $item ) { 276 | $this->print_field( $item, array( 'loop' => $loop ), 'div', 'form-field' ); 277 | } 278 | ?> 279 |
280 | get_item_id() ); 290 | } 291 | 292 | /** 293 | * Saves data for a product variation. 294 | * 295 | * @param int $product_variation_id The ID of the product variation. 296 | * @param int $loop The current loop iteration. 297 | * 298 | * @return void 299 | */ 300 | public function save( int $product_variation_id, int $loop ): void { 301 | $this->variation_id = $product_variation_id; 302 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ), $loop ); 303 | } 304 | 305 | /** 306 | * Conditionally enqueues scripts or styles based on the current screen. 307 | * 308 | * If the current screen is a product-related screen, it calls the enqueue method. 309 | * 310 | * @return void 311 | */ 312 | public function maybe_enqueue(): void { 313 | $current_screen = get_current_screen(); 314 | if ( 'product' === $current_screen->id || 'product_page_product' === $current_screen->id ) { 315 | $this->enqueue(); 316 | } 317 | } 318 | 319 | /** 320 | * Registers custom meta fields for product variations. 321 | * 322 | * Normalizes the items and registers each item as a post meta for product variations with specific type, description, and sanitization callbacks. 323 | * 324 | * @return void 325 | */ 326 | public function register_meta(): void { 327 | $items = $this->normalize_items( $this->items ); 328 | 329 | foreach ( $items as $item ) { 330 | register_post_meta( 331 | 'product_variation', 332 | $item['id'], 333 | array( 334 | 'type' => $this->custom_fields->get_wp_type( $item ), 335 | 'description' => $item['label'], 336 | 'single' => true, 337 | 'default' => $this->custom_fields->get_default_value( $item ), 338 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 339 | 'show_in_rest' => false, 340 | ), 341 | ); 342 | } 343 | } 344 | 345 | /** 346 | * Retrieves the value of a product option. 347 | * 348 | * @param string $name The name of the option to retrieve. 349 | * @param mixed $default_value The default value to return if the option is not set. 350 | * 351 | * @return mixed The value of the specified option or the default value. 352 | */ 353 | public function get_option_value( string $name, mixed $default_value ): mixed { 354 | return $this->get_product()->get_meta( $name ) ?? $default_value; 355 | } 356 | 357 | /** 358 | * Sets the value of a product option and saves the product. 359 | * 360 | * @param string $name The name of the option to set. 361 | * @param mixed $value The value to set for the option. 362 | * 363 | * @return bool True if the product was saved successfully, false otherwise. 364 | */ 365 | public function set_option_value( string $name, mixed $value ): bool { 366 | $product = $this->get_product(); 367 | $product->update_meta_data( $name, $value ); 368 | 369 | return $product->save(); 370 | } 371 | 372 | /** 373 | * Retrieves the ID of the item. 374 | * 375 | * @return int The ID of the item. 376 | */ 377 | public function get_item_id(): int { 378 | return $this->variation_id; 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /src/Integrations/WcMembershipPlanOptions.php: -------------------------------------------------------------------------------- 1 | 0 ) { 152 | throw new MissingArgumentException( 153 | sprintf( 154 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 155 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 156 | esc_html( implode( ', ', $missing ) ), 157 | __CLASS__, 158 | ), 159 | ); 160 | } 161 | 162 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 163 | $this->display = $args['display']; 164 | } else { 165 | $this->display = function () use ( $args ) { 166 | return $args['display'] ?? true; 167 | }; 168 | } 169 | 170 | $this->capability = $args['capability'] ?? 'manage_options'; 171 | $this->callback = $args['callback'] ?? null; 172 | $this->hook_priority = $args['hook_priority'] ?? 10; 173 | $this->help_tabs = $args['help_tabs'] ?? array(); 174 | $this->help_sidebar = $args['help_sidebar'] ?? ''; 175 | $this->items = $args['items'] ?? array(); 176 | $this->option_name = $args['meta_key'] ?? ''; 177 | $this->tabs = $args['tabs'] ?? array(); 178 | $this->is_new_tab = false; 179 | 180 | $tab = $args['tab'] ?? array(); 181 | 182 | if ( empty( $tab['label'] ) ) { 183 | throw new MissingArgumentException( 184 | sprintf( 185 | /* translators: %1$s is the class name. */ 186 | esc_html( __( 'Missing argument $tab["label"] in class %1$s.', 'wpify-custom-fields' ) ), 187 | __CLASS__, 188 | ), 189 | ); 190 | } 191 | 192 | if ( empty( $tab['id'] ) ) { 193 | $tab['id'] = sanitize_title( $tab['label'] ); 194 | } 195 | 196 | if ( empty( $tab['target'] ) ) { 197 | $tab['target'] = $tab['id']; 198 | } 199 | 200 | if ( empty( $tab['priority'] ) ) { 201 | $tab['priority'] = 100; 202 | } 203 | 204 | if ( empty( $tab['class'] ) ) { 205 | $tab['class'] = array(); 206 | } 207 | 208 | $this->tab = $tab; 209 | $this->id = sanitize_title( 210 | join( 211 | '-', 212 | array( 213 | 'wc_membership_plan_options', 214 | $this->tab['id'], 215 | sanitize_title( $this->tab['label'] ), 216 | $this->hook_priority, 217 | ), 218 | ), 219 | ); 220 | 221 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 222 | add_filter( 'wc_membership_plan_data_tabs', array( $this, 'wc_membership_plan_data_tabs' ), 98 ); 223 | add_action( 'wc_membership_plan_data_panels', array( $this, 'render_data_panels' ) ); 224 | add_action( 225 | 'wc_membership_plan_options_' . $this->tab['target'] ?? $this->tab['id'], 226 | array( 227 | $this, 228 | 'render', 229 | ) 230 | ); 231 | add_action( 'wc_memberships_save_meta_box', array( $this, 'save' ) ); 232 | add_action( 'init', array( $this, 'register_meta' ), $this->hook_priority ); 233 | } 234 | } 235 | 236 | /** 237 | * Updates or adds membership plan data tabs. 238 | * 239 | * @param array $tabs The existing tabs. 240 | * 241 | * @return array The modified tabs. 242 | */ 243 | public function wc_membership_plan_data_tabs( array $tabs ): array { 244 | if ( isset( $tabs[ $this->tab['id'] ] ) ) { 245 | if ( ! empty( $this->tab['label'] ) ) { 246 | $tabs[ $this->tab['id'] ]['label'] = $this->tab['label']; 247 | } 248 | 249 | if ( ! empty( $this->tab['priority'] ) ) { 250 | $tabs[ $this->tab['id'] ]['priority'] = $this->tab['priority']; 251 | } 252 | } else { 253 | $this->is_new_tab = true; 254 | 255 | $tabs[ $this->tab['id'] ] = array( 256 | 'label' => $this->tab['label'], 257 | 'priority' => $this->tab['priority'], 258 | 'target' => $this->tab['target'], 259 | 'class' => $this->tab['class'], 260 | ); 261 | } 262 | 263 | return $tabs; 264 | } 265 | 266 | 267 | /** 268 | * Renders the data panels for the membership plan. 269 | * 270 | * This method outputs the HTML for the data panels and triggers the associated actions for the specified tab target. 271 | * 272 | * @return void 273 | */ 274 | public function render_data_panels(): void { 275 | ?> 276 |
277 | tab['target'] ); 280 | ?> 281 |
282 | capability ) ) { 292 | return; 293 | } 294 | 295 | global $post; 296 | 297 | $this->membership_plan_id = $post->ID; 298 | $this->enqueue(); 299 | $items = $this->normalize_items( $this->items ); 300 | 301 | if ( is_callable( $this->callback ) ) { 302 | call_user_func( $this->callback ); 303 | } 304 | ?> 305 |
306 | print_app( 'product-options', $this->tabs ); 308 | 309 | foreach ( $items as $item ) { 310 | ?> 311 |
312 | print_field( $item ); ?> 313 |
314 | 317 |
318 | get_item_id() ); 328 | } 329 | 330 | /** 331 | * Saves the membership plan data. 332 | * 333 | * @param array $data Data from the form. 334 | * 335 | * @return void 336 | */ 337 | public function save( array $data ): void { 338 | $this->membership_plan_id = $data['post_ID']; 339 | 340 | if ( ! $this->membership_plan_id ) { 341 | return; 342 | } 343 | 344 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 345 | } 346 | 347 | /** 348 | * Registers meta fields for a custom post type 'product'. 349 | * 350 | * This method normalizes and registers each item as a post meta 351 | * for the 'product' post type, setting various properties such as type, 352 | * description, default value, and sanitize callback. 353 | * 354 | * @return void 355 | */ 356 | public function register_meta(): void { 357 | $items = $this->normalize_items( $this->items ); 358 | 359 | foreach ( $items as $item ) { 360 | register_post_meta( 361 | 'product', 362 | $item['id'], 363 | array( 364 | 'type' => $this->custom_fields->get_wp_type( $item ), 365 | 'description' => $item['label'], 366 | 'single' => true, 367 | 'default' => $this->custom_fields->get_default_value( $item ), 368 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 369 | 'show_in_rest' => false, 370 | ), 371 | ); 372 | } 373 | } 374 | 375 | /** 376 | * Retrieves an option value for a given post meta field. 377 | * 378 | * @param string $name The name of the meta field. 379 | * @param mixed $default_value The default value to return if the meta field does not exist. 380 | * 381 | * @return mixed The value of the meta field or the default value if the field does not exist. 382 | */ 383 | public function get_option_value( string $name, mixed $default_value ): mixed { 384 | return get_post_meta( $this->get_item_id(), $name, true ) ?? $default_value; 385 | } 386 | 387 | /** 388 | * Sets the value of a specified option. 389 | * 390 | * @param string $name The name of the option to be set. 391 | * @param mixed $value The value to set for the option. 392 | * 393 | * @return bool|int True on success, false on failure. 394 | */ 395 | public function set_option_value( string $name, mixed $value ): bool|int { 396 | return update_post_meta( $this->get_item_id(), $name, $value ); 397 | } 398 | 399 | /** 400 | * Retrieves the membership plan item ID. 401 | * 402 | * @return int The membership plan item ID. 403 | */ 404 | public function get_item_id(): int { 405 | return $this->membership_plan_id; 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/Integrations/CouponOptions.php: -------------------------------------------------------------------------------- 1 | 0 ) { 153 | throw new MissingArgumentException( 154 | sprintf( 155 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 156 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 157 | esc_html( implode( ', ', $missing ) ), 158 | __CLASS__, 159 | ), 160 | ); 161 | } 162 | 163 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 164 | $this->display = $args['display']; 165 | } else { 166 | $this->display = function () use ( $args ) { 167 | return $args['display'] ?? true; 168 | }; 169 | } 170 | 171 | $this->capability = $args['capability'] ?? 'manage_options'; 172 | $this->callback = $args['callback'] ?? null; 173 | $this->hook_priority = $args['hook_priority'] ?? 10; 174 | $this->help_tabs = $args['help_tabs'] ?? array(); 175 | $this->help_sidebar = $args['help_sidebar'] ?? ''; 176 | $this->items = $args['items'] ?? array(); 177 | $this->option_name = $args['meta_key'] ?? ''; 178 | $this->tabs = $args['tabs'] ?? array(); 179 | $this->is_new_tab = false; 180 | 181 | $tab = $args['tab'] ?? array(); 182 | 183 | $tab['label'] = $tab['label'] ?? ''; 184 | 185 | if ( empty( $tab['id'] ) ) { 186 | $tab['id'] = sanitize_title( $tab['label'] ); 187 | } 188 | 189 | if ( empty( $tab['target'] ) ) { 190 | $tab['target'] = $tab['id']; 191 | } 192 | 193 | if ( empty( $tab['class'] ) ) { 194 | $tab['class'] = array(); 195 | } 196 | 197 | $this->tab = $tab; 198 | $this->id = sanitize_title( 199 | join( 200 | '-', 201 | array( 202 | 'product-options', 203 | $this->tab['id'], 204 | sanitize_title( $this->tab['label'] ), 205 | $this->hook_priority, 206 | ), 207 | ), 208 | ); 209 | 210 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 211 | add_filter( 'woocommerce_coupon_data_tabs', array( $this, 'woocommerce_coupon_data_tabs' ), 98 ); 212 | add_action( 'woocommerce_coupon_data_panels', array( $this, 'render_data_panels' ) ); 213 | add_action( 'woocommerce_coupon_options_' . $this->tab['target'] ?? $this->tab['id'], array( $this, 'render' ) ); 214 | add_action( 'woocommerce_coupon_options_save', array( $this, 'save' ) ); 215 | add_action( 'init', array( $this, 'register_meta' ), $this->hook_priority ); 216 | } 217 | } 218 | 219 | /** 220 | * Modifies the WooCommerce product data tabs by updating existing tabs or adding new custom tabs. 221 | * 222 | * @param array $tabs An associative array of existing product data tabs. 223 | * 224 | * @return array The modified array of product data tabs. 225 | */ 226 | public function woocommerce_coupon_data_tabs( array $tabs ): array { 227 | if ( isset( $tabs[ $this->tab['id'] ] ) ) { 228 | if ( ! empty( $this->tab['label'] ) ) { 229 | $tabs[ $this->tab['id'] ]['label'] = $this->tab['label']; 230 | } 231 | 232 | if ( ! empty( $this->tab['priority'] ) ) { 233 | $tabs[ $this->tab['id'] ]['priority'] = $this->tab['priority']; 234 | } 235 | } else { 236 | $this->is_new_tab = true; 237 | 238 | $tabs[ $this->tab['id'] ] = array( 239 | 'label' => $this->tab['label'], 240 | 'priority' => $this->tab['priority'], 241 | 'target' => $this->tab['target'], 242 | 'class' => $this->tab['class'], 243 | ); 244 | } 245 | 246 | return $tabs; 247 | } 248 | 249 | 250 | /** 251 | * Renders the data panels for WooCommerce product options. 252 | * 253 | * This method outputs a div element with a specific ID and class, and triggers a custom WooCommerce action 254 | * based on the target attribute of the tab. 255 | * 256 | * @return void No return value. 257 | */ 258 | public function render_data_panels(): void { 259 | if ( ! $this->is_new_tab ) { 260 | return; 261 | } 262 | ?> 263 |
264 | tab['target'] ); 267 | ?> 268 |
269 | capability ) ) { 283 | return; 284 | } 285 | 286 | global $post; 287 | 288 | $this->coupon_id = $post->ID; 289 | $this->enqueue(); 290 | $items = $this->normalize_items( $this->items ); 291 | 292 | if ( is_callable( $this->callback ) ) { 293 | call_user_func( $this->callback ); 294 | } 295 | ?> 296 |
297 | print_app( 'coupon-options', $this->tabs ); 299 | 300 | foreach ( $items as $item ) { 301 | ?> 302 |
303 | print_field( $item ); ?> 304 |
305 | 308 |
309 | get_item_id() ); 321 | } 322 | 323 | /** 324 | * Saves the product fields data for a given post. 325 | * 326 | * This function processes and saves custom fields associated with a product 327 | * for the specified post ID. The items are normalized and sanitized before 328 | * being stored. 329 | * 330 | * @param int $post_id The ID of the post being saved. 331 | * 332 | * @return void 333 | */ 334 | public function save( int $post_id ): void { 335 | $this->coupon_id = $post_id; 336 | 337 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 338 | } 339 | 340 | /** 341 | * Registers custom metadata for the products. 342 | * 343 | * This method normalizes the defined items and registers each as post meta for 'product'. 344 | * It sets the meta properties such as type, description, default value, and sanitize callback. 345 | * 346 | * @return void 347 | */ 348 | public function register_meta(): void { 349 | $items = $this->normalize_items( $this->items ); 350 | 351 | foreach ( $items as $item ) { 352 | register_post_meta( 353 | 'coupon', 354 | $item['id'], 355 | array( 356 | 'type' => $this->custom_fields->get_wp_type( $item ), 357 | 'description' => $item['label'], 358 | 'single' => true, 359 | 'default' => $this->custom_fields->get_default_value( $item ), 360 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 361 | 'show_in_rest' => false, 362 | ), 363 | ); 364 | } 365 | } 366 | 367 | /** 368 | * Retrieves an option value from the product meta data. 369 | * 370 | * @param string $name The name of the meta key to retrieve. 371 | * @param mixed $default_value The default value to return if the meta key does not exist. 372 | * 373 | * @return mixed The value of the meta key if it exists, otherwise the default value. 374 | */ 375 | public function get_option_value( string $name, mixed $default_value ): mixed { 376 | return $this->get_coupon()->get_meta( $name ) ?? $default_value; 377 | } 378 | 379 | /** 380 | * Sets an option value in the product meta data. 381 | * 382 | * @param string $name The name of the meta key to set. 383 | * @param mixed $value The value to be set for the meta key. 384 | * 385 | * @return bool True on success, false on failure. 386 | */ 387 | public function set_option_value( string $name, mixed $value ): bool { 388 | $product = $this->get_coupon(); 389 | $product->update_meta_data( $name, $value ); 390 | 391 | return $product->save(); 392 | } 393 | 394 | /** 395 | * Retrieves the item ID of the product. 396 | * 397 | * @return int The product's item ID. 398 | */ 399 | public function get_item_id(): int { 400 | return $this->coupon_id; 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /src/Integrations/ProductOptions.php: -------------------------------------------------------------------------------- 1 | 0 ) { 153 | throw new MissingArgumentException( 154 | sprintf( 155 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 156 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 157 | esc_html( implode( ', ', $missing ) ), 158 | __CLASS__, 159 | ), 160 | ); 161 | } 162 | 163 | if ( isset( $args['display'] ) && is_callable( $args['display'] ) ) { 164 | $this->display = $args['display']; 165 | } else { 166 | $this->display = function () use ( $args ) { 167 | return $args['display'] ?? true; 168 | }; 169 | } 170 | 171 | $this->capability = $args['capability'] ?? 'manage_options'; 172 | $this->callback = $args['callback'] ?? null; 173 | $this->hook_priority = $args['hook_priority'] ?? 10; 174 | $this->help_tabs = $args['help_tabs'] ?? array(); 175 | $this->help_sidebar = $args['help_sidebar'] ?? ''; 176 | $this->items = $args['items'] ?? array(); 177 | $this->option_name = $args['meta_key'] ?? ''; 178 | $this->tabs = $args['tabs'] ?? array(); 179 | $this->is_new_tab = false; 180 | 181 | $tab = $args['tab'] ?? array(); 182 | 183 | $tab['label'] = $tab['label'] ?? ''; 184 | 185 | if ( empty( $tab['id'] ) ) { 186 | $tab['id'] = sanitize_title( $tab['label'] ); 187 | } 188 | 189 | if ( empty( $tab['target'] ) ) { 190 | $tab['target'] = $tab['id']; 191 | } 192 | 193 | if ( empty( $tab['class'] ) ) { 194 | $tab['class'] = array(); 195 | } 196 | 197 | $this->tab = $tab; 198 | $this->id = sanitize_title( 199 | join( 200 | '-', 201 | array( 202 | 'product-options', 203 | $this->tab['id'], 204 | sanitize_title( $this->tab['label'] ), 205 | $this->hook_priority, 206 | ), 207 | ), 208 | ); 209 | 210 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 211 | add_filter( 'woocommerce_product_data_tabs', array( $this, 'woocommerce_product_data_tabs' ), 98 ); 212 | add_action( 'woocommerce_product_data_panels', array( $this, 'render_data_panels' ) ); 213 | add_action( 214 | 'woocommerce_product_options_' . $this->tab['target'] ?? $this->tab['id'], 215 | array( 216 | $this, 217 | 'render', 218 | ) 219 | ); 220 | add_action( 'woocommerce_process_product_meta', array( $this, 'save' ) ); 221 | add_action( 'init', array( $this, 'register_meta' ), $this->hook_priority ); 222 | } 223 | } 224 | 225 | /** 226 | * Modifies the WooCommerce product data tabs by updating existing tabs or adding new custom tabs. 227 | * 228 | * @param array $tabs An associative array of existing product data tabs. 229 | * 230 | * @return array The modified array of product data tabs. 231 | */ 232 | public function woocommerce_product_data_tabs( array $tabs ): array { 233 | if ( isset( $tabs[ $this->tab['id'] ] ) ) { 234 | if ( ! empty( $this->tab['label'] ) ) { 235 | $tabs[ $this->tab['id'] ]['label'] = $this->tab['label']; 236 | } 237 | 238 | if ( ! empty( $this->tab['priority'] ) ) { 239 | $tabs[ $this->tab['id'] ]['priority'] = $this->tab['priority']; 240 | } 241 | } else { 242 | $this->is_new_tab = true; 243 | 244 | $tabs[ $this->tab['id'] ] = array( 245 | 'label' => $this->tab['label'], 246 | 'priority' => $this->tab['priority'], 247 | 'target' => $this->tab['target'], 248 | 'class' => $this->tab['class'], 249 | ); 250 | } 251 | 252 | return $tabs; 253 | } 254 | 255 | 256 | /** 257 | * Renders the data panels for WooCommerce product options. 258 | * 259 | * This method outputs a div element with a specific ID and class, and triggers a custom WooCommerce action 260 | * based on the target attribute of the tab. 261 | * 262 | * @return void No return value. 263 | */ 264 | public function render_data_panels(): void { 265 | if ( ! $this->is_new_tab ) { 266 | return; 267 | } 268 | ?> 269 |
270 | tab['target'] ); 273 | ?> 274 |
275 | capability ) ) { 289 | return; 290 | } 291 | 292 | global $post; 293 | 294 | $this->product_id = $post->ID; 295 | $this->enqueue(); 296 | $items = $this->normalize_items( $this->items ); 297 | 298 | if ( is_callable( $this->callback ) ) { 299 | call_user_func( $this->callback ); 300 | } 301 | ?> 302 |
303 | print_app( 'product-options', $this->tabs ); 305 | 306 | foreach ( $items as $item ) { 307 | ?> 308 |
309 | print_field( $item ); ?> 310 |
311 | 314 |
315 | get_item_id() ); 328 | } 329 | 330 | /** 331 | * Saves the product fields data for a given post. 332 | * 333 | * This function processes and saves custom fields associated with a product 334 | * for the specified post ID. The items are normalized and sanitized before 335 | * being stored. 336 | * 337 | * @param int $post_id The ID of the post being saved. 338 | * 339 | * @return void 340 | */ 341 | public function save( int $post_id ): void { 342 | $this->product_id = $post_id; 343 | 344 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 345 | } 346 | 347 | /** 348 | * Registers custom metadata for the products. 349 | * 350 | * This method normalizes the defined items and registers each as post meta for 'product'. 351 | * It sets the meta properties such as type, description, default value, and sanitize callback. 352 | * 353 | * @return void 354 | */ 355 | public function register_meta(): void { 356 | $items = $this->normalize_items( $this->items ); 357 | 358 | foreach ( $items as $item ) { 359 | register_post_meta( 360 | 'product', 361 | $item['id'], 362 | array( 363 | 'type' => $this->custom_fields->get_wp_type( $item ), 364 | 'description' => $item['label'], 365 | 'single' => true, 366 | 'default' => $this->custom_fields->get_default_value( $item ), 367 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 368 | 'show_in_rest' => false, 369 | ), 370 | ); 371 | } 372 | } 373 | 374 | /** 375 | * Retrieves an option value from the product meta data. 376 | * 377 | * @param string $name The name of the meta key to retrieve. 378 | * @param mixed $default_value The default value to return if the meta key does not exist. 379 | * 380 | * @return mixed The value of the meta key if it exists, otherwise the default value. 381 | */ 382 | public function get_option_value( string $name, mixed $default_value ): mixed { 383 | return $this->get_product()->get_meta( $name ) ?? $default_value; 384 | } 385 | 386 | /** 387 | * Sets an option value in the product meta data. 388 | * 389 | * @param string $name The name of the meta key to set. 390 | * @param mixed $value The value to be set for the meta key. 391 | * 392 | * @return bool True on success, false on failure. 393 | */ 394 | public function set_option_value( string $name, mixed $value ): bool { 395 | $product = $this->get_product(); 396 | $product->update_meta_data( $name, $value ); 397 | 398 | return $product->save(); 399 | } 400 | 401 | /** 402 | * Retrieves the item ID of the product. 403 | * 404 | * @return int The product's item ID. 405 | */ 406 | public function get_item_id(): int { 407 | return $this->product_id; 408 | } 409 | } 410 | -------------------------------------------------------------------------------- /src/Integrations/GutenbergBlock.php: -------------------------------------------------------------------------------- 1 | name = $args['name']; 263 | $this->id = $args['id'] ?? sprintf( 'wpifycf_block_%s', sanitize_title( $this->name ) ); 264 | $this->api_version = $args['api_version'] ?? '3'; 265 | $this->title = $args['title']; 266 | $this->category = $args['category'] ?? null; 267 | $this->parent = $args['parent'] ?? null; 268 | $this->ancestor = $args['ancestor'] ?? null; 269 | $this->allowed_blocks = $args['allowed_blocks'] ?? null; 270 | 271 | if ( ! empty( $args['icon'] ) && file_exists( $args['icon'] ) ) { 272 | global $wp_filesystem; 273 | if ( empty( $wp_filesystem ) ) { 274 | require_once ABSPATH . 'wp-admin/includes/file.php'; 275 | WP_Filesystem(); 276 | } 277 | $this->icon = $wp_filesystem->get_contents( $args['icon'] ); 278 | } elseif ( ! empty( $args['icon'] ) ) { 279 | $this->icon = $args['icon']; 280 | } else { 281 | $this->icon = null; 282 | } 283 | 284 | $this->description = $args['description'] ?? ''; 285 | $this->keywords = $args['keywords'] ?? array(); 286 | $this->textdomain = $args['textdomain'] ?? null; 287 | $this->styles = $args['styles'] ?? array(); 288 | $this->variations = $args['variations'] ?? array(); 289 | $this->selectors = $args['selectors'] ?? array(); 290 | $this->supports = $args['supports'] ?? null; 291 | $this->example = $args['example'] ?? null; 292 | $this->render_callback = $args['render_callback'] ?? null; 293 | $this->variation_callback = $args['variation_callback'] ?? null; 294 | $this->uses_context = $args['uses_context'] ?? array(); 295 | $this->provides_context = $args['provides_context'] ?? null; 296 | $this->block_hooks = $args['block_hooks'] ?? array(); 297 | $this->editor_script_handles = $args['editor_script_handles'] ?? array(); 298 | $this->script_handles = $args['script_handles'] ?? array(); 299 | $this->view_script_handles = $args['view_script_handles'] ?? array(); 300 | $this->editor_style_handles = $args['editor_style_handles'] ?? array(); 301 | $this->style_handles = $args['style_handles'] ?? array(); 302 | $this->view_style_handles = $args['view_style_handles'] ?? array(); 303 | $this->items = $args['items'] ?? array(); 304 | $this->attributes = $this->get_attributes(); 305 | $this->tabs = $args['tabs'] ?? array(); 306 | 307 | add_action( 'init', array( $this, 'register_block' ) ); 308 | add_action( 'enqueue_block_assets', array( $this, 'enqueue' ) ); 309 | add_action( 'rest_api_init', array( $this, 'register_routes' ) ); 310 | } 311 | 312 | /** 313 | * Registers a custom block with the given arguments and configurations. 314 | * 315 | * @return void 316 | */ 317 | public function register_block(): void { 318 | $args = array( 319 | ...$this->get_args(), 320 | 'variation_callback' => $this->variation_callback, 321 | 'editor_script_handles' => $this->editor_script_handles, 322 | 'script_handles' => $this->script_handles, 323 | 'view_script_handles' => $this->view_script_handles, 324 | 'editor_style_handles' => $this->editor_style_handles, 325 | 'style_handles' => $this->style_handles, 326 | 'view_style_handles' => $this->view_style_handles, 327 | ); 328 | 329 | if ( $this->render_callback ) { 330 | $args['render_callback'] = array( $this, 'render' ); 331 | } 332 | 333 | register_block_type( $this->name, $args ); 334 | do_action( 335 | 'wpifycf_register_block', 336 | $this->name, 337 | array( 338 | ...$args, 339 | 'items' => $this->normalize_items( $this->items ), 340 | ) 341 | ); 342 | } 343 | 344 | /** 345 | * Enqueue the script and dispatch a custom event with the block data. 346 | * 347 | * @return void 348 | */ 349 | public function enqueue(): void { 350 | parent::enqueue(); 351 | 352 | $data = array( 353 | 'name' => $this->name, 354 | 'items' => $this->normalize_items( $this->items ), 355 | 'args' => $this->get_args(), 356 | 'tabs' => $this->tabs, 357 | 'instance' => $this->custom_fields->get_script_handle(), 358 | ); 359 | 360 | wp_add_inline_script( 361 | $this->custom_fields->get_script_handle(), 362 | 'document.dispatchEvent(new CustomEvent("wpifycf_register_block_' . $this->custom_fields->get_script_handle() . '",{detail:' . wp_json_encode( $data ) . '}));', 363 | ); 364 | } 365 | 366 | /** 367 | * Retrieves the arguments for the block configuration. 368 | * 369 | * This method collects various configuration properties of the block, 370 | * filters out any empty values, and returns the resulting array. 371 | * 372 | * @return array The array of block configuration arguments, with empty values removed. 373 | */ 374 | public function get_args(): array { 375 | $args = array( 376 | 'api_version' => $this->api_version, 377 | 'title' => $this->title, 378 | 'category' => $this->category, 379 | 'parent' => $this->parent, 380 | 'ancestor' => $this->ancestor, 381 | 'allowed_blocks' => $this->allowed_blocks, 382 | 'icon' => $this->icon, 383 | 'description' => $this->description, 384 | 'keywords' => $this->keywords, 385 | 'textdomain' => $this->textdomain, 386 | 'styles' => $this->styles, 387 | 'variations' => $this->variations, 388 | 'selectors' => $this->selectors, 389 | 'supports' => $this->supports, 390 | 'example' => $this->example, 391 | 'attributes' => $this->attributes, 392 | 'uses_context' => $this->uses_context, 393 | 'provides_context' => $this->provides_context, 394 | 'block_hooks' => $this->block_hooks, 395 | ); 396 | 397 | foreach ( $args as $key => $arg ) { 398 | if ( empty( $arg ) ) { 399 | unset( $args[ $key ] ); 400 | } 401 | } 402 | 403 | return $args; 404 | } 405 | 406 | /** 407 | * Retrieves the attributes for the items. 408 | * 409 | * Normalizes the items and then creates an array of attributes, where each attribute is identified by 410 | * its ID and contains its type and default value. 411 | * 412 | * @return array Associative array of attributes, with each key being an item ID and each value being 413 | * an array containing 'type' and 'default' keys. 414 | */ 415 | public function get_attributes(): array { 416 | $items = $this->normalize_items( $this->items ); 417 | $attributes = array(); 418 | 419 | foreach ( $items as $item ) { 420 | $attributes[ $item['id'] ] = array( 421 | 'type' => $this->custom_fields->get_wp_type( $item ), 422 | 'default' => $this->custom_fields->get_default_value( $item ), 423 | ); 424 | } 425 | 426 | return $attributes; 427 | } 428 | 429 | /** 430 | * Renders a block's content based on provided attributes and a rendering callback. 431 | * 432 | * @param array $attributes The block attributes. 433 | * @param string $content The block content. 434 | * @param WP_Block $block The block instance. 435 | * 436 | * @return string The rendered block content. 437 | */ 438 | public function render( array $attributes, string $content, WP_Block $block ): string { 439 | if ( 440 | ( defined( 'REST_REQUEST' ) && REST_REQUEST && filter_input( INPUT_GET, 'context' ) !== 'edit' ) 441 | || ( null === $this->render_callback ) 442 | ) { 443 | return $content; 444 | } 445 | 446 | $attributes = $this->normalize_attributes( $attributes ); 447 | $rendered_content = call_user_func( $this->render_callback, $attributes, $content, $block ); 448 | 449 | if ( empty( $rendered_content ) || ! is_string( $rendered_content ) ) { 450 | return $content; 451 | } 452 | 453 | if ( preg_match( '//', $rendered_content ) ) { 454 | $rendered_content = preg_replace( '//', $content, $rendered_content ); 455 | } 456 | 457 | return $rendered_content; 458 | } 459 | 460 | /** 461 | * Registers the REST API routes for the block. 462 | * 463 | * @return void 464 | */ 465 | public function register_routes(): void { 466 | $this->custom_fields->api->register_rest_route( 467 | 'render-block/' . $this->name, 468 | WP_REST_Server::CREATABLE, 469 | array( $this, 'render_from_api' ), 470 | ); 471 | } 472 | 473 | /** 474 | * Renders a block's content based on provided attributes and a rendering callback. 475 | * 476 | * @param \WP_REST_Request $request The REST API request object. 477 | * 478 | * @return string The rendered block content. 479 | */ 480 | public function render_from_api( \WP_REST_Request $request ): string { 481 | $attributes = $request->get_param( 'attributes' ); 482 | $post_id = $request->get_param( 'postId' ); 483 | 484 | $parsed_block = array( 485 | 'blockName' => $this->name, 486 | 'attrs' => $attributes, 487 | 'innerBlocks' => array(), 488 | 'innerHTML' => '', 489 | 'innerContent' => array(), 490 | ); 491 | 492 | if ( null === $this->render_callback ) { 493 | return ''; 494 | } 495 | 496 | if ( $post_id ) { 497 | setup_postdata( $post_id ); 498 | } 499 | 500 | $attributes = $this->normalize_attributes( $attributes ); 501 | 502 | return call_user_func( $this->render_callback, $attributes, '', new WP_Block( $parsed_block ) ); 503 | } 504 | 505 | /** 506 | * Normalizes the attributes array. 507 | * 508 | * @param array $attributes Block attributes. 509 | * 510 | * @return array 511 | */ 512 | public function normalize_attributes( array $attributes ): array { 513 | $normalized_attributes = array(); 514 | 515 | foreach ( $attributes as $attribute_name => $attribute_value ) { 516 | if ( $attribute_value instanceof stdClass ) { 517 | $normalized_attributes[ $attribute_name ] = (array) $attribute_value; 518 | } else { 519 | $normalized_attributes[ $attribute_name ] = $attribute_value; 520 | } 521 | } 522 | 523 | return $normalized_attributes; 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /src/Integrations/SiteOptions.php: -------------------------------------------------------------------------------- 1 | 0 ) { 191 | throw new MissingArgumentException( 192 | sprintf( 193 | /* translators: %1$s is a list of missing arguments, %2$s is the class name. */ 194 | esc_html( __( 'Missing arguments %1$s in class %2$s.', 'wpify-custom-fields' ) ), 195 | esc_html( implode( ', ', $missing ) ), 196 | __CLASS__, 197 | ), 198 | ); 199 | } 200 | 201 | // Nonce verification not needed. 202 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended 203 | $this->blog_id = empty( $_REQUEST['id'] ) ? 0 : absint( $_REQUEST['id'] ); 204 | $this->page_title = $args['page_title']; 205 | $this->menu_title = $args['menu_title']; 206 | $this->capability = $args['capability'] ?? 'manage_options'; 207 | $this->menu_slug = $args['menu_slug']; 208 | $this->callback = $args['callback'] ?? null; 209 | $this->hook_priority = $args['hook_priority'] ?? 10; 210 | $this->help_tabs = $args['help_tabs'] ?? array(); 211 | $this->help_sidebar = $args['help_sidebar'] ?? ''; 212 | $this->display = $args['display'] ?? null; 213 | $this->submit_button = $args['submit_button'] ?? true; 214 | $this->items = $args['items'] ?? array(); 215 | $this->option_name = $args['option_name'] ?? ''; 216 | $this->option_group = empty( $this->option_name ) ? sanitize_title( $this->menu_slug ) : $this->option_name; 217 | $this->tabs = $args['tabs'] ?? array(); 218 | $this->success_message = empty( $args['success_message'] ) 219 | ? __( 'Settings saved', 'wpify-custom-fields' ) 220 | : $args['success_message']; 221 | 222 | if ( empty( $args['sections'] ) ) { 223 | $args['sections'] = array(); 224 | } 225 | 226 | $sections = array(); 227 | 228 | foreach ( $args['sections'] as $key => $section ) { 229 | if ( empty( $section['id'] ) && is_string( $key ) ) { 230 | $section['id'] = $key; 231 | } 232 | 233 | $sections[ $section['id'] ] = $section; 234 | } 235 | 236 | if ( empty( $sections ) ) { 237 | $sections = array( 238 | 'default' => array( 239 | 'id' => 'default', 240 | 'title' => '', 241 | 'callback' => '__return_true', 242 | 'page' => $this->menu_slug, 243 | ), 244 | ); 245 | } 246 | 247 | $this->sections = $sections; 248 | 249 | $this->id = sanitize_title( 250 | join( 251 | '-', 252 | array( 253 | 'site-options', 254 | $this->blog_id, 255 | $this->menu_slug, 256 | ), 257 | ), 258 | ); 259 | 260 | foreach ( $this->sections as $section ) { 261 | $this->default_section = $section['id']; 262 | break; 263 | } 264 | 265 | if ( ! defined( 'WP_CLI' ) || false === WP_CLI ) { 266 | add_filter( 'network_edit_site_nav_links', array( $this, 'create_tab' ) ); 267 | add_action( 'network_admin_menu', array( $this, 'register' ) ); 268 | add_action( 'admin_init', array( $this, 'register_settings' ), $this->hook_priority ); 269 | add_action( 'network_admin_edit_' . $this::SAVE_ACTION, array( $this, 'save_site_options' ) ); 270 | add_action( 'current_screen', array( $this, 'set_page_title' ) ); 271 | } 272 | } 273 | 274 | /** 275 | * Registers the submenu page and its related actions if the display condition is met. 276 | * 277 | * @return void 278 | */ 279 | public function register(): void { 280 | if ( 281 | ( $this->display && is_callable( $this->display ) && ! call_user_func( $this->display ) ) 282 | || ( ! is_null( $this->display ) && ! $this->display ) 283 | ) { 284 | return; 285 | } 286 | 287 | $hook_suffix = add_submenu_page( 288 | '', 289 | $this->page_title, 290 | $this->menu_title, 291 | $this->capability, 292 | $this->menu_slug, 293 | array( $this, 'render' ), 294 | ); 295 | 296 | $this->hook_suffix = $hook_suffix . '-network'; 297 | 298 | add_action( 'load-' . $this->hook_suffix, array( $this, 'render_help' ) ); 299 | } 300 | 301 | /** 302 | * Adds a new tab to the given tabs array. 303 | * 304 | * @param array $tabs The array of existing tabs. 305 | * 306 | * @return array The updated array of tabs including the newly added tab. 307 | */ 308 | public function create_tab( array $tabs ): array { 309 | $tabs[ $this->menu_slug ] = array( 310 | 'label' => $this->menu_title, 311 | 'url' => add_query_arg( 'page', $this->menu_slug, 'sites.php' ), 312 | 'cap' => 'manage_sites', 313 | ); 314 | 315 | return $tabs; 316 | } 317 | 318 | /** 319 | * Renders the settings page for editing a site. 320 | * 321 | * This method checks user capabilities, retrieves the site details based on the provided ID, 322 | * and renders the settings page along with necessary actions and messages. 323 | * 324 | * @return void 325 | */ 326 | public function render(): void { 327 | if ( ! current_user_can( $this->capability ) ) { 328 | return; 329 | } 330 | 331 | // Nonce verification not needed. 332 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended 333 | $id = isset( $_REQUEST['id'] ) ? absint( $_REQUEST['id'] ) : 0; 334 | $site = get_site( $id ); 335 | 336 | if ( empty( $site ) ) { 337 | return; 338 | } 339 | $action = add_query_arg( 'action', $this::SAVE_ACTION, 'edit.php' ); 340 | 341 | // phpcs:ignore WordPress.Security.NonceVerification.Recommended 342 | if ( ! empty( $_GET['settings-updated'] ) ) { 343 | add_settings_error( $this->menu_slug . '_error', $this->menu_slug . '_message', $this->success_message, 'updated' ); 344 | } 345 | 346 | settings_errors( $this->menu_slug . '_error' ); 347 | $this->enqueue(); 348 | ?> 349 |
350 |

351 | blogname ) ); 354 | ?> 355 |

356 |

357 | 358 | | 359 | 360 |

361 | $id, 365 | 'selected' => $this->menu_slug, 366 | ), 367 | ); 368 | ?> 369 |

370 | page_title ); ?> 371 |

372 | callback ) ) { 374 | call_user_func( $this->callback ); 375 | } 376 | ?> 377 |
378 | 379 | menu_slug === $_GET['page'] ) { 383 | ?> 384 |
385 |

success_message ); ?>

386 |
387 | print_app( 'site-options', $this->tabs ); 391 | settings_fields( $this->option_group ); 392 | do_settings_sections( $this->menu_slug ); 393 | 394 | if ( false !== $this->submit_button ) { 395 | if ( is_array( $this->submit_button ) ) { 396 | submit_button( 397 | $this->submit_button['text'] ?? null, 398 | $this->submit_button['type'] ?? null, 399 | $this->submit_button['name'] ?? null, 400 | $this->submit_button['wrap'] ?? true, 401 | $this->submit_button['other_attributes'] ?? array(), 402 | ); 403 | } elseif ( is_string( $this->submit_button ) ) { 404 | submit_button( $this->submit_button ); 405 | } else { 406 | submit_button(); 407 | } 408 | } 409 | ?> 410 |
411 |
412 | help_tabs as $key => $tab ) { 426 | $tab = wp_parse_args( 427 | $tab, 428 | array( 429 | 'id' => '', 430 | 'title' => '', 431 | 'content' => '', 432 | ), 433 | ); 434 | 435 | if ( empty( $tab['id'] ) ) { 436 | if ( ! empty( $key ) && is_string( $key ) ) { 437 | $tab['id'] = $key; 438 | } else { 439 | $tab['id'] = sanitize_title( $tab['title'] ) . '_' . $key; 440 | } 441 | } 442 | 443 | if ( is_callable( $tab['content'] ) ) { 444 | $tab['content'] = call_user_func( $tab['content'] ); 445 | } 446 | 447 | get_current_screen()->add_help_tab( $tab ); 448 | } 449 | 450 | if ( ! empty( $this->help_sidebar ) ) { 451 | get_current_screen()->set_help_sidebar( $this->help_sidebar ); 452 | } 453 | } 454 | 455 | /** 456 | * Saves the site options. 457 | * 458 | * This method normalizes the items, processes the incoming POST data for either 459 | * a named option or individual items, sanitizes the data, and sets the fields accordingly. 460 | * It then redirects the user back to the referring page with a success flag. 461 | * 462 | * @return void 463 | */ 464 | public function save_site_options(): void { 465 | $this->set_fields_from_post_request( $this->normalize_items( $this->items ) ); 466 | wp_safe_redirect( 467 | add_query_arg( 468 | array( 'updated' => true ), 469 | wp_get_referer(), 470 | ), 471 | ); 472 | exit; 473 | } 474 | 475 | /** 476 | * Registers the settings for the custom fields. 477 | * 478 | * This method normalizes the items, registers individual or grouped settings based on the given configuration, 479 | * and adds the necessary sections and fields to the settings page. 480 | * 481 | * @return void 482 | */ 483 | public function register_settings(): void { 484 | $items = $this->normalize_items( $this->items ); 485 | 486 | if ( empty( $this->option_name ) ) { 487 | foreach ( $items as $item ) { 488 | register_setting( 489 | $this->option_group, 490 | $item['id'], 491 | array( 492 | 'type' => $this->custom_fields->get_wp_type( $item ), 493 | 'label' => $item['label'] ?? '', 494 | 'sanitize_callback' => $this->custom_fields->sanitize_item_value( $item ), 495 | 'show_in_rest' => false, 496 | 'default' => $this->custom_fields->get_default_value( $item ), 497 | ), 498 | ); 499 | } 500 | } else { 501 | register_setting( 502 | $this->option_group, 503 | $this->option_name, 504 | array( 505 | 'type' => 'object', 506 | 'label' => $this->page_title, 507 | 'sanitize_callback' => $this->custom_fields->sanitize_option_value( $items ), 508 | 'show_in_rest' => false, 509 | 'default' => array(), 510 | ), 511 | ); 512 | } 513 | 514 | foreach ( $this->sections as $id => $section ) { 515 | add_settings_section( 516 | $id, 517 | $section['label'] ?? '', 518 | $section['callback'] ?? '__return_true', 519 | $this->menu_slug, 520 | $section['args'] ?? array(), 521 | ); 522 | } 523 | 524 | foreach ( $items as $item ) { 525 | $section = $this->sections[ $item['section'] ]['id'] ?? $this->default_section; 526 | 527 | add_settings_field( 528 | $item['id'], 529 | $item['label'], 530 | array( $this, 'print_field' ), 531 | $this->menu_slug, 532 | $section, 533 | array( 534 | 'label_for' => $item['id'], 535 | ...$item, 536 | ), 537 | ); 538 | } 539 | } 540 | 541 | /** 542 | * Normalizes an item by setting default values for its properties. 543 | * 544 | * This method overrides the parent function to provide additional normalization for the item. 545 | * If the 'section' property is not set, it defaults to 'general'. 546 | * 547 | * @param array $item The item to be normalized. 548 | * @param string $global_id Optional. A global identifier that can be used during normalization. Default is an empty string. 549 | * 550 | * @return array The normalized item. 551 | */ 552 | protected function normalize_item( array $item, string $global_id = '' ): array { 553 | $item = parent::normalize_item( $item, $global_id ); 554 | 555 | if ( empty( $item['section'] ) ) { 556 | $item['section'] = 'general'; 557 | } 558 | 559 | return $item; 560 | } 561 | 562 | /** 563 | * Sets the page title for a network admin screen when editing a specific site. 564 | * 565 | * This method updates the global `$title` variable with a formatted string representing the current site's name. 566 | * 567 | * @param WP_Screen $current_screen The current screen object. 568 | * 569 | * @return void 570 | */ 571 | public function set_page_title( WP_Screen $current_screen ): void { 572 | if ( is_network_admin() && $current_screen->id === $this->hook_suffix ) { 573 | global $title; 574 | $site = get_site( $this->blog_id ); 575 | 576 | /* translators: Blog name */ 577 | $title = sprintf( __( 'Edit Site: %s', 'wpify-custom-fields' ), esc_html( $site->blogname ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited 578 | } 579 | } 580 | 581 | /** 582 | * Retrieves the value of a specified option for the current blog. 583 | * 584 | * @param string $name The name of the option to retrieve. 585 | * @param mixed $default_value The default value to return if the option does not exist. 586 | */ 587 | public function get_option_value( string $name, mixed $default_value ): mixed { 588 | return get_blog_option( $this->blog_id, $name, $default_value ); 589 | } 590 | 591 | /** 592 | * Sets the value of a specified option for the current blog. 593 | * 594 | * @param string $name The name of the option to set. 595 | * @param mixed $value The value to set for the specified option. 596 | * 597 | * @return bool True if the option was successfully set, false otherwise. 598 | */ 599 | public function set_option_value( string $name, mixed $value ): bool { 600 | return update_blog_option( $this->blog_id, $name, $value ); 601 | } 602 | } 603 | --------------------------------------------------------------------------------