| 1 |
import apiFetch from '@wordpress/api-fetch'; |
| 2 |
import { __, sprintf } from '@wordpress/i18n'; |
| 3 |
import { addQueryArgs } from '@wordpress/url'; |
| 4 |
|
| 5 |
// Core's run controller 405s any verb that doesn't match these annotations. |
| 6 |
const verbFor = (annotations) => { |
| 7 |
if (annotations?.readonly) return 'GET'; |
| 8 |
if (annotations?.destructive && annotations?.idempotent) return 'DELETE'; |
| 9 |
return 'POST'; |
| 10 |
}; |
| 11 |
|
| 12 |
// Without these, a bare run of an all-optional ability 400s — core validates |
| 13 |
// `input` against the schema and ignores property-level defaults. |
| 14 |
const schemaDefaults = (schema) => |
| 15 |
Object.fromEntries( |
| 16 |
Object.entries(schema?.properties ?? {}) |
| 17 |
.filter(([, property]) => property?.default != null) |
| 18 |
.map(([key, property]) => [key, property.default]), |
| 19 |
); |
| 20 |
|
| 21 |
// Query strings stringify values; PHP treats "false" as truthy, "0" as falsy. |
| 22 |
const encodeBooleans = (value) => { |
| 23 |
if (typeof value === 'boolean') return value ? 1 : 0; |
| 24 |
if (Array.isArray(value)) return value.map(encodeBooleans); |
| 25 |
if (value && typeof value === 'object') |
| 26 |
return Object.fromEntries( |
| 27 |
Object.entries(value).map(([k, v]) => [k, encodeBooleans(v)]), |
| 28 |
); |
| 29 |
return value; |
| 30 |
}; |
| 31 |
|
| 32 |
export default async ({ ability, input }) => { |
| 33 |
const descriptor = (window.extAgentData?.wpAbilities ?? []) |
| 34 |
.flatMap((category) => category.abilities ?? []) |
| 35 |
.find((a) => a.name === ability); |
| 36 |
if (!descriptor?.runHref) { |
| 37 |
// translators: %s is the machine name of a WordPress ability the agent tried to run. |
| 38 |
throw new Error( |
| 39 |
sprintf(__('Ability "%s" is not available.', 'extendify-local'), ability), |
| 40 |
); |
| 41 |
} |
| 42 |
|
| 43 |
// Optional inputs the model didn't fill come through as null at any depth — |
| 44 |
// drop them so schema defaults stand in and the ability sees only real values. |
| 45 |
const isEmptyEntry = (value) => |
| 46 |
!!value && |
| 47 |
typeof value === 'object' && |
| 48 |
!Array.isArray(value) && |
| 49 |
!Object.keys(value).length; |
| 50 |
const withoutNulls = (value) => { |
| 51 |
// An entry the model left blank still has to satisfy the item schema. |
| 52 |
if (Array.isArray(value)) |
| 53 |
return value.map(withoutNulls).filter((entry) => !isEmptyEntry(entry)); |
| 54 |
if (value && typeof value === 'object') { |
| 55 |
return Object.fromEntries( |
| 56 |
Object.entries(value) |
| 57 |
.filter(([, v]) => v != null) |
| 58 |
.map(([k, v]) => [k, withoutNulls(v)]), |
| 59 |
); |
| 60 |
} |
| 61 |
return value; |
| 62 |
}; |
| 63 |
const filled = withoutNulls(input ?? {}); |
| 64 |
const provided = { ...schemaDefaults(descriptor.inputSchema), ...filled }; |
| 65 |
const hasInput = Object.keys(provided).length > 0; |
| 66 |
|
| 67 |
const method = verbFor(descriptor.annotations); |
| 68 |
// The run controller reads `input` from the JSON body for POST, but from the |
| 69 |
// query string for GET/DELETE — pass it where WP will actually look. |
| 70 |
if (method === 'POST') { |
| 71 |
return await apiFetch({ |
| 72 |
url: descriptor.runHref, |
| 73 |
method, |
| 74 |
...(hasInput && { data: { input: provided } }), |
| 75 |
}); |
| 76 |
} |
| 77 |
return await apiFetch({ |
| 78 |
url: hasInput |
| 79 |
? addQueryArgs(descriptor.runHref, { input: encodeBooleans(provided) }) |
| 80 |
: descriptor.runHref, |
| 81 |
method, |
| 82 |
}); |
| 83 |
}; |
| 84 |
|