PluginProbe
Custom Metadata Manager / trunk
Custom Metadata Manager vtrunk
trunk 0.1 0.2 0.3 0.5 0.5.1 0.5.2 0.5.3 0.5.4 0.5.5 0.5.6 0.5.7 0.6 0.7 0.7.1 0.8.0
custom-metadata / includes / class-custom-metadata-manager.php

class-custom-metadata-manager.php in Custom Metadata Manager trunk, at includes/class-custom-metadata-manager.php

2,165 lines 82.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Custom Metadata Manager class.
4 *
5 * @package Automattic\CustomMetadata
6 */
7
8 /**
9 * Registers and manages custom metadata fields, groups and multifields
10 * for WordPress object types (posts, pages, users and comments).
11 */
12 class custom_metadata_manager {
13
14 /**
15 * Registration errors collected while registering fields.
16 *
17 * @var array
18 */
19 public $errors = array();
20
21 /**
22 * The registered metadata, keyed by object type.
23 *
24 * @var array
25 */
26 public $metadata = array();
27
28 /**
29 * Object types that are not post types.
30 *
31 * @var array
32 */
33 public $_non_post_types = array( 'user', 'comment' );
34
35 /**
36 * Object types that come "built-in" with WordPress.
37 *
38 * @var array
39 */
40 public $_builtin_object_types = array( 'post', 'page', 'user', 'comment' );
41
42 /**
43 * Column filter names.
44 *
45 * @var array
46 */
47 public $_column_types = array( 'posts', 'pages', 'users', 'comments' );
48
49 /**
50 * Supported field types.
51 *
52 * @var array
53 */
54 public $_field_types = array( 'text', 'textarea', 'password', 'number', 'email', 'telephone', 'checkbox', 'radio', 'select', 'multi_select', 'upload', 'wysiwyg', 'datepicker', 'datetimepicker', 'timepicker', 'colorpicker', 'taxonomy_select', 'taxonomy_radio', 'taxonomy_checkbox', 'link' );
55
56 /**
57 * Field types that are cloneable.
58 *
59 * @var array
60 */
61 public $_cloneable_field_types = array( 'text', 'textarea', 'upload', 'password', 'number', 'email', 'tel' );
62
63 /**
64 * Field types that support a default value.
65 *
66 * @var array
67 */
68 public $_field_types_that_support_default_value = array( 'text', 'textarea', 'password', 'number', 'email', 'telephone', 'upload', 'wysiwyg', 'datepicker', 'datetimepicker', 'timepicker', 'link', 'radio' );
69
70 /**
71 * Field types that support the placeholder attribute.
72 *
73 * @var array
74 */
75 public $_field_types_that_support_placeholder = array( 'text', 'textarea', 'password', 'number', 'email', 'tel', 'upload', 'datepicker', 'datetimepicker', 'timepicker', 'link' );
76
77 /**
78 * Field types that are read only by default.
79 *
80 * @var array
81 */
82 public $_field_types_that_are_read_only = array( 'upload', 'link', 'datepicker', 'datetimepicker', 'timepicker' );
83
84 /**
85 * Field types that support being part of a multifield group.
86 * Workarounds are still needed for other field types.
87 *
88 * @var array
89 */
90 public $_field_types_that_support_multifield = array( 'text', 'textarea', 'password', 'number', 'email', 'tel', 'select' );
91
92 /**
93 * Taxonomy field types.
94 *
95 * @var array
96 */
97 public $_taxonomy_fields = array( 'taxonomy_select', 'taxonomy_radio', 'taxonomy_checkbox', 'taxonomy_multi_select' );
98
99 /**
100 * Field types that are saved as multiples but are not cloneable.
101 *
102 * @var array
103 */
104 public $_multiple_not_cloneable = array( 'taxonomy_checkbox' );
105
106 /**
107 * Field types that always save as an array.
108 *
109 * @var array
110 */
111 public $_always_multiple_fields = array( 'taxonomy_checkbox', 'multi_select', 'taxonomy_multi_select' );
112
113 /**
114 * Object types whose columns are generated through apply_filters instead of do_action.
115 *
116 * @var array
117 */
118 public $_column_filter_object_types = array( 'user' );
119
120 /**
121 * Admin pages that receive the plugin stylesheets and scripts.
122 *
123 * @var array
124 */
125 public $_pages_whitelist = array( 'edit.php', 'post.php', 'post-new.php', 'users.php', 'profile.php', 'user-edit.php', 'edit-comments.php', 'comment.php' );
126
127 /**
128 * Default arguments passed to the wp_editor function.
129 *
130 * @var array
131 */
132 public $default_editor_args = array();
133
134 /**
135 * Singleton instance.
136 *
137 * @var custom_metadata_manager
138 */
139 private static $instance;
140
141 /**
142 * Gets the singleton instance, creating it on first use.
143 *
144 * @return custom_metadata_manager The singleton instance.
145 */
146 public static function instance() {
147 if ( isset( self::$instance ) ) {
148 return self::$instance;
149 }
150
151 self::$instance = new custom_metadata_manager();
152 self::$instance->run_initial_hooks();
153 return self::$instance;
154 }
155
156 /**
157 * Do nothing on construct.
158 *
159 * @return void
160 */
161 public function __construct() {}
162
163 /**
164 * Registers the initial admin hook.
165 *
166 * @return void
167 */
168 public function run_initial_hooks() {
169 add_action( 'admin_init', array( $this, 'admin_init' ), 1000, 0 );
170 }
171
172 /**
173 * Filters configuration, defines constants and registers the admin hooks.
174 *
175 * @return void
176 */
177 public function admin_init() {
178 global $pagenow;
179
180 // filter our vars.
181 $this->_non_post_types = apply_filters( 'custom_metadata_manager_non_post_types', $this->_non_post_types );
182 $this->_builtin_object_types = apply_filters( 'custom_metadata_manager_builtin_object_types', $this->_builtin_object_types );
183 $this->_column_types = apply_filters( 'custom_metadata_manager_column_types', $this->_column_types );
184 $this->_field_types = apply_filters( 'custom_metadata_manager_field_types', $this->_field_types );
185 $this->_cloneable_field_types = apply_filters( 'custom_metadata_manager_cloneable_field_types', $this->_cloneable_field_types );
186 $this->_field_types_that_support_default_value = apply_filters( 'custom_metadata_manager_field_types_that_support_default_value', $this->_field_types_that_support_default_value );
187 $this->_field_types_that_support_placeholder = apply_filters( 'custom_metadata_manager_field_types_that_support_placeholder', $this->_field_types_that_support_placeholder );
188 $this->_field_types_that_are_read_only = apply_filters( 'custom_metadata_manager_field_types_that_are_read_only', $this->_field_types_that_are_read_only );
189 $this->_field_types_that_support_multifield = apply_filters( 'custom_metadata_manager_field_types_that_support_multifield', $this->_field_types_that_support_multifield );
190 $this->_taxonomy_fields = apply_filters( 'custom_metadata_manager_cloneable_field_types', $this->_taxonomy_fields );
191 $this->_column_filter_object_types = apply_filters( 'custom_metadata_manager_column_filter_object_types', $this->_column_filter_object_types );
192 $this->_pages_whitelist = apply_filters( 'custom_metadata_manager_pages_whitelist', $this->_pages_whitelist );
193 $this->default_editor_args = apply_filters( 'custom_metadata_manager_default_editor_args', $this->default_editor_args );
194
195 define( 'CUSTOM_METADATA_MANAGER_SELECT2_VERSION', '3.2' ); // version for included select2.js.
196 define( 'CUSTOM_METADATA_MANAGER_TIMEPICKER_VERSION', '1.2' ); // version for included timepicker.
197 define( 'CUSTOM_METADATA_MANAGER_VERSION', '0.8.0' );
198 define( 'CUSTOM_METADATA_MANAGER_URL', apply_filters( 'custom_metadata_manager_url', trailingslashit( plugins_url( '', __FILE__ ) ) ) );
199
200 $this->init_object_types();
201
202 // Hook into load to initialize custom columns.
203 if ( in_array( $pagenow, $this->_pages_whitelist ) ) {
204 add_action( 'load-' . $pagenow, array( $this, 'init_metadata' ) );
205 }
206
207 // Hook into admin_notices to show errors.
208 if ( current_user_can( 'manage_options' ) ) {
209 add_action( 'admin_notices', array( $this, '_display_registration_errors' ) );
210 }
211
212 do_action( 'custom_metadata_manager_init' );
213 do_action( 'custom_metadata_manager_admin_init' );
214 }
215
216 /**
217 * Seeds the metadata store with an empty entry for every object type.
218 *
219 * @return void
220 */
221 public function init_object_types() {
222 foreach ( array_merge( get_post_types(), $this->_builtin_object_types ) as $object_type ) {
223 $this->metadata[ $object_type ] = array();
224 }
225 }
226
227 /**
228 * Registers the hooks needed to display and save metadata for the current object type.
229 *
230 * @return void
231 */
232 public function init_metadata() {
233 $object_type = $this->_get_object_type_context();
234
235 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
236 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
237
238 $this->init_columns();
239
240 // Handle actions related to users.
241 if ( 'user' == $object_type ) {
242 global $user_id;
243
244 if ( empty( $user_id ) ) {
245 $user_id = get_current_user_id(); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- $user_id is the WordPress-provided global on user admin screens.
246 }
247
248 // Editing another user's profile.
249 add_action( 'edit_user_profile', array( $this, 'add_user_metadata_groups' ) );
250 add_action( 'edit_user_profile_update', array( $this, 'save_user_metadata' ) );
251 // Allow user-editable fields on "Your Profile".
252 add_action( 'show_user_profile', array( $this, 'add_user_metadata_groups' ) );
253 add_action( 'personal_options_update', array( $this, 'save_user_metadata' ) );
254 } else {
255
256 // Hook in to metaboxes.
257 add_action( 'add_meta_boxes', array( $this, 'add_post_metadata_groups' ) );
258
259 // Hook in to save.
260 add_action( 'save_post', array( $this, 'save_post_metadata' ) );
261 add_action( 'edit_comment', array( $this, 'save_comment_metadata' ) );
262 }
263
264 do_action( 'custom_metadata_manager_init_metadata', $object_type );
265
266 add_action( 'admin_footer', array( $this, '_display_wp_link_dialog' ) );
267 }
268
269 /**
270 * Registers the custom column header and content hooks for the current object type.
271 *
272 * @return void
273 */
274 public function init_columns() {
275
276 $object_type = $this->_get_object_type_context();
277
278 // This is not really that clean, but it works. Damn inconsistencies!
279 if ( post_type_exists( $object_type ) ) {
280 $column_header_name = sprintf( '%s_posts', $object_type );
281 $column_content_name = ( 'page' != $object_type ) ? 'posts' : 'pages';
282 } elseif ( 'comment' == $object_type ) {
283 $column_header_name = 'edit-comments';
284 $column_content_name = 'comments';
285 } else {
286 // users.
287 $column_content_name = $object_type . 's';
288 $column_header_name = $column_content_name;
289 }
290
291 // Hook into Column Headers.
292 add_filter( "manage_{$column_header_name}_columns", array( $this, 'add_metadata_column_headers' ) );
293
294 // User and Posts have different functions.
295 $custom_column_content_function = array( $this, "add_{$object_type}_metadata_column_content" );
296 if ( ! is_callable( $custom_column_content_function ) ) {
297 $custom_column_content_function = array( $this, 'add_metadata_column_content' );
298 }
299
300 // Hook into Column Content. Users get filtered, others get actioned.
301 if ( ! in_array( $object_type, $this->_column_filter_object_types ) ) {
302 add_action( "manage_{$column_content_name}_custom_column", $custom_column_content_function, 10, 3 );
303 } else {
304 add_filter( "manage_{$column_content_name}_custom_column", $custom_column_content_function, 10, 3 );
305 }
306 }
307
308 /**
309 * Enqueues the scripts used by the metadata fields.
310 *
311 * @return void
312 */
313 public function enqueue_scripts() {
314 wp_enqueue_media();
315 wp_enqueue_script( 'wplink' );
316 wp_enqueue_script( 'wpdialogs-popup' );
317 wp_enqueue_style( 'wp-jquery-ui-dialog' );
318 wp_enqueue_script( 'select2', apply_filters( 'custom_metadata_manager_select2_js', CUSTOM_METADATA_MANAGER_URL . 'js/select2.min.js' ), array( 'jquery' ), CUSTOM_METADATA_MANAGER_SELECT2_VERSION, true );
319 wp_enqueue_script( 'timepicker', apply_filters( 'custom_metadata_manager_timepicker_js', CUSTOM_METADATA_MANAGER_URL . 'js/jquery-ui-timepicker.min.js' ), array( 'jquery', 'jquery-ui-datepicker' ), CUSTOM_METADATA_MANAGER_TIMEPICKER_VERSION, true );
320 wp_enqueue_script( 'custom-metadata-manager-js', apply_filters( 'custom_metadata_manager_default_js', CUSTOM_METADATA_MANAGER_URL . 'js/custom-metadata-manager.js' ), array( 'jquery', 'jquery-ui-datepicker', 'select2' ), CUSTOM_METADATA_MANAGER_VERSION, true );
321 wp_enqueue_script( 'wp-color-picker' );
322 }
323
324 /**
325 * Enqueues the styles used by the metadata fields.
326 *
327 * @return void
328 */
329 public function enqueue_styles() {
330 wp_enqueue_style( 'wp-jquery-ui-dialog' );
331 wp_enqueue_style( 'editor-buttons' );
332 wp_enqueue_style( 'custom-metadata-manager-css', apply_filters( 'custom_metadata_manager_default_css', CUSTOM_METADATA_MANAGER_URL . 'css/custom-metadata-manager.css' ), array(), CUSTOM_METADATA_MANAGER_VERSION );
333 wp_enqueue_style( 'jquery-ui-datepicker', apply_filters( 'custom_metadata_manager_jquery_ui_css', CUSTOM_METADATA_MANAGER_URL . 'css/jquery-ui-smoothness.css' ), array(), CUSTOM_METADATA_MANAGER_VERSION );
334 wp_enqueue_style( 'select2', apply_filters( 'custom_metadata_manager_select2_css', CUSTOM_METADATA_MANAGER_URL . 'css/select2.css' ), array(), CUSTOM_METADATA_MANAGER_SELECT2_VERSION );
335 wp_enqueue_style( 'wp-color-picker' );
336 }
337
338 /**
339 * Adds the custom field column headers to a list table.
340 *
341 * @param array $columns Existing column headers.
342 * @return array Column headers with the custom fields added.
343 */
344 public function add_metadata_column_headers( $columns ) {
345
346 $object_type = $this->_get_object_type_context();
347
348 if ( $object_type ) {
349 $fields = $this->get_fields_in_object_type( $object_type );
350
351 foreach ( $fields as $field_slug => $field ) {
352 if ( $this->is_field_addable_to_columns( $field_slug, $field ) ) {
353 $columns[ $field_slug ] = is_string( $field->display_column ) ? $field->display_column : $field->label;
354 }
355 }
356 }
357 return $columns;
358 }
359
360 /**
361 * Returns the custom column content for a user row.
362 *
363 * @param string $param Existing column content.
364 * @param string $name Column (field) slug.
365 * @param int $object_id User ID.
366 * @return string Column content.
367 */
368 public function add_user_metadata_column_content( $param, $name, $object_id ) {
369 return $this->add_metadata_column_content( $name, $object_id, $param );
370 }
371
372 /**
373 * Outputs or returns the custom column content for a field.
374 *
375 * @param string $name Column (field) slug.
376 * @param int $object_id Object ID.
377 * @param string $column_content Existing column content.
378 * @return string|void Column content, or void when it is echoed.
379 */
380 public function add_metadata_column_content( $name, $object_id, $column_content = '' ) {
381
382 $object_type = $this->_get_object_type_context();
383 $field_slug = $name;
384
385 if ( $this->is_registered_object_type( $object_type ) && $this->is_registered_field( $field_slug, null, $object_type ) ) {
386 $field = $this->get_field( $field_slug, null, $object_type );
387 $column_content = $this->_metadata_column_content( $field_slug, $field, $object_type, $object_id );
388 }
389
390 if ( $column_content && ! in_array( $object_type, $this->_column_filter_object_types ) ) {
391 echo $column_content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by _metadata_column_content(), or by the field's display_column_callback.
392 } else {
393 return $column_content;
394 }
395 }
396
397 /**
398 * Registers a metadata field for one or more object types.
399 *
400 * @param string $field_slug Unique slug for the field.
401 * @param array|string $object_types Object type(s) the field applies to.
402 * @param array $args Field arguments.
403 * @return void
404 */
405 public function add_metadata_field( $field_slug, $object_types = array( 'post' ), $args = array() ) {
406 static $localized_strings;
407
408 if ( ! $localized_strings ) {
409 $localized_strings = (object) array(
410 'upload_modal_title' => __( 'Choose a file', 'custom-metadata' ), // upload modal title (for upload field only).
411 'upload_modal_button_text' => __( 'Select this file', 'custom-metadata' ), // upload modal button text (for upload field only).
412 'upload_clear_button_text' => __( 'Clear', 'custom-metadata' ), // upload clear field text (for upload field only).
413 'link_modal_button_text' => __( 'Select', 'custom-metadata' ), // link field button text.
414 );
415 }
416
417 $defaults = array(
418 'group' => '', // To which meta_box the field should be added.
419 'multifield' => false, // which multifield does this field belong to, if any.
420 'field_type' => 'text', // The type of field; possibly values: text, checkbox, radio, select, image.
421 'label' => $field_slug, // Label for the field.
422 'slug' => $field_slug, // Slug for the field.
423 'description' => '', // Description of the field, displayed below the input.
424 'values' => array(), // values for select, checkbox, radio buttons.
425 'default_value' => '', // default value.
426 'placeholder' => '',
427 'display_callback' => '', // function to custom render the input.
428 'sanitize_callback' => '',
429 'display_column' => false, // Add the field to the columns when viewing all posts.
430 'display_column_callback' => '',
431 'add_to_quick_edit' => false, // (post only) Add the field to Quick edit
432 'required_cap' => false, // the cap required to view and edit the field.
433 'multiple' => false, // can the field be duplicated with a click of a button.
434 'readonly' => false, // makes the field be readonly.
435 'select2' => false, // applies select2.js (work on select and multi select field types).
436 'min' => false, // a minimum value (for number field only).
437 'max' => false, // a maximum value (for number field only).
438 'upload_modal_title' => $localized_strings->upload_modal_title,
439 'upload_modal_button_text' => $localized_strings->upload_modal_button_text,
440 'upload_clear_button_text' => $localized_strings->upload_clear_button_text,
441 'link_modal_button_text' => $localized_strings->link_modal_button_text,
442 );
443
444 // upload field is readonly by default (can be set explicitly to false though).
445 if ( ! empty( $args['field_type'] ) && in_array( $args['field_type'], $this->_field_types_that_are_read_only ) ) {
446 $defaults['readonly'] = true;
447 }
448
449 // `chosen` arg is the same as `select2` arg
450 if ( isset( $args['chosen'] ) ) {
451 $args['select2'] = $args['chosen'];
452 unset( $args['chosen'] );
453 }
454
455 // Merge defaults with args.
456 $field = wp_parse_args( $args, $defaults );
457 $field = (object) $field;
458
459 // Sanitize slug.
460 $field_slug = sanitize_key( $field_slug );
461 $group_slug = sanitize_key( $field->group );
462
463 // Check to see if the user should see this field.
464 if ( ! empty( $field->required_cap ) && ! current_user_can( $field->required_cap ) ) {
465 return;
466 }
467
468 $field = apply_filters( 'custom_metadata_manager_add_metadata_field', $field, $field_slug, $group_slug, $object_types );
469
470 if ( ! $this->_validate_metadata_field( $field_slug, $field, $group_slug, $object_types ) ) {
471 return;
472 }
473
474 $this->add_field_to_group( $field_slug, $field, $group_slug, $object_types );
475 }
476
477 /**
478 * Registers a multifield, a repeatable group of fields, for one or more object types.
479 *
480 * @param string $slug Unique slug for the multifield.
481 * @param array|string $object_types Object type(s) the multifield applies to.
482 * @param array $args Multifield arguments.
483 * @return void
484 */
485 public function add_multifield( $slug, $object_types = array( 'post' ), $args = array() ) {
486
487 $defaults = array(
488 'group' => '', // To which meta_box the multifield should be added.
489 'label' => $slug, // Label for the multifield.
490 'description' => '', // Description of the multifield, displayed below all the fields.
491 'required_cap' => false, // the cap required to view and edit the multifield.
492 );
493
494 // Merge defaults with args.
495 $multifield = wp_parse_args( $args, $defaults );
496 $multifield['multifield'] = true; // force it.
497 $multifield = (object) $multifield;
498
499 // Sanitize slug.
500 $slug = sanitize_key( $slug );
501 $group_slug = sanitize_key( $multifield->group );
502
503 // Check to see if the user should see this field.
504 if ( ! empty( $multifield->required_cap ) && ! current_user_can( $multifield->required_cap ) ) {
505 return;
506 }
507
508 $multifield = apply_filters( 'custom_metadata_manager_add_multifield', $multifield, $slug, $group_slug, $object_types );
509
510 if ( ! $this->_validate_metadata_field( $slug, $multifield, $group_slug, $object_types ) ) {
511 return;
512 }
513
514 // Add to group.
515 $this->add_multifield_to_group( $slug, $multifield, $group_slug, $object_types );
516 }
517
518 /**
519 * Registers a metadata group (meta box) for one or more object types.
520 *
521 * @param string $group_slug Unique slug for the group.
522 * @param array|string $object_types Object type(s) the group applies to.
523 * @param array $args Group arguments.
524 * @return void
525 */
526 public function add_metadata_group( $group_slug, $object_types, $args = array() ) {
527
528 $defaults = array(
529 'label' => $group_slug, // Label for the group.
530 'description' => '', // Description of the group.
531 'context' => 'normal', // (post only)
532 'priority' => 'default', // (post only)
533 'autosave' => false, // (post only) Should the group be saved in autosave?
534 'required_cap' => false, // the cap required to view and edit the group.
535 );
536
537 // Merge defaults with args.
538 $group = wp_parse_args( $args, $defaults );
539 $group = (object) $group;
540
541 // Sanitize slug.
542 $group_slug = sanitize_key( $group_slug );
543
544 $group = apply_filters( 'custom_metadata_manager_add_metadata_group', $group, $group_slug, $object_types );
545
546 // Check to see if the user has caps to view/edit this group.
547 if ( ! empty( $group->required_cap ) && ! current_user_can( $group->required_cap ) ) {
548 return;
549 }
550
551 if ( ! $this->_validate_metadata_group( $group_slug, $group, $object_types ) ) {
552 return;
553 }
554
555 $this->add_group_to_object_type( $group_slug, $group, $object_types );
556 }
557
558
559 /**
560 * Adds a field to a group, creating the group first if it does not exist.
561 *
562 * @param string $field_slug Field slug.
563 * @param object $field Field arguments.
564 * @param string $group_slug Group slug.
565 * @param array|string $object_types Object type(s) the field applies to.
566 * @return void
567 */
568 public function add_field_to_group( $field_slug, $field, $group_slug, $object_types ) {
569 $object_types = (array) $object_types;
570
571 foreach ( $object_types as $object_type ) {
572 if ( ! $group_slug ) {
573 $group_slug = sprintf( 'single-group-%1$s-%2$s', $object_type, $field_slug );
574 }
575
576 // If group doesn't exist, create group.
577 if ( ! $this->is_registered_group( $group_slug, $object_type ) ) {
578 $this->add_metadata_group( $group_slug, $object_type, array( 'label' => ( ! empty( $field->label ) ) ? $field->label : $field_slug ) );
579 $field->group = $group_slug;
580 }
581
582 $this->_push_field( $field_slug, $field, $group_slug, $object_type );
583 }
584 }
585
586 /**
587 * Adds a multifield to a group, creating the group first if it does not exist.
588 *
589 * @param string $slug Multifield slug.
590 * @param object $multifield Multifield arguments.
591 * @param string $group_slug Group slug.
592 * @param array|string $object_types Object type(s) the multifield applies to.
593 * @return void
594 */
595 public function add_multifield_to_group( $slug, $multifield, $group_slug, $object_types ) {
596 $object_types = (array) $object_types;
597
598 foreach ( $object_types as $object_type ) {
599 if ( ! $group_slug ) {
600 $group_slug = sprintf( 'single-group-%1$s-%2$s', $object_type, $slug );
601 }
602
603 // If group doesn't exist, create group.
604 if ( ! $this->is_registered_group( $group_slug, $object_type ) ) {
605 $this->add_metadata_group( $group_slug, $object_type, array( 'label' => ( ! empty( $multifield->label ) ) ? $multifield->label : $slug ) );
606 $multifield->group = $group_slug;
607 }
608
609 $this->_push_multifield( $slug, $multifield, $group_slug, $object_type );
610 }
611 }
612
613 /**
614 * Registers a group against each of the given object types.
615 *
616 * @param string $group_slug Group slug.
617 * @param object $group Group arguments.
618 * @param array|string $object_types Object type(s) the group applies to.
619 * @return void
620 */
621 public function add_group_to_object_type( $group_slug, $group, $object_types ) {
622 $object_types = (array) $object_types;
623
624 foreach ( $object_types as $object_type ) {
625 if ( ( $this->is_registered_object_type( $object_type ) && ! $this->is_group_in_object_type( $group_slug, $object_type ) ) ) {
626 $group->fields = array();
627 $this->_push_group( $group_slug, $group, $object_type );
628 }
629 }
630 }
631
632 /**
633 * Validates a group before it is registered.
634 *
635 * @param string $group_slug Group slug.
636 * @param object $group Group arguments.
637 * @param string $object_type Object type.
638 * @return bool Whether the group is valid.
639 */
640 public function _validate_metadata_group( $group_slug, $group, $object_type ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters are the inputs for the pending validation implementation (#153).
641 // Registration validation is not yet implemented; see https://github.com/Automattic/custom-metadata/issues/153.
642 return true;
643 }
644
645 /**
646 * Validates a field before it is registered.
647 *
648 * @param string $field_slug Field slug.
649 * @param object $field Field arguments.
650 * @param string $group_slug Group slug.
651 * @param array|string $object_types Object type(s) the field applies to.
652 * @return bool Whether the field is valid.
653 */
654 public function _validate_metadata_field( $field_slug, $field, $group_slug, $object_types ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters are the inputs for the pending validation implementation (#153).
655 // Registration validation is not yet implemented; see https://github.com/Automattic/custom-metadata/issues/153.
656 return true;
657 }
658
659 /**
660 * Stores a registration error for later display.
661 *
662 * @param string $field_slug Field slug the error relates to.
663 * @param string $error_message Error message.
664 * @return void
665 */
666 public function _add_registration_error( $field_slug, $error_message ) {
667 $this->errors[] = sprintf( '<strong>%1$s:</strong> %2$s', $field_slug, $error_message );
668 }
669
670 /**
671 * Adds the registered groups as meta boxes to the current post or comment.
672 *
673 * @return void
674 */
675 public function add_post_metadata_groups() {
676 global $post, $comment;
677
678 $object_id = 0;
679
680 if ( isset( $post ) ) {
681 $object_id = $post->ID;
682 } elseif ( isset( $comment ) ) {
683 $object_id = $comment->comment_ID;
684 }
685 $object_type = $this->_get_object_type_context();
686
687 $groups = $this->get_groups_in_object_type( $object_type );
688
689 if ( $object_id && ! empty( $groups ) ) {
690 foreach ( $groups as $group_slug => $group ) {
691 $this->add_post_metadata_group( $group_slug, $group, $object_type, $object_id );
692 }
693 }
694 }
695
696 /**
697 * Registers a single meta box for a group on a post or comment.
698 *
699 * @param string $group_slug Group slug.
700 * @param object $group Group arguments.
701 * @param string $object_type Object type.
702 * @param int $object_id Object ID.
703 * @return void
704 */
705 public function add_post_metadata_group( $group_slug, $group, $object_type, $object_id ) {
706
707 $fields = $this->get_fields_in_group( $group_slug, $object_type );
708
709 if ( ! empty( $fields ) && $this->is_thing_added_to_object( $group_slug, $group, $object_type, $object_id ) ) {
710 add_meta_box(
711 $group_slug,
712 $group->label,
713 array( $this, '_display_post_metadata_box' ),
714 $object_type,
715 $group->context,
716 $group->priority,
717 array(
718 'group' => $group,
719 'fields' => $fields,
720 )
721 );
722 }
723 }
724
725 /**
726 * Displays the registered groups on the user profile screen.
727 *
728 * @return void
729 */
730 public function add_user_metadata_groups() {
731 global $user_id;
732
733 if ( ! $user_id ) {
734 return;
735 }
736
737 $object_type = 'user';
738
739 $groups = $this->get_groups_in_object_type( $object_type );
740
741 if ( ! empty( $groups ) ) {
742 foreach ( $groups as $group_slug => $group ) {
743 $this->add_user_metadata_group( $group_slug, $group, $object_type, $user_id );
744 }
745 }
746 }
747
748 /**
749 * Displays a single group of fields for a user, if any fields apply.
750 *
751 * @param string $group_slug Group slug.
752 * @param object $group Group arguments.
753 * @param string $object_type Object type.
754 * @param int $user_id User ID.
755 * @return void
756 */
757 public function add_user_metadata_group( $group_slug, $group, $object_type, $user_id ) {
758 $fields = $this->get_fields_in_group( $group_slug, $object_type );
759
760 if ( ! empty( $fields ) && $this->is_thing_added_to_object( $group_slug, $group, $object_type, $user_id ) ) {
761 $this->_display_user_metadata_box( $group_slug, $group, $object_type, $fields );
762 }
763 }
764
765
766 /**
767 * Renders the fields of a group on the user profile screen.
768 *
769 * @param string $group_slug Group slug.
770 * @param object $group Group arguments.
771 * @param string $object_type Object type.
772 * @param array $fields Fields in the group.
773 * @return void
774 */
775 public function _display_user_metadata_box( $group_slug, $group, $object_type, $fields ) {
776 global $user_id;
777 ?>
778 <h3><?php echo esc_html( $group->label ); ?></h3>
779
780 <table class="form-table user-metadata-group">
781 <?php foreach ( $fields as $field_slug => $field ) : ?>
782 <?php if ( $this->is_thing_added_to_object( $field_slug, $field, $object_type, $user_id ) ) : ?>
783 <tr valign="top">
784 <td scope="row">
785 <?php $this->_display_metadata_field( $field_slug, $field, $object_type, $user_id ); ?>
786 </td>
787 </tr>
788 <?php endif; ?>
789 <?php endforeach; ?>
790 </table>
791 <?php
792
793 $this->_display_group_nonce( $group_slug, $object_type );
794 }
795
796 /**
797 * Renders the fields of a group inside a post or comment meta box.
798 *
799 * @param object $wp_object The post or comment object being edited.
800 * @param array $meta_box Meta box arguments, including the group and its fields.
801 * @return void
802 */
803 public function _display_post_metadata_box( $wp_object, $meta_box ) {
804
805 $group_slug = $meta_box['id'];
806 $group = $meta_box['args']['group'];
807 $fields = $meta_box['args']['fields'];
808 $object_type = $this->_get_object_type_context();
809
810 // I really don't like using variable variables, but this is the path of least resistence.
811 if ( isset( $wp_object->{$object_type . '_ID'} ) ) {
812 $object_id = $wp_object->{$object_type . '_ID'};
813 } elseif ( isset( $wp_object->ID ) ) {
814 $object_id = $wp_object->ID;
815 } else {
816 _e( 'Uh oh, something went wrong!', 'custom-metadata' );
817 return;
818 }
819
820 $this->_display_group_description( $group );
821
822 foreach ( $fields as $field_slug => $field ) {
823 if ( $this->is_thing_added_to_object( $field_slug, $field, $object_type, $object_id ) ) {
824 if ( $this->_is_multifield( $field_slug ) ) {
825 $this->_display_metadata_multifield( $field_slug, $field, $object_type, $object_id );
826 } elseif ( empty( $field->multifield ) ) {
827 $this->_display_metadata_field( $field_slug, $field, $object_type, $object_id );
828 }
829 }
830 }
831
832 // Each group gets its own nonce.
833 $this->_display_group_nonce( $group_slug, $object_type );
834 }
835
836 /**
837 * Outputs the description for a group, if one is set.
838 *
839 * @param object $group Group arguments.
840 * @return void
841 */
842 public function _display_group_description( $group ) {
843 if ( ! empty( $group->description ) ) {
844 printf( '<div class="custom-metadata-group-description description">%s</div>', wp_kses_post( $group->description ) );
845 }
846 }
847
848 /**
849 * Outputs a nonce field for a group.
850 *
851 * @param string $group_slug Group slug.
852 * @param string $object_type Object type.
853 * @return void
854 */
855 public function _display_group_nonce( $group_slug, $object_type ) {
856 $nonce_key = $this->build_nonce_key( $group_slug, $object_type );
857 wp_nonce_field( 'save-metadata', $nonce_key, false );
858 }
859
860 /**
861 * Verifies the nonce submitted for a group.
862 *
863 * @param string $group_slug Group slug.
864 * @param string $object_type Object type.
865 * @return int|false The nonce check result, or false if the nonce is missing.
866 */
867 public function verify_group_nonce( $group_slug, $object_type ) {
868 $nonce_key = $this->build_nonce_key( $group_slug, $object_type );
869 if ( isset( $_POST[ $nonce_key ] ) ) {
870 return wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ $nonce_key ] ) ), 'save-metadata' );
871 } else {
872 return false;
873 }
874 }
875
876 /**
877 * Builds the nonce key for a group.
878 *
879 * @param string $group_slug Group slug.
880 * @param string $object_type Object type.
881 * @return string The nonce key.
882 */
883 public function build_nonce_key( $group_slug, $object_type ) {
884 return sprintf( 'metadata-%1$s-%2$s', $object_type, $group_slug );
885 }
886
887 /**
888 * Saves the submitted metadata for a user.
889 *
890 * @param int $user_id User ID.
891 * @return void
892 */
893 public function save_user_metadata( $user_id ) {
894 $object_type = 'user';
895 $groups = $this->get_groups_in_object_type( $object_type );
896
897 foreach ( $groups as $group_slug => $group ) {
898 $this->save_metadata_group( $group_slug, $group, $object_type, $user_id );
899 }
900 }
901
902 /**
903 * Saves the submitted metadata for a post.
904 *
905 * @param int $post_id Post ID.
906 * @return int|void The post ID during autosave, otherwise void.
907 */
908 public function save_post_metadata( $post_id ) {
909 if ( wp_is_post_revision( $post_id ) ) {
910 return;
911 }
912 $post_type = $this->_get_object_type_context();
913 $groups = $this->get_groups_in_object_type( $post_type );
914
915 foreach ( $groups as $group_slug => $group ) {
916 if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE && ! $group->autosave ) {
917 return $post_id;
918 }
919
920 $this->save_metadata_group( $group_slug, $group, $post_type, $post_id );
921 }
922 }
923
924 /**
925 * Saves the submitted metadata for a comment.
926 *
927 * @param int $comment_id Comment ID.
928 * @return void
929 */
930 public function save_comment_metadata( $comment_id ) {
931 $object_type = 'comment';
932 $groups = $this->get_groups_in_object_type( $object_type );
933
934 foreach ( $groups as $group_slug => $group ) {
935 $this->save_metadata_group( $group_slug, $group, $object_type, $comment_id );
936 }
937 }
938
939 /**
940 * Saves every field in a group after verifying its nonce.
941 *
942 * @param string $group_slug Group slug.
943 * @param object $group Group arguments.
944 * @param string $object_type Object type.
945 * @param int $object_id Object ID.
946 * @return int|void The object ID if the nonce fails, otherwise void.
947 */
948 public function save_metadata_group( $group_slug, $group, $object_type, $object_id ) {
949 if ( ! $this->verify_group_nonce( $group_slug, $object_type ) ) {
950 return $object_id;
951 }
952
953 // The nonce only proves the request came from a form this user was shown; it is not an
954 // authorization check. Confirm the user may actually edit this object before saving.
955 switch ( $object_type ) {
956 case 'user':
957 $capability = 'edit_user';
958 break;
959 case 'comment':
960 $capability = 'edit_comment';
961 break;
962 default:
963 $capability = 'edit_post'; // every registered post type maps through the edit_post meta cap.
964 break;
965 }
966
967 if ( ! current_user_can( $capability, $object_id ) ) {
968 return $object_id;
969 }
970
971 $fields = $this->get_fields_in_group( $group_slug, $object_type );
972
973 foreach ( $fields as $field_slug => $field ) {
974 if ( true === $field->multifield ) {
975 $this->save_metadata_multifield( $field_slug, $field, $object_type, $object_id );
976 } elseif ( ! $field->multifield ) {
977 $this->save_metadata_field( $field_slug, $field, $object_type, $object_id );
978 }
979 }
980 }
981
982 /**
983 * Saves the submitted values for a multifield.
984 *
985 * @param string $slug Multifield slug.
986 * @param object $multifield Multifield arguments.
987 * @param string $object_type Object type.
988 * @param int $object_id Object ID.
989 * @return void
990 */
991 public function save_metadata_multifield( $slug, $multifield, $object_type, $object_id ) {
992
993 if ( isset( $_POST[ $slug ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce and capability are verified in save_metadata_group() before this runs.
994 $multifield_value = array();
995 $groupings = wp_unslash( $_POST[ $slug ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified upstream; each value is sanitised below by _sanitize_field_value().
996 $fields = $this->get_fields_in_multifield( $multifield->group, $slug, $object_type );
997 foreach ( $groupings as $grouping ) {
998 $grouping_values = array();
999 foreach ( $fields as $field_slug => $field ) {
1000 if ( ! empty( $grouping[ $field_slug ] ) ) {
1001 $grouping_values[ $field_slug ] = $this->_sanitize_field_value( $field_slug, $field, $object_type, $object_id, $grouping[ $field_slug ] );
1002 } else {
1003 $grouping_values[ $field_slug ] = '';
1004 }
1005 }
1006 $multifield_value[] = $grouping_values;
1007 }
1008
1009 $slug = sanitize_key( $slug );
1010
1011 if ( ! in_array( $object_type, $this->_non_post_types ) ) {
1012 $object_type = 'post';
1013 }
1014
1015 update_metadata( $object_type, $object_id, $slug, $multifield_value );
1016 } else {
1017 $slug = sanitize_key( $slug );
1018
1019 if ( ! in_array( $object_type, $this->_non_post_types ) ) {
1020 $object_type = 'post';
1021 }
1022
1023 delete_metadata( $object_type, $object_id, $slug );
1024 }
1025 }
1026
1027 /**
1028 * Saves or deletes the submitted value for a single field.
1029 *
1030 * @param string $field_slug Field slug.
1031 * @param object $field Field arguments.
1032 * @param string $object_type Object type.
1033 * @param int $object_id Object ID.
1034 * @return void
1035 */
1036 public function save_metadata_field( $field_slug, $field, $object_type, $object_id ) {
1037 if ( isset( $_POST[ $field_slug ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce and capability are verified in save_metadata_group() before this runs.
1038 $value = $this->_sanitize_field_value( $field_slug, $field, $object_type, $object_id, wp_unslash( $_POST[ $field_slug ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified upstream; sanitised by _sanitize_field_value().
1039 $this->_save_field_value( $field_slug, $field, $object_type, $object_id, $value );
1040
1041
1042 // save the attachment ID of the upload field as well.
1043 if ( 'upload' == $field->field_type && isset( $_POST[ $field_slug . '_attachment_id' ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in save_metadata_group() before this runs.
1044 $this->_save_field_value( $field_slug . '_attachment_id', $field, $object_type, $object_id, absint( $_POST[ $field_slug . '_attachment_id' ] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified upstream; sanitised with absint().
1045 }
1046 } else {
1047 $this->_delete_field_value( $field_slug, $field, $object_type, $object_id );
1048
1049 // delete the attachment ID of the upload field as well.
1050 if ( 'upload' == $field->field_type && isset( $_POST[ $field_slug . '_attachment_id' ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in save_metadata_group() before this runs.
1051 $this->_delete_field_value( $field_slug . '_attachment_id', $field, $object_type, $object_id );
1052 }
1053 }
1054 }
1055
1056 /**
1057 * Gets the stored value for a multifield.
1058 *
1059 * @param string $slug Multifield slug.
1060 * @param object $multifield Multifield arguments.
1061 * @param string $object_type Object type.
1062 * @param int $object_id Object ID.
1063 * @return mixed The stored multifield value.
1064 */
1065 public function get_metadata_mulitifield_value( $slug, $multifield, $object_type, $object_id ) {
1066 return $this->_get_field_value( $slug, $multifield, $object_type, $object_id, true );
1067 }
1068
1069 /**
1070 * Gets the stored value for a field.
1071 *
1072 * @param string $field_slug Field slug.
1073 * @param object $field Field arguments.
1074 * @param string $object_type Object type.
1075 * @param int $object_id Object ID.
1076 * @return mixed The stored field value.
1077 */
1078 public function get_metadata_field_value( $field_slug, $field, $object_type, $object_id ) {
1079 return $this->_get_field_value( $field_slug, $field, $object_type, $object_id );
1080 }
1081
1082 /**
1083 * Determines whether an object type has been registered.
1084 *
1085 * @param string $object_type Object type.
1086 * @return bool Whether the object type is registered.
1087 */
1088 public function is_registered_object_type( $object_type ) {
1089 return array_key_exists( $object_type, $this->metadata );
1090 }
1091
1092 /**
1093 * Determines whether a group is registered for an object type.
1094 *
1095 * @param string $group_slug Group slug.
1096 * @param string $object_type Object type.
1097 * @return bool Whether the group is registered.
1098 */
1099 public function is_registered_group( $group_slug, $object_type ) {
1100 return $this->is_registered_object_type( $object_type ) && array_key_exists( $group_slug, $this->get_groups_in_object_type( $object_type ) );
1101 }
1102
1103 /**
1104 * Determines whether a field is registered.
1105 *
1106 * The $object_type parameter carries a default only so it can follow the
1107 * optional $group_slug without tripping PHP 8.0's "optional parameter before
1108 * required" deprecation. It stays mandatory in practice: omitting it throws,
1109 * because the parameter order is fixed for backwards compatibility and cannot
1110 * be reordered without breaking external callers.
1111 *
1112 * @param string $field_slug Field slug.
1113 * @param string $group_slug Optional group slug to check within.
1114 * @param string $object_type Object type. Required despite the default.
1115 * @throws \InvalidArgumentException When $object_type is empty.
1116 * @return bool Whether the field is registered.
1117 */
1118 public function is_registered_field( $field_slug, $group_slug = '', $object_type = '' ) {
1119 if ( empty( $object_type ) ) {
1120 throw new \InvalidArgumentException( '$object_type is required for ' . __METHOD__ . '().' );
1121 }
1122
1123 if ( $group_slug ) {
1124 return $this->is_registered_group( $group_slug, $object_type ) && array_key_exists( $field_slug, $this->get_fields_in_group( $group_slug, $object_type ) );
1125 } else {
1126 return array_key_exists( $field_slug, $this->get_fields_in_object_type( $object_type ) );
1127 }
1128 }
1129
1130 /**
1131 * Determines whether a field belongs to a group.
1132 *
1133 * @param string $field_slug Field slug.
1134 * @param string $group_slug Group slug.
1135 * @param string $object_type Object type.
1136 * @return bool Whether the field is in the group.
1137 */
1138 public function is_field_in_group( $field_slug, $group_slug, $object_type ) {
1139 return in_array( $field_slug, $this->get_fields_in_group( $group_slug, $object_type ) );
1140 }
1141
1142 /**
1143 * Determines whether a group belongs to an object type.
1144 *
1145 * @param string $group_slug Group slug.
1146 * @param string $object_type Object type.
1147 * @return bool Whether the group is in the object type.
1148 */
1149 public function is_group_in_object_type( $group_slug, $object_type ) {
1150 return array_key_exists( $group_slug, $this->get_groups_in_object_type( $object_type ) );
1151 }
1152
1153 /**
1154 * Determines whether a field can be shown as a list table column.
1155 *
1156 * @param string $field_slug Field slug.
1157 * @param object $field Field arguments.
1158 * @return bool Whether the field can be added to the columns.
1159 */
1160 public function is_field_addable_to_columns( $field_slug, $field ) {
1161 return is_string( $field->display_column ) || ( is_bool( $field->display_column ) && $field->display_column );
1162 }
1163
1164 /**
1165 * Gets a registered field.
1166 *
1167 * @param string $field_slug Field slug.
1168 * @param string $group_slug Group slug.
1169 * @param string $object_type Object type.
1170 * @return object|null The field arguments, or null if not found.
1171 */
1172 public function get_field( $field_slug, $group_slug, $object_type ) {
1173 if ( $this->is_registered_field( $field_slug, $group_slug, $object_type ) ) {
1174 if ( $group_slug ) {
1175 return $this->get_single_field_in_group( $field_slug, $group_slug, $object_type );
1176 } else {
1177 return $this->get_single_field_in_object_type( $field_slug, $object_type );
1178 }
1179 }
1180 return null;
1181 }
1182
1183 /**
1184 * Gets a registered group.
1185 *
1186 * @param string $group_slug Group slug.
1187 * @param string $object_type Object type.
1188 * @return object|null The group arguments, or null if not found.
1189 */
1190 public function get_group( $group_slug, $object_type ) {
1191 if ( $this->is_registered_group( $group_slug, $object_type ) ) {
1192 $groups = $this->get_groups_in_object_type( $object_type );
1193 $group = $groups[ $group_slug ];
1194 return $group;
1195 }
1196 return null;
1197 }
1198
1199 /**
1200 * Gets all registered object types.
1201 *
1202 * @return array The registered object types.
1203 */
1204 public function get_object_types() {
1205 return array_keys( $this->metadata );
1206 }
1207
1208 /**
1209 * Gets the groups registered for an object type.
1210 *
1211 * @param string $object_type Object type.
1212 * @return array The groups in the object type.
1213 */
1214 public function get_groups_in_object_type( $object_type ) {
1215 if ( $this->is_registered_object_type( $object_type ) ) {
1216 return $this->metadata[ $object_type ];
1217 }
1218 return array();
1219 }
1220
1221 /**
1222 * Gets a single field from a group.
1223 *
1224 * @param string $field_slug Field slug.
1225 * @param string $group_slug Group slug.
1226 * @param string $object_type Object type.
1227 * @return object|null The field arguments, or null if not found.
1228 */
1229 public function get_single_field_in_group( $field_slug, $group_slug, $object_type ) {
1230 $fields = $this->get_fields_in_group( $group_slug, $object_type );
1231 return isset( $fields[ $field_slug ] ) ? $fields[ $field_slug ] : null;
1232 }
1233
1234 /**
1235 * Gets the fields registered in a group.
1236 *
1237 * @param string $group_slug Group slug.
1238 * @param string $object_type Object type.
1239 * @return array The fields in the group.
1240 */
1241 public function get_fields_in_group( $group_slug, $object_type ) {
1242 $group = $this->get_group( $group_slug, $object_type );
1243 if ( $group ) {
1244 return (array) $group->fields;
1245 }
1246 return array();
1247 }
1248
1249 /**
1250 * Gets the fields that belong to a multifield within a group.
1251 *
1252 * @param string $group_slug Group slug.
1253 * @param string $multifield_slug Multifield slug.
1254 * @param string $object_type Object type.
1255 * @return array The fields in the multifield.
1256 */
1257 public function get_fields_in_multifield( $group_slug, $multifield_slug, $object_type ) {
1258 $group = $this->get_group( $group_slug, $object_type );
1259 $fields_in_multifield = array();
1260 if ( empty( $group ) || empty( $group->fields ) || empty( $group->fields[ $multifield_slug ] ) ) {
1261 return $fields_in_multifield;
1262 }
1263
1264 $_multifields = wp_list_pluck( $group->fields, 'multifield' );
1265 foreach ( $_multifields as $_field_key => $_multifield ) {
1266 if ( empty( $_multifield ) || true === $_multifield ) {
1267 continue;
1268 }
1269
1270 if ( $multifield_slug == $_multifield || '_x_multifield_' . $_multifield == $multifield_slug ) {
1271 $fields_in_multifield[ $_field_key ] = $group->fields[ $_field_key ];
1272 }
1273 }
1274
1275 return $fields_in_multifield;
1276 }
1277
1278 /**
1279 * Gets a single field from an object type, regardless of group.
1280 *
1281 * @param string $field_slug Field slug.
1282 * @param string $object_type Object type.
1283 * @return object|null The field arguments, or null if not found.
1284 */
1285 public function get_single_field_in_object_type( $field_slug, $object_type ) {
1286 $fields = $this->get_fields_in_object_type( $object_type );
1287 return isset( $fields[ $field_slug ] ) ? $fields[ $field_slug ] : null;
1288 }
1289
1290 /**
1291 * Gets all fields registered for an object type, across all groups.
1292 *
1293 * @param string $object_type Object type.
1294 * @return array The fields in the object type.
1295 */
1296 public function get_fields_in_object_type( $object_type ) {
1297 $fields = array();
1298 foreach ( $this->get_groups_in_object_type( $object_type ) as $group_slug => $group ) {
1299 $fields = array_merge( $fields, $this->get_fields_in_group( $group_slug, $object_type ) );
1300 }
1301 return $fields;
1302 }
1303
1304 /**
1305 * Stores a group in the metadata store.
1306 *
1307 * @param string $group_slug Group slug.
1308 * @param object $group Group arguments.
1309 * @param string $object_type Object type.
1310 * @return void
1311 */
1312 public function _push_group( $group_slug, $group, $object_type ) {
1313 $this->metadata[ $object_type ][ $group_slug ] = $group;
1314 }
1315
1316 /**
1317 * Stores a field in the metadata store.
1318 *
1319 * @param string $field_slug Field slug.
1320 * @param object $field Field arguments.
1321 * @param string $group_slug Group slug.
1322 * @param string $object_type Object type.
1323 * @return void
1324 */
1325 public function _push_field( $field_slug, $field, $group_slug, $object_type ) {
1326 $this->metadata[ $object_type ][ $group_slug ]->fields[ $field_slug ] = $field;
1327 }
1328
1329 /**
1330 * Stores a multifield in the metadata store.
1331 *
1332 * @param string $slug Multifield slug.
1333 * @param object $multifield Multifield arguments.
1334 * @param string $group_slug Group slug.
1335 * @param string $object_type Object type.
1336 * @return void
1337 */
1338 public function _push_multifield( $slug, $multifield, $group_slug, $object_type ) {
1339 $this->metadata[ $object_type ][ $group_slug ]->fields[ '_x_multifield_' . $slug ] = $multifield;
1340 }
1341
1342 /**
1343 * Determines whether a multifield exists in a group for an object type.
1344 *
1345 * @param string $slug Multifield slug.
1346 * @param string $group_slug Group slug.
1347 * @param string $object_type Object type.
1348 * @return bool Whether the multifield exists.
1349 */
1350 public function _multifield_exists_for_group_object( $slug, $group_slug, $object_type ) {
1351 $slug = '_x_multifield_' . $slug;
1352 return (
1353 ! empty( $this->metadata[ $object_type ] ) &&
1354 ! empty( $this->metadata[ $object_type ][ $group_slug ] ) &&
1355 ! empty( $this->metadata[ $object_type ][ $group_slug ]->fields ) &&
1356 array_key_exists( $slug, $this->metadata[ $object_type ][ $group_slug ]->fields )
1357 );
1358 }
1359
1360 /**
1361 * Determines whether a slug refers to a multifield.
1362 *
1363 * @param string $slug Slug to check.
1364 * @return bool Whether the slug is a multifield.
1365 */
1366 public function _is_multifield( $slug ) {
1367 return ( 0 === strpos( $slug, '_x_multifield' ) );
1368 }
1369
1370 /**
1371 * Determines whether a field or group should be shown for a specific object, honouring include and exclude rules.
1372 *
1373 * @param string $thing_slug Field or group slug.
1374 * @param object $thing Field or group arguments.
1375 * @param string $object_type Object type.
1376 * @param int $object_id Object ID.
1377 * @param string $object_slug Optional object slug.
1378 * @return bool Whether the field or group applies to the object.
1379 */
1380 public function is_thing_added_to_object( $thing_slug, $thing, $object_type, $object_id, $object_slug = '' ) {
1381
1382 if ( isset( $thing->exclude ) ) {
1383 if ( is_callable( $thing->exclude ) ) {
1384 return ! (bool) call_user_func( $thing->exclude, $thing_slug, $thing, $object_type, $object_id, $object_slug );
1385 }
1386 return ! $this->does_id_array_match_object( $thing->exclude, $object_type, $object_id, $object_slug );
1387 }
1388
1389 if ( isset( $thing->include ) ) {
1390 if ( is_callable( $thing->include ) ) {
1391 return (bool) call_user_func( $thing->include, $thing_slug, $thing, $object_type, $object_id, $object_slug );
1392 }
1393 return $this->does_id_array_match_object( $thing->include, $object_type, $object_id, $object_slug );
1394 }
1395
1396 return true;
1397 }
1398
1399 /**
1400 * Determines whether an include or exclude definition matches an object.
1401 *
1402 * @param array|int|string $id_array Include or exclude definition.
1403 * @param string $object_type Object type.
1404 * @param int $object_id Object ID.
1405 * @param string $object_slug Optional object slug.
1406 * @return bool Whether the definition matches the object.
1407 */
1408 public function does_id_array_match_object( $id_array, $object_type, $object_id, $object_slug = '' ) {
1409 if ( is_array( $id_array ) ) {
1410 if ( isset( $id_array[ $object_type ] ) ) {
1411 if ( is_array( $id_array[ $object_type ] ) ) {
1412 // Object type keyed to an array of IDs and/or slugs.
1413 return $this->does_id_array_match_object( $id_array[ $object_type ], $object_type, $object_id, $object_slug );
1414 } else {
1415 // Object type keyed to a single ID.
1416 return $this->does_id_match_object( $id_array[ $object_type ], $object_id, $object_slug );
1417 }
1418 } else {
1419 // A flat list of IDs and/or slugs.
1420 $match = false;
1421 foreach ( $id_array as $id ) {
1422 if ( $this->does_id_match_object( $id, $object_id, $object_slug ) ) {
1423 $match = true;
1424 break;
1425 }
1426 }
1427 return $match;
1428 }
1429 } else {
1430 // A single scalar ID or slug.
1431 return $this->does_id_match_object( $id_array, $object_id, $object_slug );
1432 }
1433 }
1434
1435 /**
1436 * Determines whether a single ID or slug matches an object.
1437 *
1438 * @param int|string $id Object ID or slug to match.
1439 * @param int $object_id Object ID.
1440 * @param string $object_slug Optional object slug.
1441 * @return bool Whether the ID or slug matches the object.
1442 */
1443 public function does_id_match_object( $id, $object_id, $object_slug = '' ) {
1444 if ( is_int( $id ) ) {
1445 // Match against the numeric object ID.
1446 return $id == $object_id;
1447 } elseif ( is_string( $id ) ) {
1448 // Match against the object slug.
1449 return $id == $object_slug;
1450 }
1451 return false;
1452 }
1453
1454 /**
1455 * Determines whether a field slug is restricted for an object type.
1456 *
1457 * @param string $field_slug Field slug.
1458 * @param string $object_type Object type.
1459 * @return bool Whether the field is restricted.
1460 */
1461 public function is_restricted_field( $field_slug, $object_type ) {
1462 $post_restricted = array( 'post_title', 'post_author' );
1463 $page_restricted = array();
1464 $user_restricted = array();
1465
1466 switch ( $object_type ) {
1467 case 'user':
1468 return in_array( $field_slug, $user_restricted );
1469 case 'page':
1470 return in_array( $field_slug, $page_restricted ) || in_array( $field_slug, $post_restricted );
1471 case 'post':
1472 default:
1473 return in_array( $field_slug, $post_restricted );
1474 }
1475 return false;
1476 }
1477
1478 /**
1479 * Determines whether a group slug is restricted for an object type.
1480 *
1481 * @param string $group_slug Group slug.
1482 * @param string $object_type Object type.
1483 * @return bool Whether the group is restricted.
1484 */
1485 public function is_restricted_group( $group_slug, $object_type ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Parameters are the inputs for the pending restriction logic (#152).
1486 // Group restriction is not yet implemented; see https://github.com/Automattic/custom-metadata/issues/152.
1487 return false;
1488 }
1489
1490 /**
1491 * Determines the current object type from the admin screen context.
1492 *
1493 * @return string The current object type, or an empty string if it cannot be determined.
1494 */
1495 public function _get_object_type_context() {
1496 global $current_screen, $pagenow;
1497
1498 $object_type = '';
1499
1500 if ( 'profile.php' == $pagenow || 'user-edit.php' == $pagenow || 'users.php' == $pagenow ) {
1501 return 'user';
1502 }
1503
1504 if ( isset( $current_screen->post_type ) ) {
1505 $object_type = $current_screen->post_type;
1506 } elseif ( isset( $current_screen->base ) ) {
1507 foreach ( $this->_builtin_object_types as $builtin_type ) {
1508 if ( strpos( $current_screen->base, $builtin_type ) !== false ) {
1509 $object_type = $builtin_type;
1510 break;
1511 }
1512 }
1513 }
1514
1515 return $object_type;
1516 }
1517
1518 /**
1519 * Gets the callback used to read a field value, if any.
1520 *
1521 * @param object $field Field arguments.
1522 * @param string $object_type Object type.
1523 * @return callable|string The value callback, or an empty string if none applies.
1524 */
1525 public function _get_value_callback( $field, $object_type ) {
1526 $callback = isset( $field->value_callback ) ? $field->value_callback : '';
1527
1528 if ( ! ( $callback && is_callable( $callback ) ) ) {
1529 $callback = '';
1530 }
1531
1532 return apply_filters( 'custom_metadata_manager_get_value_callback', $callback, $field, $object_type );
1533 }
1534
1535 /**
1536 * Gets the callback used to save a field value, if any.
1537 *
1538 * @param object $field Field arguments.
1539 * @param string $object_type Object type.
1540 * @return callable|string The save callback, or an empty string if none applies.
1541 */
1542 public function _get_save_callback( $field, $object_type ) {
1543 $callback = isset( $field->save_callback ) ? $field->save_callback : '';
1544
1545 if ( ! ( $callback && is_callable( $callback ) ) ) {
1546 $callback = '';
1547 }
1548
1549 return apply_filters( 'custom_metadata_manager_get_save_callback', $callback, $field, $object_type );
1550 }
1551
1552 /**
1553 * Gets the callback used to sanitise a field value, if any.
1554 *
1555 * @param object $field Field arguments.
1556 * @param string $object_type Object type.
1557 * @return callable|string The sanitise callback, or an empty string if none applies.
1558 */
1559 public function get_sanitize_callback( $field, $object_type ) {
1560 $callback = $field->sanitize_callback;
1561
1562 if ( ! ( $callback && is_callable( $callback ) ) ) {
1563 $callback = '';
1564 }
1565
1566 return apply_filters( 'custom_metadata_manager_get_sanitize_callback', $callback, $field, $object_type );
1567 }
1568
1569 /**
1570 * Gets the callback used to render a field column, if any.
1571 *
1572 * @param object $field Field arguments.
1573 * @param string $object_type Object type.
1574 * @return callable|string The display column callback, or an empty string if none applies.
1575 */
1576 public function get_display_column_callback( $field, $object_type ) {
1577 $callback = $field->display_column_callback;
1578
1579 if ( ! ( $callback && is_callable( $callback ) ) ) {
1580 $callback = '';
1581 }
1582
1583 return apply_filters( 'custom_metadata_manager_get_display_column_callback', $callback, $field, $object_type );
1584 }
1585
1586 /**
1587 * Gets the stored value for a field, using its value callback if one is set.
1588 *
1589 * @param string $field_slug Field slug.
1590 * @param object $field Field arguments.
1591 * @param string $object_type Object type.
1592 * @param int $object_id Object ID.
1593 * @param bool $single Whether to return a single value.
1594 * @return mixed The field value.
1595 */
1596 public function _get_field_value( $field_slug, $field, $object_type, $object_id, $single = false ) {
1597
1598 $get_value_callback = $this->_get_value_callback( $field, $object_type );
1599
1600 if ( $get_value_callback ) {
1601 return call_user_func( $get_value_callback, $object_type, $object_id, $field_slug );
1602 }
1603
1604 if ( ! in_array( $object_type, $this->_non_post_types ) ) {
1605 $object_type = 'post';
1606 }
1607
1608 $value = get_metadata( $object_type, $object_id, $field_slug, $single );
1609
1610 return $value;
1611 }
1612
1613 /**
1614 * Saves the value for a field, using its save callback if one is set.
1615 *
1616 * @param string $field_slug Field slug.
1617 * @param object $field Field arguments.
1618 * @param string $object_type Object type.
1619 * @param int $object_id Object ID.
1620 * @param mixed $value Value to save.
1621 * @return mixed The save callback result, or void.
1622 */
1623 public function _save_field_value( $field_slug, $field, $object_type, $object_id, $value ) {
1624
1625 $save_callback = $this->_get_save_callback( $field, $object_type );
1626
1627 if ( $save_callback ) {
1628 return call_user_func( $save_callback, $object_type, $object_id, $field_slug, $value );
1629 }
1630
1631 if ( ! in_array( $object_type, $this->_non_post_types ) ) {
1632 $object_type = 'post';
1633 }
1634
1635 $field_slug = sanitize_key( $field_slug );
1636
1637 // save the taxonomy as a taxonomy [as well as a custom field].
1638 if ( in_array( $field->field_type, $this->_taxonomy_fields ) && ! in_array( $object_type, $this->_non_post_types ) ) {
1639 wp_set_object_terms( $object_id, $value, $field->taxonomy );
1640 }
1641
1642 if ( is_array( $value ) ) {
1643 // multiple values.
1644 delete_metadata( $object_type, $object_id, $field_slug ); // delete the old values and add the new ones.
1645 foreach ( $value as $v ) {
1646 add_metadata( $object_type, $object_id, $field_slug, $v, false );
1647 }
1648 } else {
1649 // single value.
1650 update_metadata( $object_type, $object_id, $field_slug, $value );
1651 }
1652
1653 // delete metadata entries if empty.
1654 if ( empty( $value ) ) {
1655 delete_metadata( $object_type, $object_id, $field_slug );
1656 }
1657 }
1658
1659 /**
1660 * Deletes the stored value for a field.
1661 *
1662 * @param string $field_slug Field slug.
1663 * @param object $field Field arguments.
1664 * @param string $object_type Object type.
1665 * @param int $object_id Object ID.
1666 * @param mixed $value Optional specific value to delete.
1667 * @return void
1668 */
1669 public function _delete_field_value( $field_slug, $field, $object_type, $object_id, $value = false ) {
1670 if ( ! in_array( $object_type, $this->_non_post_types ) ) {
1671 $object_type = 'post';
1672 }
1673
1674 $field_slug = sanitize_key( $field_slug );
1675
1676 delete_metadata( $object_type, $object_id, $field_slug, $value );
1677 }
1678
1679 /**
1680 * Sanitises a submitted field value, using its sanitise callback if one is set.
1681 *
1682 * @param string $field_slug Field slug.
1683 * @param object $field Field arguments.
1684 * @param string $object_type Object type.
1685 * @param int $object_id Object ID.
1686 * @param mixed $original_value Submitted value.
1687 * @return mixed The sanitised value.
1688 */
1689 public function _sanitize_field_value( $field_slug, $field, $object_type, $object_id, $original_value ) {
1690 $new_value = $original_value;
1691
1692 $sanitize_callback = $this->get_sanitize_callback( $field, $object_type );
1693
1694 // convert date to unix timestamp.
1695 if ( in_array( $field->field_type, array( 'datepicker', 'datetimepicker', 'timepicker' ) ) ) {
1696 $new_value = strtotime( $original_value );
1697 }
1698
1699 if ( $sanitize_callback ) {
1700 return call_user_func( $sanitize_callback, $field_slug, $field, $object_type, $object_id, $new_value, $original_value );
1701 }
1702
1703 // No explicit sanitize_callback: apply a safe, field-type-aware default (defense-in-depth).
1704 // Opt out globally by returning false from the filter, or per field by registering a sanitize_callback.
1705 if ( apply_filters( 'custom_metadata_manager_apply_default_sanitize', true, $field, $object_type ) ) {
1706 $new_value = $this->_default_sanitize_field_value( $field, $new_value );
1707 }
1708
1709 return $new_value;
1710 }
1711
1712 /**
1713 * Apply a safe default sanitizer based on the field type.
1714 *
1715 * Used only when a field has no explicit sanitize_callback. This complements the
1716 * escaping done on output: it keeps attacker-controlled markup out of the stored
1717 * value, which is also read by themes via get_post_meta(), by custom display
1718 * callbacks, and (for taxonomy fields) by wp_set_object_terms() — none of which
1719 * the column escaping can reach.
1720 *
1721 * @param object $field The field object.
1722 * @param mixed $value The value to sanitize (a scalar, or an array for multi-value fields).
1723 * @return mixed The sanitized value.
1724 */
1725 public function _default_sanitize_field_value( $field, $value ) {
1726 // Multi-value fields (multi_select, taxonomy_checkbox, taxonomy_multi_select, cloneable) arrive as arrays.
1727 if ( is_array( $value ) ) {
1728 $sanitized = array();
1729 foreach ( $value as $key => $item ) {
1730 $sanitized[ $key ] = $this->_default_sanitize_field_value( $field, $item );
1731 }
1732 return $sanitized;
1733 }
1734
1735 switch ( $field->field_type ) {
1736 case 'wysiwyg':
1737 // HTML is the point of this field; strip only scripts, event handlers and bad protocols.
1738 return wp_kses_post( $value );
1739 case 'textarea':
1740 // Like sanitize_text_field() but preserves newlines.
1741 return sanitize_textarea_field( $value );
1742 case 'email':
1743 return sanitize_email( $value );
1744 case 'number':
1745 // Keep floats and negatives; reject anything non-numeric.
1746 return is_numeric( $value ) ? $value + 0 : '';
1747 case 'link':
1748 case 'upload':
1749 // URL fields: esc_url_raw() neutralizes javascript:/data: that sanitize_text_field() would leave intact.
1750 return esc_url_raw( $value );
1751 case 'colorpicker':
1752 $hex = sanitize_hex_color( $value );
1753 return $hex ? $hex : sanitize_text_field( $value );
1754 case 'datepicker':
1755 case 'datetimepicker':
1756 case 'timepicker':
1757 // Already normalised to a timestamp (int) or false by strtotime() upstream.
1758 return $value;
1759 case 'password':
1760 // Do not mangle secrets.
1761 return $value;
1762 default:
1763 // text, tel, checkbox, radio, select, and any unrecognized type.
1764 return sanitize_text_field( $value );
1765 }
1766 }
1767
1768 /**
1769 * Builds the column content for a field.
1770 *
1771 * @param string $field_slug Field slug.
1772 * @param object $field Field arguments.
1773 * @param string $object_type Object type.
1774 * @param int $object_id Object ID.
1775 * @return mixed The column content.
1776 */
1777 public function _metadata_column_content( $field_slug, $field, $object_type, $object_id ) {
1778 $value = $this->get_metadata_field_value( $field_slug, $field, $object_type, $object_id );
1779
1780 $display_column_callback = $this->get_display_column_callback( $field, $object_type );
1781
1782 if ( $display_column_callback ) {
1783 return call_user_func( $display_column_callback, $field_slug, $field, $object_type, $object_id, $value );
1784 }
1785
1786 // Escape on output. A custom `display_column_callback` (above) is responsible for its own escaping.
1787 if ( is_array( $value ) ) {
1788 return implode( ', ', array_map( 'esc_html', $value ) );
1789 }
1790 return esc_html( $value );
1791 }
1792
1793 /**
1794 * Renders the inputs for a multifield.
1795 *
1796 * @param string $slug Multifield slug.
1797 * @param object $multifield Multifield arguments.
1798 * @param string $object_type Object type.
1799 * @param int $object_id Object ID.
1800 * @return void
1801 */
1802 public function _display_metadata_multifield( $slug, $multifield, $object_type, $object_id ) {
1803 echo '<div class="custom-metadata-multifield" data-slug="' . esc_attr( $slug ) . '" id="' . esc_attr( 'custom-metadata-multifield-' . str_replace( '_', '-', str_replace( '_x_multifield_', '', $slug ) ) ) . '">';
1804
1805 if ( ! empty( $multifield->label ) ) {
1806 printf( '<h2>%s</h2>', esc_html( $multifield->label ) );
1807 }
1808
1809 if ( ! empty( $multifield->description ) ) {
1810 printf( '<p class="description">%s</p>', esc_html( $multifield->description ) );
1811 }
1812
1813 $fields = $this->get_fields_in_multifield( $multifield->group, $slug, $object_type );
1814
1815 // validate/weed out the fields that can't be part of mulitified.
1816 foreach ( $fields as $field_slug => $field ) {
1817 if ( ! in_array( $field->field_type, $this->_field_types_that_support_multifield ) ) {
1818 unset( $fields[ $field_slug ] );
1819 }
1820 }
1821
1822 $_values = $this->get_metadata_mulitifield_value( $slug, $multifield, $object_type, $object_id );
1823 $_values = ( ! empty( $_values ) ) ? $_values : array( array() );
1824 $grouping_count = 0;
1825
1826 foreach ( $_values as $grouping_of_values ) {
1827 ++$grouping_count;
1828 $grouping_id = $slug . '-' . $grouping_count;
1829 printf( '<div id="%s" class="custom-metadata-multifield-grouping">', esc_attr( $grouping_id ) );
1830 foreach ( $fields as $field_slug => $field ) {
1831 $value = ( isset( $grouping_of_values[ $field_slug ] ) ) ? $grouping_of_values[ $field_slug ] : false;
1832 $field_id = $slug . '[' . ( $grouping_count - 1 ) . '][' . $field_slug . ']';
1833 $display_field_slug = $field_slug . '-' . $grouping_count;
1834 $this->_display_metadata_field( $display_field_slug, $field, $object_type, $object_id, $field_id, $value );
1835 }
1836 echo '<div class="clear"></div>';
1837 printf( '<a title="%s" class="custom-metadata-multifield-clone hide-if-no-js" href="#">+</a>', esc_attr__( 'duplicate this set of fields', 'custom-metadata' ) );
1838
1839 if ( $grouping_count > 1 ) {
1840 printf( '<a title="%s" class="custom-metadata-multifield-delete hide-if-no-js" href="#">-</a>', esc_attr__( 'remove this set of fields', 'custom-metadata' ) );
1841 }
1842
1843 echo '</div>';
1844 }
1845
1846 echo '</div>';
1847 }
1848
1849 /**
1850 * Renders the input(s) for a single field.
1851 *
1852 * @param string $field_slug Field slug.
1853 * @param object $field Field arguments.
1854 * @param string $object_type Object type.
1855 * @param int $object_id Object ID.
1856 * @param string $field_id Optional HTML field name.
1857 * @param mixed $value Optional current value.
1858 * @return void
1859 */
1860 public function _display_metadata_field( $field_slug, $field, $object_type, $object_id, $field_id = null, $value = null ) {
1861
1862 // this is a safety to prevent multifields from being displayed as a field.
1863 if ( true === $field->multifield ) {
1864 return;
1865 }
1866
1867 if ( null === $value ) {
1868 $value = $this->get_metadata_field_value( $field_slug, $field, $object_type, $object_id );
1869 }
1870
1871 $callback = $field->display_callback;
1872
1873 if ( $callback && is_callable( $callback ) ) {
1874 call_user_func( $callback, $field_slug, $field, $object_type, $object_id, $value );
1875 return;
1876 }
1877
1878 echo '<div class="custom-metadata-field ' . sanitize_html_class( $field->field_type ) . '" data-slug="' . esc_attr( $field->slug ) . '">';
1879 if ( ! in_array( $object_type, $this->_non_post_types ) ) {
1880 global $post;
1881 }
1882
1883 if ( ! empty( $field->multiple ) && ( empty( $this->_cloneable_field_types ) || ! in_array( $field->field_type, $this->_cloneable_field_types ) ) ) {
1884 $field->multiple = false;
1885 printf( '<p class="error">%s</p>', wp_kses_post( __( '<strong>Note:</strong> this field type cannot be multiplied', 'custom-metadata' ) ) );
1886 }
1887
1888 if ( ! isset( $field_id ) ) {
1889 $field_id = ( ! empty( $field->multiple ) || in_array( $field->field_type, $this->_always_multiple_fields ) ) ? $field_slug . '[]' : $field_slug;
1890 }
1891
1892 $cloneable = ( ! empty( $field->multiple ) ) ? true : false;
1893 $readonly_str = ( ! empty( $field->readonly ) ) ? ' readonly="readonly"' : '';
1894 $placeholder_str = ( in_array( $field->field_type, $this->_field_types_that_support_placeholder ) && ! empty( $field->placeholder ) ) ? ' placeholder="' . esc_attr( $field->placeholder ) . '"' : '';
1895
1896 printf( '<label for="%s">%s</label>', esc_attr( $field_slug ), esc_html( $field->label ) );
1897
1898 // check if there is a default value and set it if no value currently set.
1899 if ( empty( $value ) && in_array( $field->field_type, $this->_field_types_that_support_default_value ) && ! empty( $field->default_value ) ) {
1900 $value = sanitize_text_field( $field->default_value );
1901 }
1902
1903
1904 // if value is empty set to an empty string.
1905 if ( empty( $value ) ) {
1906 $value = '';
1907 }
1908
1909 // make sure $value is an array.
1910 $value = (array) $value;
1911
1912 $count = 1;
1913 $container_class = sanitize_html_class( $field_slug );
1914 $container_class .= ( $cloneable ) ? ' cloneable' : '';
1915 foreach ( $value as $v ) :
1916 $container_id = $field_slug . '-' . $count;
1917 printf( '<div class="%s" id="%s">', esc_attr( $container_class ), esc_attr( $container_id ) );
1918
1919 switch ( $field->field_type ) :
1920 case 'text':
1921 printf( '<input type="text" id="%s" name="%s" value="%s"%s%s/>', esc_attr( $field_slug ), esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1922 break;
1923 case 'password':
1924 printf( '<input type="password" id="%s" name="%s" value="%s"%s%s/>', esc_attr( $field_slug ), esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1925 break;
1926 case 'email':
1927 printf( '<input type="email" id="%s" name="%s" value="%s"%s%s/>', esc_attr( $field_slug ), esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1928 break;
1929 case 'tel':
1930 printf( '<input type="tel" id="%s" name="%s" value="%s"%s%s/>', esc_attr( $field_slug ), esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1931 break;
1932 case 'link':
1933 printf( '<input type="text" id="%s" name="%s" value="%s" %s%s/>', esc_attr( $field_slug ), esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1934 printf( '<input type="button" class="button custom-metadata-link-button" value="%s"/>', esc_attr( $field->link_modal_button_text ) );
1935 break;
1936 case 'number':
1937 $min = ( ! empty( $field->min ) ) ? ' min="' . (int) $field->min . '"' : '';
1938 $max = ( ! empty( $field->max ) ) ? ' max="' . (int) $field->max . '"' : '';
1939 printf( '<input type="number" id="%s" name="%s" value="%s"%s%s%s%s/>', esc_attr( $field_slug ), esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str, $min, $max ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1940 break;
1941 case 'textarea':
1942 printf( '<textarea id="%s" name="%s"%s%s>%s</textarea>', esc_attr( $field_slug ), esc_attr( $field_id ), $readonly_str, $placeholder_str, esc_textarea( $v ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1943 break;
1944 case 'checkbox':
1945 printf( '<input type="checkbox" id="%s" name="%s" %s/>', esc_attr( $field_slug ), esc_attr( $field_id ), checked( $v, 'on', false ) );
1946 break;
1947 case 'radio':
1948 foreach ( $field->values as $value_slug => $value_label ) {
1949 $value_id = sprintf( '%s_%s', $field_slug, $value_slug );
1950 printf( '<label for="%s" class="selectit">', esc_attr( $value_id ) );
1951 printf( '<input type="radio" id="%s" name="%s" id="%s" value="%s"%s/>', esc_attr( $value_id ), esc_attr( $field_id ), esc_attr( $value_id ), esc_attr( $value_slug ), checked( $v, $value_slug, false ) );
1952 echo esc_html( $value_label );
1953 echo '</label>';
1954 }
1955 break;
1956 case 'select':
1957 $select2 = ( $field->select2 ) ? ' class="custom-metadata-select2" ' : ' ';
1958 $select2 .= ( $field->placeholder ) ? ' data-placeholder="' . esc_attr( $field->placeholder ) . '" ' : ' ';
1959 printf( '<select id="%s" name="%s"%s>', esc_attr( $field_slug ), esc_attr( $field_id ), $select2 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1960 foreach ( $field->values as $value_slug => $value_label ) {
1961 printf( '<option value="%s"%s>', esc_attr( $value_slug ), selected( $v, $value_slug, false ) );
1962 echo esc_html( $value_label );
1963 echo '</option>';
1964 }
1965 echo '</select>';
1966 break;
1967 case 'datepicker':
1968 $datepicker_value = ! empty( $v ) ? esc_attr( date( 'm/d/Y', $v ) ) : ''; // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- Saved via strtotime() in server time and displayed the same way; gmdate() would break the round-trip.
1969 printf( '<input type="text" name="%s" value="%s"%s%s/>', esc_attr( $field_id ), $datepicker_value, $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1970 break;
1971 case 'colorpicker':
1972 printf( '<input type="text" name="%s" value="%s"%s%s/>', esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1973 break;
1974 case 'datetimepicker':
1975 $datetimepicker_value = ! empty( $v ) ? esc_attr( date( 'm/d/Y G:i', $v ) ) : ''; // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- Saved via strtotime() in server time and displayed the same way; gmdate() would break the round-trip.
1976 printf( '<input type="text" name="%s" value="%s"%s%s/>', esc_attr( $field_id ), $datetimepicker_value, $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1977 break;
1978 case 'timepicker':
1979 $timepicker = ! empty( $v ) ? esc_attr( date( 'G:i', $v ) ) : ''; // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- Saved via strtotime() in server time and displayed the same way; gmdate() would break the round-trip.
1980 printf( '<input type="text" name="%s" value="%s"%s%s/>', esc_attr( $field_id ), $timepicker, $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1981 break;
1982 case 'wysiwyg':
1983 $wysiwyg_args = apply_filters( 'custom_metadata_manager_wysiwyg_args_field_' . $field_id, $this->default_editor_args, $field_slug, $field, $object_type, $object_id );
1984 wp_editor( $v, $field_id, $wysiwyg_args );
1985 break;
1986 case 'upload':
1987 $_attachment_id = $this->get_metadata_field_value( $field_slug . '_attachment_id', $field, $object_type, $object_id );
1988 $attachment_id = reset( $_attachment_id ); // get the first value in the array.
1989 printf( '<input type="text" name="%s" value="%s" class="custom-metadata-upload-url"%s%s/>', esc_attr( $field_id ), esc_attr( $v ), $readonly_str, $placeholder_str ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
1990 printf( '<input type="button" data-uploader-title="%s" data-uploader-button-text="%s" class="button custom-metadata-upload-button" value="%s"/>', esc_attr( $field->upload_modal_title ), esc_attr( $field->upload_modal_button_text ), esc_attr( $field->upload_modal_title ) );
1991 printf( '<input type="button" class="button custom-metadata-clear-button" value="%s"/>', esc_attr( $field->upload_clear_button_text ) );
1992 printf( '<input type="hidden" name="%s" value="%s" class="custom-metadata-upload-id"/>', esc_attr( $field_id . '_attachment_id' ), esc_attr( $attachment_id ) );
1993 break;
1994 case 'taxonomy_select':
1995 $terms = get_terms(
1996 array(
1997 'taxonomy' => $field->taxonomy,
1998 'hide_empty' => false,
1999 )
2000 );
2001 if ( empty( $terms ) ) {
2002 /* translators: %s: the taxonomy label. */
2003 printf( esc_html__( 'There are no %s to select from yet.', 'custom-metadata' ), esc_html( $field->taxonomy ) );
2004 break;
2005 }
2006 $select2 = ( $field->select2 ) ? ' class="custom-metadata-select2" ' : ' ';
2007 $select2 .= ( $field->placeholder ) ? ' data-placeholder="' . esc_attr( $field->placeholder ) . '" ' : ' ';
2008 printf( '<select name="%s" id="%s"%s>', esc_attr( $field_id ), esc_attr( $field_slug ), $select2 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
2009 echo '<option value=""></option>';
2010 foreach ( $terms as $term ) {
2011 printf( '<option value="%s"%s>%s</option>', esc_attr( $term->slug ), selected( $v, $term->slug, false ), esc_html( $term->name ) );
2012 }
2013 echo '</select>';
2014 break;
2015 case 'taxonomy_radio':
2016 $terms = get_terms(
2017 array(
2018 'taxonomy' => $field->taxonomy,
2019 'hide_empty' => false,
2020 )
2021 );
2022 if ( empty( $terms ) ) {
2023 /* translators: %s: the taxonomy label. */
2024 printf( esc_html__( 'There are no %s to select from yet.', 'custom-metadata' ), esc_html( $field->taxonomy ) );
2025 break;
2026 }
2027 foreach ( $terms as $term ) {
2028 printf( '<label for="%s" class="selectit">', esc_attr( $term->slug ) );
2029 printf( '<input type="radio" name="%s" value="%s" id="%s"%s>', esc_attr( $field_id ), esc_attr( $term->slug ), esc_attr( $term->slug ), checked( $v, $term->slug, false ) );
2030 echo esc_html( $term->name );
2031 echo '</label>';
2032 }
2033 break;
2034 endswitch;
2035
2036 if ( $cloneable && $count > 1 ) {
2037 echo '<a href="#" class="del-multiple hide-if-no-js">' . esc_html__( 'Delete', 'custom-metadata' ) . '</a>';
2038 }
2039
2040 ++$count;
2041
2042 echo '</div>';
2043 endforeach;
2044
2045
2046 if ( in_array( $field->field_type, $this->_always_multiple_fields ) ) :
2047 $container_id = $field_slug . '-' . 1;
2048 printf( '<div class="%s" id="%s">', esc_attr( $container_class ), esc_attr( $container_id ) );
2049
2050
2051 // fields that save as arrays are not part of the foreach, otherwise they would display for each value, which is not the desired behaviour.
2052 switch ( $field->field_type ) :
2053 case 'multi_select':
2054 $select2 = ( $field->select2 ) ? ' class="custom-metadata-select2" ' : ' ';
2055 $select2 .= ( $field->placeholder ) ? ' data-placeholder="' . esc_attr( $field->placeholder ) . '" ' : ' ';
2056 printf( '<select id="%s" name="%s"%smultiple>', esc_attr( $field_slug ), esc_attr( $field_id ), $select2 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
2057 foreach ( $field->values as $value_slug => $value_label ) {
2058 printf( '<option value="%s"%s>', esc_attr( $value_slug ), selected( in_array( $value_slug, $value ), true, false ) );
2059 echo esc_html( $value_label );
2060 echo '</option>';
2061 }
2062 echo '</select>';
2063 break;
2064 case 'taxonomy_checkbox':
2065 $terms = get_terms(
2066 array(
2067 'taxonomy' => $field->taxonomy,
2068 'hide_empty' => false,
2069 )
2070 );
2071 if ( empty( $terms ) ) {
2072 /* translators: %s: the taxonomy label. */
2073 printf( esc_html__( 'There are no %s to select from yet.', 'custom-metadata' ), esc_html( $field->taxonomy ) );
2074 break;
2075 }
2076 foreach ( $terms as $term ) {
2077 printf( ' <label for="%s" class="selectit">', esc_attr( $term->slug ) );
2078 printf( '<input type="checkbox" name="%s" value="%s" id="%s"%s>', esc_attr( $field_id ), esc_attr( $term->slug ), esc_attr( $term->slug ), checked( in_array( $term->slug, $value ), true, false ) );
2079 echo esc_html( $term->name );
2080 echo '</label>';
2081 }
2082 break;
2083 case 'taxonomy_multi_select':
2084 $terms = get_terms(
2085 array(
2086 'taxonomy' => $field->taxonomy,
2087 'hide_empty' => false,
2088 )
2089 );
2090 if ( empty( $terms ) ) {
2091 /* translators: %s: the taxonomy label. */
2092 printf( esc_html__( 'There are no %s to select from yet.', 'custom-metadata' ), esc_html( $field->taxonomy ) );
2093 break;
2094 }
2095 $select2 = ( $field->select2 ) ? ' class="custom-metadata-select2" ' : ' ';
2096 $select2 .= ( $field->placeholder ) ? ' data-placeholder="' . esc_attr( $field->placeholder ) . '" ' : ' ';
2097 printf( '<select name="%s" id="%s"%smultiple>', esc_attr( $field_id ), esc_attr( $field_slug ), $select2 ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Assembled from integer-cast and esc_attr()-escaped values with static markup.
2098 foreach ( $terms as $term ) {
2099 printf( '<option value="%s"%s>%s</option>', esc_attr( $term->slug ), selected( in_array( $term->slug, $value ), true, false ), esc_html( $term->name ) );
2100 }
2101 echo '</select>';
2102 break;
2103 endswitch;
2104
2105 echo '</div>';
2106 endif;
2107
2108 if ( $cloneable ) {
2109 printf( '<p><a href="#" class="add-multiple hide-if-no-js" id="%s">%s</a></p>', esc_attr( 'add-' . $field_slug ), esc_html__( '+ Add New', 'custom-metadata' ) );
2110 }
2111
2112 $this->_display_field_description( $field_slug, $field, $object_type, $object_id, $value );
2113
2114 echo '</div>';
2115 }
2116
2117 /**
2118 * Outputs the description for a field, if one is set.
2119 *
2120 * @param string $field_slug Field slug.
2121 * @param object $field Field arguments.
2122 * @param string $object_type Object type.
2123 * @param int $object_id Object ID.
2124 * @param mixed $value Current field value.
2125 * @return void
2126 */
2127 public function _display_field_description( $field_slug, $field, $object_type, $object_id, $value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Signature matches the other _display_* callbacks.
2128 if ( $field->description ) {
2129 echo '<span class="description">' . wp_kses_post( $field->description ) . '</span>';
2130 }
2131 }
2132
2133 /**
2134 * Outputs any registration errors as an admin notice.
2135 *
2136 * @return void
2137 */
2138 public function _display_registration_errors() {
2139 if ( empty( $this->errors ) ) {
2140 return;
2141 }
2142
2143 echo '<div class="message error">';
2144 foreach ( $this->errors as $error => $error_message ) {
2145 printf( '<li>%s</li>', esc_html( $error_message ) );
2146 }
2147 echo '</div>';
2148 }
2149
2150 /**
2151 * Outputs the WordPress link dialog markup used by link fields.
2152 *
2153 * @return void
2154 */
2155 public function _display_wp_link_dialog() {
2156 if ( ! class_exists( '_WP_Editors' ) ) {
2157 require ABSPATH . WPINC . '/class-wp-editor.php';
2158 }
2159
2160 if ( ! has_action( 'admin_footer', array( '_WP_Editors', 'enqueue_scripts' ) ) ) {
2161 _WP_Editors::wp_link_dialog();
2162 }
2163 }
2164 }
2165