| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentForm\App\Modules\MCP\Tools; |
| 4 |
|
| 5 |
defined('ABSPATH') || exit; |
| 6 |
|
| 7 |
use FluentForm\App\Models\Form; |
| 8 |
use FluentForm\App\Modules\MCP\Support\ErrorCodes; |
| 9 |
use FluentForm\App\Modules\MCP\Support\FormAccess; |
| 10 |
use FluentForm\App\Modules\MCP\Support\FormCreator; |
| 11 |
use FluentForm\App\Modules\MCP\Support\MCPHelper; |
| 12 |
use FluentForm\App\Modules\MCP\Support\Mutation; |
| 13 |
use FluentForm\App\Modules\MCP\Support\WriteGuard; |
| 14 |
use FluentForm\App\Services\Form\FormService; |
| 15 |
use FluentForm\Framework\Support\Arr; |
| 16 |
|
| 17 |
/** |
| 18 |
* Field-editing tool (write, destructive). |
| 19 |
* |
| 20 |
* The update-form-fields tool replaces an existing form's whole field set from a |
| 21 |
* simple spec (the same shape create-form accepts), reusing FormService::update so the |
| 22 |
* agent path shares the admin editor's duplicate-name validation and field |
| 23 |
* sanitization. It is destructive: a submission's answers are keyed by a field's |
| 24 |
* attributes.name, so removing or renaming a field orphans its stored values. |
| 25 |
* The dry-run preview lists exactly which stored field keys would disappear, and |
| 26 |
* a confirm_token bound to the form's current field state must round-trip before |
| 27 |
* anything is written. Multi-step forms are refused — their step wrappers make a |
| 28 |
* blind full-replace unsafe; edit those in the form builder. |
| 29 |
*/ |
| 30 |
class FieldTools |
| 31 |
{ |
| 32 |
public static function definitions() |
| 33 |
{ |
| 34 |
return [ |
| 35 |
'fluentform/update-form-fields' => [ |
| 36 |
'label' => __('Update Form Fields', 'fluentform'), |
| 37 |
'group' => __('Forms', 'fluentform'), |
| 38 |
'description' => __('Replace a form\'s fields from a spec (same shape as create-form: a list of {element, attributes, settings}). This overwrites the ENTIRE field set — include every field you want to keep, not just new ones. Entries are keyed by a field\'s name, so dropping or renaming a field orphans its stored answers. Call once with dry_run:true to preview which stored field keys would be removed and get a confirm_token, then call again with the same fields plus confirm_token to execute. If your new list drops any existing field you must ALSO pass allow_field_removal:true. Multi-step forms are not editable here. Requires form_id and fields.', 'fluentform'), |
| 39 |
'input_schema' => [ |
| 40 |
'type' => 'object', |
| 41 |
'properties' => array_merge([ |
| 42 |
'form_id' => ['type' => 'integer', 'description' => 'Required. The form to edit.'], |
| 43 |
'fields' => ['type' => 'array', 'items' => ['type' => 'object'], 'description' => 'Required. The full replacement field list (create-form spec shape).'], |
| 44 |
'allow_field_removal' => ['type' => 'boolean', 'description' => 'Required true to execute if your new list drops any existing field key (which orphans that field\'s stored entries).'], |
| 45 |
], WriteGuard::schemaProps()), |
| 46 |
'required' => ['form_id', 'fields'], |
| 47 |
], |
| 48 |
'execute_callback' => [self::class, 'updateFields'], |
| 49 |
'capability' => 'fluentform_forms_manager', |
| 50 |
'annotations' => ['destructive' => true], |
| 51 |
], |
| 52 |
]; |
| 53 |
} |
| 54 |
|
| 55 |
public static function updateFields($params = []) |
| 56 |
{ |
| 57 |
$form = FormAccess::resolveForm($params); |
| 58 |
if (is_wp_error($form)) { |
| 59 |
return $form; |
| 60 |
} |
| 61 |
$formId = (int) $form->id; |
| 62 |
|
| 63 |
$spec = isset($params['fields']) ? $params['fields'] : null; |
| 64 |
if (!is_array($spec) || empty($spec)) { |
| 65 |
return MCPHelper::error(ErrorCodes::MISSING_PARAM, __('fields must be a non-empty array of field definitions.', 'fluentform'), ['fields' => ['fields']]); |
| 66 |
} |
| 67 |
|
| 68 |
$existing = json_decode($form->form_fields, true); |
| 69 |
if (!is_array($existing)) { |
| 70 |
$existing = ['fields' => [], 'submitButton' => []]; |
| 71 |
} |
| 72 |
|
| 73 |
if (!empty($existing['stepsWrapper'])) { |
| 74 |
return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('This form is multi-step; edit its fields in the form builder. update-form-fields does not support step wrappers.', 'fluentform'), ['fields' => ['form_id']]); |
| 75 |
} |
| 76 |
|
| 77 |
try { |
| 78 |
$newFields = (new FormCreator())->formatFields($spec); |
| 79 |
} catch (\Throwable $e) { |
| 80 |
return MCPHelper::error(ErrorCodes::INVALID_PARAM, $e->getMessage(), ['fields' => ['fields']]); |
| 81 |
} |
| 82 |
if (empty($newFields)) { |
| 83 |
return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('None of the supplied fields resolved to a valid FluentForm element.', 'fluentform'), ['fields' => ['fields']]); |
| 84 |
} |
| 85 |
|
| 86 |
$oldNames = self::fieldNames(Arr::get($existing, 'fields', [])); |
| 87 |
$newNames = self::fieldNames($newFields); |
| 88 |
$removed = array_values(array_diff($oldNames, $newNames)); |
| 89 |
|
| 90 |
// Dropping a field orphans its stored entries. dry_run still previews the |
| 91 |
// removed keys so the agent can learn what's at stake; execution is refused |
| 92 |
// unless the caller explicitly opts into removal. |
| 93 |
if (empty($params['dry_run']) && $removed && empty($params['allow_field_removal'])) { |
| 94 |
return MCPHelper::error( |
| 95 |
ErrorCodes::INVALID_PARAM, |
| 96 |
__('This change removes existing fields, which orphans their stored entries. Re-send with allow_field_removal:true (plus the confirm_token) to proceed.', 'fluentform'), |
| 97 |
['fields' => ['allow_field_removal'], 'removed_field_keys' => $removed, 'next_step' => 'set allow_field_removal:true'] |
| 98 |
); |
| 99 |
} |
| 100 |
|
| 101 |
$fingerprint = 'fields:' . md5((string) $form->form_fields); |
| 102 |
|
| 103 |
return Mutation::runGuarded( |
| 104 |
'fluentform/update-form-fields', |
| 105 |
$params, |
| 106 |
'form_fields:' . $formId, |
| 107 |
$fingerprint, |
| 108 |
function () use ($removed, $oldNames, $newNames) { |
| 109 |
return [ |
| 110 |
'removed_field_keys' => $removed, |
| 111 |
'kept_field_keys' => array_values(array_intersect($oldNames, $newNames)), |
| 112 |
'new_field_keys' => array_values(array_diff($newNames, $oldNames)), |
| 113 |
'warning' => $removed |
| 114 |
? __('Entries stored under the removed field keys will no longer display against those fields. To execute, re-send with confirm_token AND allow_field_removal:true.', 'fluentform') |
| 115 |
: __('No stored field keys are removed.', 'fluentform'), |
| 116 |
]; |
| 117 |
}, |
| 118 |
function () use ($formId, $newFields, $removed, $fingerprint) { |
| 119 |
global $wpdb; |
| 120 |
|
| 121 |
// Serialize against concurrent form saves (editor autosave, another |
| 122 |
// agent): lock the form row, then read-modify-write inside one |
| 123 |
// transaction so no edit lands between our read and the Updater |
| 124 |
// write (lost update). FOR UPDATE degrades to a plain fresh read on |
| 125 |
// engines without row locks. Mirrors the pattern the field-conditions |
| 126 |
// tool used before it was folded away. |
| 127 |
$wpdb->query('START TRANSACTION'); |
| 128 |
|
| 129 |
try { |
| 130 |
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, id is %d-prepared |
| 131 |
$wpdb->query($wpdb->prepare("SELECT id FROM {$wpdb->prefix}fluentform_forms WHERE id = %d FOR UPDATE", $formId)); |
| 132 |
|
| 133 |
$fresh = Form::query()->find($formId); |
| 134 |
if (!$fresh) { |
| 135 |
$wpdb->query('ROLLBACK'); |
| 136 |
return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form was deleted while this update was in flight.', 'fluentform')); |
| 137 |
} |
| 138 |
|
| 139 |
// Re-validate the locked row against the exact state the caller |
| 140 |
// previewed and confirmed. The confirm_token check ran against a |
| 141 |
// pre-lock read; an edit landing between that read and this lock |
| 142 |
// would make the removed-keys preview wrong (a concurrently-added |
| 143 |
// field would be dropped without consent). Refuse and force a |
| 144 |
// fresh dry_run so removal is always previewed against what ships. |
| 145 |
if ('fields:' . md5((string) $fresh->form_fields) !== $fingerprint) { |
| 146 |
$wpdb->query('ROLLBACK'); |
| 147 |
return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form changed while this update was in flight. Run a fresh dry_run to re-preview which fields would be removed, then execute.', 'fluentform'), ['next_step' => 'set dry_run:true']); |
| 148 |
} |
| 149 |
|
| 150 |
$decoded = json_decode($fresh->form_fields, true); |
| 151 |
if (!is_array($decoded)) { |
| 152 |
$decoded = ['fields' => [], 'submitButton' => []]; |
| 153 |
} |
| 154 |
if (!empty($decoded['stepsWrapper'])) { |
| 155 |
$wpdb->query('ROLLBACK'); |
| 156 |
return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form became multi-step while this update was in flight; edit it in the form builder.', 'fluentform'), ['fields' => ['form_id']]); |
| 157 |
} |
| 158 |
|
| 159 |
// Replace only the fields; keep the fresh submitButton and any |
| 160 |
// other top-level keys as they stand right now. |
| 161 |
$decoded['fields'] = $newFields; |
| 162 |
|
| 163 |
(new FormService())->update([ |
| 164 |
'form_id' => $formId, |
| 165 |
'formFields' => wp_json_encode($decoded), |
| 166 |
'title' => $fresh->title, |
| 167 |
'status' => $fresh->status, |
| 168 |
]); |
| 169 |
|
| 170 |
$wpdb->query('COMMIT'); |
| 171 |
} catch (\FluentForm\Framework\Validator\ValidationException $e) { |
| 172 |
$wpdb->query('ROLLBACK'); |
| 173 |
return MCPHelper::error(ErrorCodes::INVALID_PARAM, $e->getMessage(), ['fields' => ['fields']]); |
| 174 |
} catch (\Throwable $e) { |
| 175 |
$wpdb->query('ROLLBACK'); |
| 176 |
throw $e; |
| 177 |
} |
| 178 |
|
| 179 |
return MCPHelper::envelope( |
| 180 |
sprintf( |
| 181 |
/* translators: %s: form title */ |
| 182 |
__('Fields for "%s" updated.', 'fluentform'), |
| 183 |
$fresh->title |
| 184 |
), |
| 185 |
['form_id' => $formId, 'removed_field_keys' => $removed] |
| 186 |
); |
| 187 |
}, |
| 188 |
['form_id' => $formId] |
| 189 |
); |
| 190 |
} |
| 191 |
|
| 192 |
/** |
| 193 |
* Every stored field key (attributes.name) in a fields array, recursing into |
| 194 |
* container columns — the keys entries are stored against. |
| 195 |
*/ |
| 196 |
private static function fieldNames($fields) |
| 197 |
{ |
| 198 |
$names = []; |
| 199 |
if (!is_array($fields)) { |
| 200 |
return $names; |
| 201 |
} |
| 202 |
|
| 203 |
foreach ($fields as $field) { |
| 204 |
if (!is_array($field)) { |
| 205 |
continue; |
| 206 |
} |
| 207 |
$name = Arr::get($field, 'attributes.name'); |
| 208 |
if ($name) { |
| 209 |
$names[] = $name; |
| 210 |
} |
| 211 |
$columns = Arr::get($field, 'columns', []); |
| 212 |
if (is_array($columns)) { |
| 213 |
foreach ($columns as $column) { |
| 214 |
$names = array_merge($names, self::fieldNames(Arr::get($column, 'fields', []))); |
| 215 |
} |
| 216 |
} |
| 217 |
} |
| 218 |
|
| 219 |
return array_values(array_unique($names)); |
| 220 |
} |
| 221 |
} |
| 222 |
|