| 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 |
|
| 16 |
/** |
| 17 |
* Form tools (read). |
| 18 |
* |
| 19 |
* The list-forms tool is the catalogue; get-form loads one form's field schema |
| 20 |
* so the agent knows the exact field keys before reading entries. Both respect the |
| 21 |
* user's form scope: a "specific forms" manager never sees a form outside their |
| 22 |
* assignment, even by id. |
| 23 |
*/ |
| 24 |
class FormTools |
| 25 |
{ |
| 26 |
public static function definitions() |
| 27 |
{ |
| 28 |
return [ |
| 29 |
'fluentform/list-forms' => [ |
| 30 |
'label' => __('List Forms', 'fluentform'), |
| 31 |
'group' => __('Forms', 'fluentform'), |
| 32 |
'description' => __('Find and filter forms with compact rows (id, title, status, type, entry count). Use this to discover the form_id you need for list-submissions and get-form-stats.', 'fluentform'), |
| 33 |
'input_schema' => [ |
| 34 |
'type' => 'object', |
| 35 |
'properties' => [ |
| 36 |
'search' => ['type' => 'string', 'description' => 'Matches form title.'], |
| 37 |
'status' => ['type' => 'string', 'enum' => ['published', 'unpublished']], |
| 38 |
'sort_by' => ['type' => 'string', 'enum' => ['ASC', 'DESC'], 'default' => 'DESC'], |
| 39 |
'page' => ['type' => 'integer', 'default' => 1], |
| 40 |
'per_page' => ['type' => 'integer', 'default' => 15, 'description' => 'Max 100.'], |
| 41 |
], |
| 42 |
], |
| 43 |
'execute_callback' => [self::class, 'listForms'], |
| 44 |
'capability' => ['fluentform_forms_manager', 'fluentform_dashboard_access'], |
| 45 |
'annotations' => ['readonly' => true], |
| 46 |
], |
| 47 |
|
| 48 |
'fluentform/get-form' => [ |
| 49 |
'label' => __('Get Form', 'fluentform'), |
| 50 |
'group' => __('Forms', 'fluentform'), |
| 51 |
'description' => __('Full detail for one form: status, type, timestamps, and the field schema (each input key, label, and element type) so you know the exact keys that appear in submission responses.', 'fluentform'), |
| 52 |
'input_schema' => [ |
| 53 |
'type' => 'object', |
| 54 |
'properties' => [ |
| 55 |
'form_id' => ['type' => 'integer', 'description' => 'The form id (from list-forms).'], |
| 56 |
], |
| 57 |
'required' => ['form_id'], |
| 58 |
], |
| 59 |
'execute_callback' => [self::class, 'getForm'], |
| 60 |
'capability' => ['fluentform_forms_manager', 'fluentform_dashboard_access'], |
| 61 |
'annotations' => ['readonly' => true], |
| 62 |
], |
| 63 |
|
| 64 |
'fluentform/create-form' => [ |
| 65 |
'label' => __('Create Form', 'fluentform'), |
| 66 |
'group' => __('Forms', 'fluentform'), |
| 67 |
'description' => __('Create a new form from a title and a list of fields. Each field needs a type (text, email, textarea, name, phone, number, url, dropdown, checkbox, radio, date) and a label. Omit fields to create a basic contact form (name, email, message). Call once with dry_run:true to preview the form that would be created and get a confirm_token, then call again with the same title and fields plus confirm_token to execute. Returns the new form id and editor URL.', 'fluentform'), |
| 68 |
'input_schema' => [ |
| 69 |
'type' => 'object', |
| 70 |
'properties' => array_merge([ |
| 71 |
'title' => ['type' => 'string', 'description' => 'The form title.'], |
| 72 |
'fields' => [ |
| 73 |
'type' => 'array', |
| 74 |
'description' => 'Fields to add, in order. Omit for a basic contact form.', |
| 75 |
'items' => [ |
| 76 |
'type' => 'object', |
| 77 |
'properties' => [ |
| 78 |
'type' => ['type' => 'string', 'description' => 'Field type, e.g. text, email, textarea, name, phone, number, url, dropdown, checkbox, radio, date.'], |
| 79 |
'label' => ['type' => 'string', 'description' => 'Field label shown to the user.'], |
| 80 |
], |
| 81 |
'required' => ['type'], |
| 82 |
], |
| 83 |
], |
| 84 |
'is_conversational' => ['type' => 'boolean', 'description' => 'Create as a conversational form. Default false.'], |
| 85 |
], WriteGuard::schemaProps()), |
| 86 |
'required' => ['title'], |
| 87 |
], |
| 88 |
'execute_callback' => [self::class, 'createForm'], |
| 89 |
'capability' => 'fluentform_forms_manager', |
| 90 |
], |
| 91 |
]; |
| 92 |
} |
| 93 |
|
| 94 |
public static function createForm($params = []) |
| 95 |
{ |
| 96 |
$title = isset($params['title']) ? sanitize_text_field($params['title']) : ''; |
| 97 |
if ('' === $title) { |
| 98 |
return MCPHelper::error(ErrorCodes::MISSING_PARAM, __('title is required.', 'fluentform'), ['fields' => ['title']]); |
| 99 |
} |
| 100 |
|
| 101 |
$specFields = []; |
| 102 |
$fieldsIn = (isset($params['fields']) && is_array($params['fields'])) ? $params['fields'] : []; |
| 103 |
foreach ($fieldsIn as $field) { |
| 104 |
if (!is_array($field)) { |
| 105 |
continue; |
| 106 |
} |
| 107 |
$type = isset($field['type']) ? sanitize_text_field($field['type']) : ''; |
| 108 |
if ('' === $type) { |
| 109 |
continue; |
| 110 |
} |
| 111 |
$label = isset($field['label']) ? sanitize_text_field($field['label']) : ''; |
| 112 |
$settings = []; |
| 113 |
if ('' !== $label) { |
| 114 |
$settings['label'] = $label; |
| 115 |
$settings['admin_field_label'] = $label; |
| 116 |
} |
| 117 |
$specFields[] = ['type' => $type, 'settings' => $settings]; |
| 118 |
} |
| 119 |
|
| 120 |
// No usable fields supplied -> sensible default so the form is never empty. |
| 121 |
if (!$specFields) { |
| 122 |
$specFields = [ |
| 123 |
['type' => 'name', 'settings' => ['label' => __('Name', 'fluentform')]], |
| 124 |
['type' => 'email', 'settings' => ['label' => __('Email', 'fluentform')]], |
| 125 |
['type' => 'textarea', 'settings' => ['label' => __('Message', 'fluentform')]], |
| 126 |
]; |
| 127 |
} |
| 128 |
|
| 129 |
$isConversational = !empty($params['is_conversational']); |
| 130 |
|
| 131 |
// Nothing exists yet to fingerprint, so the token binds to the spec |
| 132 |
// itself: the form previewed to the operator is the only form it can |
| 133 |
// create. A different title or field list needs its own dry run. |
| 134 |
$specHash = md5(wp_json_encode([$title, $specFields, $isConversational])); |
| 135 |
|
| 136 |
return Mutation::runGuarded( |
| 137 |
'fluentform/create-form', |
| 138 |
$params, |
| 139 |
'form:new:' . $specHash, |
| 140 |
'spec:' . $specHash, |
| 141 |
function () use ($title, $specFields, $isConversational) { |
| 142 |
return [ |
| 143 |
'title' => $title, |
| 144 |
'is_conversational' => $isConversational, |
| 145 |
'field_count' => count($specFields), |
| 146 |
'fields' => array_map(function ($field) { |
| 147 |
return [ |
| 148 |
'type' => isset($field['type']) ? $field['type'] : null, |
| 149 |
'label' => isset($field['settings']['label']) ? $field['settings']['label'] : null, |
| 150 |
]; |
| 151 |
}, $specFields), |
| 152 |
]; |
| 153 |
}, |
| 154 |
function () use ($title, $specFields, $isConversational) { |
| 155 |
try { |
| 156 |
$creator = new FormCreator(); |
| 157 |
|
| 158 |
// Submission responses are keyed by attributes.name; assign each |
| 159 |
// field a unique one before saving so repeated field types never |
| 160 |
// share a storage key (create() re-checks this, idempotently). |
| 161 |
$specFields = $creator->assignStorageNames($specFields); |
| 162 |
|
| 163 |
$form = $creator->create([ |
| 164 |
'title' => $title, |
| 165 |
'fields' => $specFields, |
| 166 |
'is_conversational' => $isConversational, |
| 167 |
]); |
| 168 |
} catch (\Throwable $e) { |
| 169 |
return MCPHelper::error(ErrorCodes::CREATE_FAILED, $e->getMessage(), ['retryable' => false]); |
| 170 |
} |
| 171 |
|
| 172 |
return MCPHelper::envelope( |
| 173 |
sprintf( |
| 174 |
/* translators: 1: form title, 2: form id */ |
| 175 |
__('Form "%1$s" created (#%2$d).', 'fluentform'), |
| 176 |
$form->title, |
| 177 |
(int) $form->id |
| 178 |
), |
| 179 |
[ |
| 180 |
'id' => (int) $form->id, |
| 181 |
'title' => $form->title, |
| 182 |
'status' => $form->status, |
| 183 |
'fields' => count($specFields), |
| 184 |
'edit_url' => admin_url('admin.php?page=fluent_forms&form_id=' . (int) $form->id . '&route=editor'), |
| 185 |
] |
| 186 |
); |
| 187 |
}, |
| 188 |
function ($result) { |
| 189 |
return (is_array($result) && isset($result['data']['id'])) ? ['form_id' => (int) $result['data']['id']] : []; |
| 190 |
} |
| 191 |
); |
| 192 |
} |
| 193 |
|
| 194 |
public static function listForms($params = []) |
| 195 |
{ |
| 196 |
$paging = MCPHelper::pagination($params, 15); |
| 197 |
|
| 198 |
$query = Form::query()->orderBy('id', strtoupper(isset($params['sort_by']) && 'ASC' === strtoupper($params['sort_by']) ? 'ASC' : 'DESC')); |
| 199 |
|
| 200 |
FormAccess::applyScope($query, 'id'); |
| 201 |
if (!empty($params['search'])) { |
| 202 |
$query->where('title', 'LIKE', '%' . sanitize_text_field($params['search']) . '%'); |
| 203 |
} |
| 204 |
if (!empty($params['status'])) { |
| 205 |
$query->where('status', sanitize_text_field($params['status'])); |
| 206 |
} |
| 207 |
|
| 208 |
$paginator = $query->paginate($paging['per_page'], ['*'], 'page', $paging['page']); |
| 209 |
$total = MCPHelper::paginatorTotal($paginator); |
| 210 |
|
| 211 |
$items = MCPHelper::paginatorItems($paginator); |
| 212 |
$counts = FormAccess::entryCounts(array_map(function ($form) { |
| 213 |
return (int) $form->id; |
| 214 |
}, is_array($items) ? $items : iterator_to_array($items))); |
| 215 |
|
| 216 |
$rows = []; |
| 217 |
foreach ($items as $form) { |
| 218 |
$formId = (int) $form->id; |
| 219 |
$rows[] = [ |
| 220 |
'id' => $formId, |
| 221 |
'title' => $form->title, |
| 222 |
'status' => $form->status, |
| 223 |
'type' => $form->type, |
| 224 |
'entries' => isset($counts[$formId]) ? $counts[$formId] : null, |
| 225 |
'created_at' => MCPHelper::toIso8601($form->created_at), |
| 226 |
]; |
| 227 |
} |
| 228 |
|
| 229 |
return MCPHelper::envelope( |
| 230 |
sprintf( |
| 231 |
/* translators: %d: number of matching forms */ |
| 232 |
_n('%d form found.', '%d forms found.', $total, 'fluentform'), |
| 233 |
$total |
| 234 |
), |
| 235 |
['forms' => $rows], |
| 236 |
MCPHelper::pagingMeta($paginator) |
| 237 |
); |
| 238 |
} |
| 239 |
|
| 240 |
public static function getForm($params = []) |
| 241 |
{ |
| 242 |
$form = FormAccess::resolveForm($params); |
| 243 |
if (is_wp_error($form)) { |
| 244 |
return $form; |
| 245 |
} |
| 246 |
$formId = (int) $form->id; |
| 247 |
|
| 248 |
$fields = []; |
| 249 |
try { |
| 250 |
$service = new FormService(); |
| 251 |
$schema = $service->getInputsAndLabels($formId); |
| 252 |
$inputs = isset($schema['inputs']) ? $schema['inputs'] : []; |
| 253 |
$labels = isset($schema['labels']) ? $schema['labels'] : []; |
| 254 |
foreach ($inputs as $key => $input) { |
| 255 |
$fields[] = [ |
| 256 |
'key' => $key, |
| 257 |
'label' => isset($labels[$key]) ? $labels[$key] : (isset($input['admin_label']) ? $input['admin_label'] : $key), |
| 258 |
'element' => isset($input['element']) ? $input['element'] : null, |
| 259 |
]; |
| 260 |
} |
| 261 |
} catch (\Throwable $e) { |
| 262 |
$fields = []; |
| 263 |
} |
| 264 |
|
| 265 |
return MCPHelper::envelope( |
| 266 |
sprintf( |
| 267 |
/* translators: 1: form title, 2: field count */ |
| 268 |
__('Form "%1$s" has %2$d fields.', 'fluentform'), |
| 269 |
$form->title, |
| 270 |
count($fields) |
| 271 |
), |
| 272 |
[ |
| 273 |
'id' => (int) $form->id, |
| 274 |
'title' => $form->title, |
| 275 |
'status' => $form->status, |
| 276 |
'type' => $form->type, |
| 277 |
'entries' => FormAccess::entryCount($form->id), |
| 278 |
'created_at' => MCPHelper::toIso8601($form->created_at), |
| 279 |
'updated_at' => MCPHelper::toIso8601($form->updated_at), |
| 280 |
'fields' => $fields, |
| 281 |
] |
| 282 |
); |
| 283 |
} |
| 284 |
} |
| 285 |
|