PluginProbe ʕ •ᴥ•ʔ
Secure Custom Fields / trunk
Secure Custom Fields vtrunk
6.9.1 6.9.0 6.8.9 6.8.7 6.8.8 6.8.6 6.8.4 6.8.5 trunk 6.4.0-beta1 6.4.0-beta2 6.4.1 6.4.1-beta3 6.4.1-beta4 6.4.1-beta5 6.4.1-beta6 6.4.1-beta7 6.4.2 6.5.0 6.5.1 6.5.2 6.5.3 6.5.4 6.5.5 6.5.6 6.5.7 6.6.0 6.7.0 6.7.1 6.8.0 6.8.1 6.8.2 6.8.3
secure-custom-fields / src / AI / Abilities / FieldGroup.php
secure-custom-fields / src / AI / Abilities Last commit date
Abilities.php 2 months ago AbstractAbilityGroup.php 2 months ago FieldGroup.php 1 month ago PostType.php 2 months ago SCF_REST_Ability.php 2 months ago Taxonomy.php 1 month ago
FieldGroup.php
581 lines
1 <?php
2 /**
3 * ACF 6.8.0 feature port.
4 *
5 * @package wordpress/secure-custom-fields
6 */
7
8 // phpcs:disable -- Upstream ACF 6.8.0 feature-port files are kept close to source.
9
10 namespace SCF\AI\Abilities;
11
12 use WP_Error;
13
14 // Exit if accessed directly.
15 defined( 'ABSPATH' ) || exit;
16
17 /**
18 * ACF Field Group Abilities
19 *
20 * Handles ACF field group related abilities for the WordPress Abilities API.
21 */
22 class FieldGroup extends AbstractAbilityGroup {
23
24 /**
25 * Register field group related abilities.
26 *
27 * @since 6.8.0
28 *
29 * @return void
30 */
31 public function register_abilities() {
32 if ( ! $this->is_abilities_api_available() ) {
33 return;
34 }
35
36 // Register ACF field groups ability.
37 $this->register_ability(
38 'acf/field-groups',
39 array(
40 'label' => __( 'List SCF Field Groups', 'secure-custom-fields' ),
41 'description' => __( 'Get all SCF field groups that allow AI access.', 'secure-custom-fields' ),
42 'category' => 'acf-field-management',
43 'input_schema' => array(
44 'type' => array( 'object', 'null' ),
45 'properties' => array(),
46 'additionalProperties' => false,
47 ),
48 'output_schema' => array(
49 'type' => 'object',
50 'properties' => array(
51 'field_groups' => array(
52 'type' => 'array',
53 'items' => array(
54 'type' => 'object',
55 ),
56 ),
57 'count' => array(
58 'type' => 'integer',
59 ),
60 'message' => array(
61 'type' => 'string',
62 ),
63 ),
64 ),
65 'execute_callback' => array( $this, 'get_field_groups' ),
66 'permission_callback' => function () {
67 return current_user_can( acf_get_setting( 'capability' ) );
68 },
69 'meta' => array(
70 'annotations' => array(
71 'readonly' => true,
72 'destructive' => false,
73 ),
74 'show_in_rest' => true,
75 ),
76 )
77 );
78
79 // Register field group ability.
80 $this->register_ability(
81 'acf/register-field-group',
82 array(
83 'label' => __( 'Register SCF Field Group', 'secure-custom-fields' ),
84 'description' => __( 'Register a new SCF field group schema with field definitions. This creates the field structure that will appear on content, not the field values themselves. Field values are set when creating or updating posts, terms, or other content.', 'secure-custom-fields' ),
85 'category' => 'acf-field-management',
86 'input_schema' => array(
87 'type' => 'object',
88 'properties' => array(
89 'title' => array(
90 'type' => 'string',
91 'description' => 'The title of the field group',
92 'minLength' => 1,
93 ),
94 'fields' => $this->get_fields_schema(),
95 'location' => $this->get_location_schema(),
96 'description' => array(
97 'type' => 'string',
98 'description' => 'A description for this field group',
99 ),
100 'position' => array(
101 'type' => 'string',
102 'description' => 'Where to show the field group (normal, side, acf_after_title)',
103 'enum' => array( 'normal', 'side', 'acf_after_title' ),
104 'default' => 'normal',
105 ),
106 'style' => array(
107 'type' => 'string',
108 'description' => 'Field group style (default, seamless)',
109 'enum' => array( 'default', 'seamless' ),
110 'default' => 'default',
111 ),
112 'label_placement' => array(
113 'type' => 'string',
114 'description' => 'Where to place field labels (top, left)',
115 'enum' => array( 'top', 'left' ),
116 'default' => 'top',
117 ),
118 'instruction_placement' => array(
119 'type' => 'string',
120 'description' => 'Where to show field instructions (label, field)',
121 'enum' => array( 'label', 'field' ),
122 'default' => 'label',
123 ),
124 'hide_on_screen' => array(
125 'type' => 'array',
126 'description' => 'Items that should be hidden from the edit screen containing this field group',
127 'items' => array(
128 'type' => 'string',
129 'enum' => array(
130 'permalink',
131 'the_content',
132 'excerpt',
133 'custom_fields',
134 'discussion',
135 'comments',
136 'revisions',
137 'slug',
138 'author',
139 'format',
140 'page_attributes',
141 'featured_image',
142 'categories',
143 'tags',
144 'send-trackbacks',
145 ),
146 ),
147 ),
148 'active' => array(
149 'type' => 'boolean',
150 'description' => 'Whether the field group is active',
151 'default' => true,
152 ),
153 'show_in_rest' => array(
154 'type' => 'boolean',
155 'description' => 'Whether the field group is shown in the REST API',
156 'default' => true,
157 ),
158 'allow_ai_access' => array(
159 'type' => 'boolean',
160 'description' => 'Whether the field group allows access to AI',
161 'default' => true,
162 ),
163 'ai_description' => array(
164 'type' => 'string',
165 'description' => 'A short description of the field group to provide AI more context',
166 ),
167 ),
168 'required' => array( 'title', 'fields', 'location' ),
169 'additionalProperties' => false,
170 ),
171 'output_schema' => array(
172 'type' => 'object',
173 'properties' => array(
174 'success' => array(
175 'type' => 'boolean',
176 ),
177 'field_group' => array(
178 'type' => 'object',
179 'properties' => array(
180 'ID' => array( 'type' => 'integer' ),
181 'key' => array( 'type' => 'string' ),
182 'title' => array( 'type' => 'string' ),
183 'fields' => array( 'type' => 'array' ),
184 'location' => array( 'type' => 'array' ),
185 'position' => array( 'type' => 'string' ),
186 'style' => array( 'type' => 'string' ),
187 'label_placement' => array( 'type' => 'string' ),
188 'instruction_placement' => array( 'type' => 'string' ),
189 'active' => array( 'type' => 'boolean' ),
190 'description' => array( 'type' => 'string' ),
191 'show_in_rest' => array( 'type' => 'boolean' ),
192 'allow_ai_access' => array( 'type' => 'boolean' ),
193 'ai_description' => array( 'type' => 'string' ),
194 ),
195 ),
196 'field_group_id' => array(
197 'type' => 'integer',
198 'description' => 'The ID of the created field group',
199 ),
200 'message' => array(
201 'type' => 'string',
202 ),
203 ),
204 ),
205 'execute_callback' => array( $this, 'create_field_group' ),
206 'permission_callback' => function () {
207 return current_user_can( acf_get_setting( 'capability' ) );
208 },
209 'meta' => array(
210 'annotations' => array(
211 'destructive' => false,
212 'idempotent' => true,
213 ),
214 'show_in_rest' => true,
215 ),
216 )
217 );
218 }
219
220 /**
221 * Get the field schema that includes all registered field types.
222 *
223 * Returns a JSON Schema with oneOf containing schemas for all ACF field types,
224 * allowing the AI to see available properties for each field type.
225 *
226 * @since 6.8.0
227 *
228 * @return array
229 */
230 private function get_fields_schema(): array {
231 $field_types = acf_get_field_types();
232 $schemas = array();
233
234 foreach ( $field_types as $field_type ) {
235 // Get the schema for this field type.
236 $schema = array();
237 if ( method_exists( $field_type, 'get_field_creation_schema' ) ) {
238 $schema = $field_type->get_field_creation_schema();
239 }
240
241 // Skip if the schema is empty.
242 if ( empty( $schema ) ) {
243 continue;
244 }
245
246 $schemas[] = $schema;
247 }
248
249 return array(
250 'type' => 'array',
251 'description' => 'Array of fields to add to the field group',
252 'minItems' => 1,
253 'items' => array(
254 'oneOf' => $schemas,
255 ),
256 );
257 }
258
259 /**
260 * Returns the schema needed to create field group location rules.
261 *
262 * @since 6.8.0
263 *
264 * @return array
265 */
266 private function get_location_schema(): array {
267 // Get all location types organized by category
268 $location_types = acf_get_location_rule_types();
269
270 // Build oneOf schemas for each location type
271 $location_rule_schemas = array();
272
273 foreach ( $location_types as $types ) {
274 foreach ( $types as $param => $label ) {
275 // Create a sample rule to get operators and values
276 $sample_rule = array( 'param' => $param );
277
278 // Get operators for this param
279 $operators = acf_get_location_rule_operators( $sample_rule );
280
281 // Build schema for this specific location type
282 $location_rule_schemas[] = array(
283 'type' => 'object',
284 'properties' => array(
285 'param' => array(
286 'type' => 'string',
287 'enum' => array( $param ),
288 'description' => $label,
289 ),
290 'operator' => array(
291 'type' => 'string',
292 'enum' => array_keys( $operators ),
293 'description' => 'Comparison operator',
294 'default' => '==',
295 ),
296 'value' => array(
297 'type' => 'string',
298 'description' => sprintf( 'Value for %s', $label ),
299 ),
300 ),
301 'required' => array( 'param', 'operator', 'value' ),
302 );
303 }
304 }
305
306 // Return full location schema supporting multiple groups and rules
307 return array(
308 'type' => 'array',
309 'description' => 'Location rules determining where this field group appears. Each array item is an OR group containing AND rules.',
310 'minItems' => 1,
311 'items' => array(
312 'type' => 'array',
313 'description' => 'Group of location rules (AND logic)',
314 'minItems' => 1,
315 'items' => array(
316 'oneOf' => $location_rule_schemas,
317 ),
318 ),
319 );
320 }
321
322 /**
323 * Callback for the "acf/get-field-groups" ability.
324 *
325 * @since 6.8.0
326 *
327 * @param array $input Ability input (unused).
328 * @return array
329 */
330 public function get_field_groups( $input = array() ) {
331 unset( $input ); // Not used, but required by interface.
332
333 $field_groups = $this->get_ai_accessible_field_groups();
334 $count = count( $field_groups );
335
336 return array(
337 'field_groups' => $field_groups,
338 'count' => $count,
339 'message' => sprintf(
340 /* translators: %d: Number of found field groups */
341 _n( 'Found %d SCF field group.', 'Found %d SCF field groups.', $count, 'secure-custom-fields' ),
342 $count
343 ),
344 );
345 }
346
347 /**
348 * A helper function to get the field groups that allow AI access.
349 *
350 * @since 6.8.0
351 *
352 * @return array
353 */
354 public function get_ai_accessible_field_groups(): array {
355 $field_groups = acf_get_field_groups();
356 $ai_accessible = array();
357
358 foreach ( $field_groups as $field_group ) {
359 if ( $this->is_field_group_ai_accessible( $field_group ) ) {
360 $ai_accessible[] = $field_group;
361 }
362 }
363
364 return $ai_accessible;
365 }
366
367 /**
368 * Check if a field group allows AI access.
369 *
370 * @since 6.8.0
371 *
372 * @param array $field_group Field group array.
373 * @return boolean
374 */
375 private function is_field_group_ai_accessible( $field_group ): bool {
376 return ! empty( $field_group['allow_ai_access'] );
377 }
378
379 /**
380 * Callback for the "acf/register-field-group" ability.
381 *
382 * @since 6.8.0
383 *
384 * @param array $input Ability arguments containing title and fields.
385 * @return array|WP_Error
386 */
387 public function create_field_group( $input = array() ) {
388 // Prepare the field group data.
389 $field_group_data = array(
390 'key' => 'group_' . uniqid(),
391 'title' => sanitize_text_field( $input['title'] ),
392 'fields' => $input['fields'],
393 'location' => $this->sanitize_location_rules( $input['location'] ),
394 'description' => isset( $input['description'] ) ? sanitize_textarea_field( $input['description'] ) : '',
395 'position' => $input['position'] ?? 'normal',
396 'style' => $input['style'] ?? 'default',
397 'label_placement' => $input['label_placement'] ?? 'top',
398 'instruction_placement' => $input['instruction_placement'] ?? 'label',
399 'hide_on_screen' => ! empty( $input['hide_on_screen'] ) ? $input['hide_on_screen'] : array(),
400 'active' => ! isset( $input['active'] ) || $input['active'],
401 'show_in_rest' => ! isset( $input['show_in_rest'] ) || $input['show_in_rest'],
402 'allow_ai_access' => ! isset( $input['allow_ai_access'] ) || $input['allow_ai_access'],
403 'ai_description' => isset( $input['ai_description'] ) ? sanitize_text_field( $input['ai_description'] ) : '',
404 );
405
406 // Create the field group using ACF's function.
407 add_filter( 'acf/prepare_field_for_import', array( $this, 'prepare_field_for_ability_import' ), 5 );
408 $field_group = acf_import_field_group( $field_group_data );
409 remove_filter( 'acf/prepare_field_for_import', array( $this, 'prepare_field_for_ability_import' ), 5 );
410
411 if ( empty( $field_group['ID'] ) || ! is_int( $field_group['ID'] ) ) {
412 return new WP_Error(
413 'field_group_creation_failed',
414 __( 'Failed to create the field group', 'secure-custom-fields' ),
415 array( 'field_group_data' => $field_group )
416 );
417 }
418
419 return array(
420 'success' => true,
421 'field_group' => $field_group,
422 'field_group_id' => $field_group['ID'],
423 'message' => sprintf(
424 /* translators: %s: Field group title */
425 __( 'Field group "%s" created successfully.', 'secure-custom-fields' ),
426 $field_group['title']
427 ),
428 );
429 }
430
431 /**
432 * Ensures a field has a key and name before import and sanitizes user input.
433 *
434 * @since 6.8.0
435 *
436 * @param array $field The field being prepared for import.
437 * @return array The field with key, name, and sanitized values.
438 */
439 public function prepare_field_for_ability_import( $field ) {
440 // Generate field name if not provided.
441 if ( empty( $field['name'] ) && ! empty( $field['label'] ) ) {
442 $field['name'] = acf_slugify( $field['label'], '_' );
443 }
444
445 // Generate field key if not provided.
446 if ( empty( $field['key'] ) ) {
447 $field['key'] = 'field_' . uniqid();
448 }
449
450 if ( ! empty( $field['label'] ) ) {
451 $field['label'] = sanitize_text_field( $field['label'] );
452 }
453
454 if ( ! empty( $field['instructions'] ) ) {
455 $field['instructions'] = wp_kses_post( $field['instructions'] );
456 }
457
458 if ( ! empty( $field['placeholder'] ) ) {
459 $field['placeholder'] = sanitize_text_field( $field['placeholder'] );
460 }
461
462 if ( ! empty( $field['prepend'] ) ) {
463 $field['prepend'] = sanitize_text_field( $field['prepend'] );
464 }
465
466 if ( ! empty( $field['append'] ) ) {
467 $field['append'] = sanitize_text_field( $field['append'] );
468 }
469
470 if ( ! empty( $field['wrapper']['class'] ) ) {
471 $field['wrapper']['class'] = sanitize_text_field( $field['wrapper']['class'] );
472 }
473
474 if ( ! empty( $field['wrapper']['id'] ) ) {
475 $field['wrapper']['id'] = sanitize_key( $field['wrapper']['id'] );
476 }
477
478 if ( ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) {
479 $field['choices'] = array_map( 'sanitize_text_field', $field['choices'] );
480 }
481
482 if ( isset( $field['min'] ) ) {
483 $field['min'] = absint( $field['min'] );
484 }
485
486 if ( isset( $field['max'] ) ) {
487 $field['max'] = absint( $field['max'] );
488 }
489
490 if ( isset( $field['default_value'] ) ) {
491 if ( is_string( $field['default_value'] ) ) {
492 $field['default_value'] = sanitize_text_field( $field['default_value'] );
493 } elseif ( is_array( $field['default_value'] ) ) {
494 $field['default_value'] = array_map( 'sanitize_text_field', $field['default_value'] );
495 }
496 }
497
498 if ( ! empty( $field['message'] ) ) {
499 $field['message'] = wp_kses_post( $field['message'] );
500 }
501
502 if ( ! empty( $field['conditional_logic'] ) && is_array( $field['conditional_logic'] ) ) {
503 $field['conditional_logic'] = $this->sanitize_conditional_logic( $field['conditional_logic'] );
504 }
505
506 return $field;
507 }
508
509 /**
510 * Sanitize location rules for a field group.
511 *
512 * @since 6.8.0
513 *
514 * @param array $location The location rules array.
515 * @return array The sanitized location rules.
516 */
517 private function sanitize_location_rules( array $location ): array {
518 foreach ( $location as &$group ) {
519 if ( ! is_array( $group ) ) {
520 continue;
521 }
522
523 foreach ( $group as &$rule ) {
524 if ( ! is_array( $rule ) ) {
525 continue;
526 }
527
528 if ( isset( $rule['param'] ) ) {
529 $rule['param'] = sanitize_text_field( $rule['param'] );
530 }
531
532 if ( isset( $rule['operator'] ) ) {
533 $rule['operator'] = sanitize_text_field( $rule['operator'] );
534 }
535
536 if ( isset( $rule['value'] ) ) {
537 $rule['value'] = sanitize_text_field( $rule['value'] );
538 }
539 }
540 }
541
542 return $location;
543 }
544
545 /**
546 * Sanitize conditional logic rules for a field.
547 *
548 * @since 6.8.0
549 *
550 * @param array $conditional_logic The conditional logic array.
551 * @return array The sanitized conditional logic.
552 */
553 private function sanitize_conditional_logic( array $conditional_logic ): array {
554 foreach ( $conditional_logic as &$group ) {
555 if ( ! is_array( $group ) ) {
556 continue;
557 }
558
559 foreach ( $group as &$rule ) {
560 if ( ! is_array( $rule ) ) {
561 continue;
562 }
563
564 if ( isset( $rule['field'] ) ) {
565 $rule['field'] = sanitize_text_field( $rule['field'] );
566 }
567
568 if ( isset( $rule['operator'] ) ) {
569 $rule['operator'] = sanitize_text_field( $rule['operator'] );
570 }
571
572 if ( isset( $rule['value'] ) ) {
573 $rule['value'] = sanitize_text_field( $rule['value'] );
574 }
575 }
576 }
577
578 return $conditional_logic;
579 }
580 }
581