PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/REST/AIEdit.php +344 -187 4.5.04.9.2 View file →
@@ -3,224 +3,381 @@
3 3 namespace WPDeveloper\BetterDocs\REST;
4 4
5 5 use WP_REST_Request;
6 6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 +use WPDeveloper\BetterDocs\Utils\AIUsage;
7 8
8 9 class AIEdit extends BaseAPI {
9 10
10 - const MAX_SELECTION_LENGTH = 12000;
11 - const MAX_INSTRUCTION_LENGTH = 1000;
11 + const MAX_SELECTION_LENGTH = 12000;
12 + const MAX_INSTRUCTION_LENGTH = 1000;
12 13
13 - public function register() {
14 - $this->post(
15 - '/ai-edit',
16 - [ $this, 'generate' ],
17 - [
18 - 'post_id' => [
19 - 'type' => 'integer',
20 - 'required' => true
21 - ],
22 - 'action' => [
23 - 'type' => 'string',
24 - 'required' => true
25 - ],
26 - 'selection' => [
27 - 'type' => 'string',
28 - 'required' => false,
29 - 'default' => ''
30 - ],
31 - 'selection_type' => [
32 - 'type' => 'string',
33 - 'required' => false,
34 - 'default' => 'block'
35 - ],
36 - 'instruction' => [
37 - 'type' => 'string',
38 - 'required' => false,
39 - 'default' => ''
40 - ],
41 - 'option' => [
42 - 'type' => 'string',
43 - 'required' => false,
44 - 'default' => ''
45 - ]
46 - ]
47 - );
48 - }
14 + public function register() {
15 + $this->post(
16 + '/ai-edit',
17 + array( $this, 'generate' ),
18 + array(
19 + 'post_id' => array(
20 + 'type' => 'integer',
21 + 'required' => true
22 + ),
23 + 'action' => array(
24 + 'type' => 'string',
25 + 'required' => true
26 + ),
27 + 'selection' => array(
28 + 'type' => 'string',
29 + 'required' => false,
30 + 'default' => ''
31 + ),
32 + 'selection_type' => array(
33 + 'type' => 'string',
34 + 'required' => false,
35 + 'default' => 'block'
36 + ),
37 + 'instruction' => array(
38 + 'type' => 'string',
39 + 'required' => false,
40 + 'default' => ''
41 + ),
42 + 'option' => array(
43 + 'type' => 'string',
44 + 'required' => false,
45 + 'default' => ''
46 + ),
47 + 'instruction_ids' => array(
48 + 'type' => 'array',
49 + 'required' => false,
50 + 'default' => array()
51 + )
52 + )
53 + );
54 + }
49 55
50 - public function permission_check() {
51 - $post_id = isset( $_REQUEST['post_id'] ) ? intval( $_REQUEST['post_id'] ) : 0;
56 + public function permission_check() {
57 + // Gate on edit_others_posts to match the sibling FAQ/Glossary AI endpoints
58 + // (AIFaq/AIGlossary) and keep Author-role users from spending the AI budget.
59 + return current_user_can( 'edit_others_posts' );
60 + }
52 61
53 - if ( $post_id <= 0 ) {
54 - return current_user_can( 'edit_posts' );
55 - }
62 + public function generate( WP_REST_Request $request ) {
63 + $write_ai = betterdocs()->ai_autowrtie;
56 64
57 - return current_user_can( 'edit_post', $post_id );
58 - }
65 + if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) {
66 + return $this->error(
67 + 'ai_disabled',
68 + __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ),
69 + 400
70 + );
71 + }
59 72
60 - public function generate( WP_REST_Request $request ) {
61 - $write_ai = betterdocs()->ai_autowrtie;
73 + $api_key = $write_ai->get_api_key();
74 + if ( empty( $api_key ) ) {
75 + return $this->error(
76 + 'ai_no_key',
77 + __( 'AI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
78 + 400
79 + );
80 + }
62 81
63 - if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) {
64 - return $this->error(
65 - 'ai_disabled',
66 - __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ),
67 - 400
68 - );
69 - }
82 + $action = sanitize_key( (string) $request->get_param( 'action' ) );
83 + $selection_type = sanitize_key( (string) $request->get_param( 'selection_type' ) );
84 + $selection = (string) $request->get_param( 'selection' );
85 + $instruction = (string) $request->get_param( 'instruction' );
86 + $option = sanitize_text_field( (string) $request->get_param( 'option' ) );
70 87
71 - $api_key = $write_ai->get_api_key();
72 - if ( empty( $api_key ) ) {
73 - return $this->error(
74 - 'ai_no_key',
75 - __( 'OpenAI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
76 - 400
77 - );
78 - }
88 + if ( strlen( $selection ) > self::MAX_SELECTION_LENGTH ) {
89 + $selection = substr( $selection, 0, self::MAX_SELECTION_LENGTH );
90 + }
91 + if ( strlen( $instruction ) > self::MAX_INSTRUCTION_LENGTH ) {
92 + $instruction = substr( $instruction, 0, self::MAX_INSTRUCTION_LENGTH );
93 + }
79 94
80 - $action = sanitize_key( (string) $request->get_param( 'action' ) );
81 - $selection_type = sanitize_key( (string) $request->get_param( 'selection_type' ) );
82 - $selection = (string) $request->get_param( 'selection' );
83 - $instruction = (string) $request->get_param( 'instruction' );
84 - $option = sanitize_text_field( (string) $request->get_param( 'option' ) );
95 + $instruction = wp_kses_post( $instruction );
85 96
86 - if ( strlen( $selection ) > self::MAX_SELECTION_LENGTH ) {
87 - $selection = substr( $selection, 0, self::MAX_SELECTION_LENGTH );
88 - }
89 - if ( strlen( $instruction ) > self::MAX_INSTRUCTION_LENGTH ) {
90 - $instruction = substr( $instruction, 0, self::MAX_INSTRUCTION_LENGTH );
91 - }
97 + // The selection is content to transform, sent verbatim in the OpenAI request
98 + // body (not rendered), and for block selections it is serialized Gutenberg
99 + // markup (`<!-- wp:… -->` delimiters + block HTML). wp_kses_post() would strip
100 + // anything off the post allowlist — inline SVG, embeds, data-* attributes — and
101 + // mangle block delimiters, so the model never sees them and they vanish on
102 + // Accept. Preserve the markup here; WordPress applies kses on the normal save
103 + // path per the user's capability. Only guard against malformed UTF-8.
104 + $selection = wp_check_invalid_utf8( $selection );
92 105
93 - $instruction = wp_kses_post( $instruction );
94 - $selection = wp_kses_post( $selection );
106 + if ( 'inline' !== $selection_type ) {
107 + $selection_type = 'block';
108 + }
95 109
96 - if ( $selection_type !== 'inline' ) {
97 - $selection_type = 'block';
98 - }
110 + $presets = self::get_presets();
99 111
100 - $presets = self::get_presets();
112 + if ( 'custom' !== $action && ! isset( $presets[ $action ] ) ) {
113 + return $this->error(
114 + 'ai_bad_action',
115 + __( 'Unknown AI action.', 'betterdocs' ),
116 + 400
117 + );
118 + }
101 119
102 - if ( $action !== 'custom' && ! isset( $presets[ $action ] ) ) {
103 - return $this->error(
104 - 'ai_bad_action',
105 - __( 'Unknown AI action.', 'betterdocs' ),
106 - 400
107 - );
108 - }
120 + if ( trim( wp_strip_all_tags( $instruction ) ) === '' ) {
121 + return $this->error(
122 + 'ai_empty_instruction',
123 + __( 'Please provide a prompt for the AI.', 'betterdocs' ),
124 + 400
125 + );
126 + }
109 127
110 - if ( trim( wp_strip_all_tags( $instruction ) ) === '' ) {
111 - return $this->error(
112 - 'ai_empty_instruction',
113 - __( 'Please provide a prompt for the AI.', 'betterdocs' ),
114 - 400
115 - );
116 - }
128 + if ( trim( wp_strip_all_tags( $selection ) ) === '' && 'continue' !== $action ) {
129 + return $this->error(
130 + 'ai_empty_selection',
131 + __( 'No content selected for the AI to work on.', 'betterdocs' ),
132 + 400
133 + );
134 + }
117 135
118 - if ( trim( wp_strip_all_tags( $selection ) ) === '' && $action !== 'continue' ) {
119 - return $this->error(
120 - 'ai_empty_selection',
121 - __( 'No content selected for the AI to work on.', 'betterdocs' ),
122 - 400
123 - );
124 - }
136 + $prompt = $this->build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option );
125 137
126 - $prompt = $this->build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option );
138 + // Selected instruction sets → extra system messages layered on the base prompt.
139 + $extra_system = $write_ai->get_instruction_messages( (array) $request->get_param( 'instruction_ids' ) );
127 140
128 - $result = $write_ai->generate_openai_response_full( $prompt );
141 + $result = $write_ai->generate_openai_response_ai_edit( $prompt, $extra_system );
129 142
130 - if ( empty( $result['success'] ) ) {
131 - $message = isset( $result['error'] ) ? (string) $result['error'] : __( 'Unknown AI error.', 'betterdocs' );
132 - return $this->error( 'ai_upstream', $message, 502 );
133 - }
143 + if ( empty( $result[ 'success' ] ) ) {
144 + $message = isset( $result[ 'error' ] ) ? (string) $result[ 'error' ] : __( 'Unknown AI error.', 'betterdocs' );
145 + return $this->error( 'ai_upstream', $message, 502 );
146 + }
134 147
135 - $content = $this->clean_output( (string) $result['content'] );
148 + // Sanitize the model-generated HTML before it reaches the editor (rendered
149 + // via dangerouslySetInnerHTML in the Edit-with-AI preview): strip <script>,
150 + // event handlers, <iframe> and javascript: URLs while keeping valid markup.
151 + $content = wp_kses_post( $this->clean_output( (string) $result[ 'content' ] ) );
136 152
137 - if ( $content === '' ) {
138 - return $this->error(
139 - 'ai_empty_response',
140 - __( 'The AI returned no content. Try again or rephrase your instruction.', 'betterdocs' ),
141 - 502
142 - );
143 - }
153 + if ( '' === $content ) {
154 + return $this->error(
155 + 'ai_empty_response',
156 + __( 'The AI returned no content. Try again or rephrase your instruction.', 'betterdocs' ),
157 + 502
158 + );
159 + }
144 160
145 - return $this->success(
146 - [
147 - 'content' => $content,
148 - 'action' => $action,
149 - 'usage' => [
150 - 'prompt_tokens' => isset( $result['prompt_tokens'] ) ? $result['prompt_tokens'] : null,
151 - 'completion_tokens' => isset( $result['completion_tokens'] ) ? $result['completion_tokens'] : null,
152 - 'total_tokens' => isset( $result['total_tokens'] ) ? $result['total_tokens'] : null,
153 - ],
154 - 'model' => isset( $result['model'] ) ? $result['model'] : null,
155 - ]
156 - );
157 - }
161 + AIUsage::record( 'ai_edit', (int) $request->get_param( 'post_id' ), $action );
158 162
159 - protected function build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option ) {
160 - $core = trim( $instruction );
163 + return $this->success(
164 + array(
165 + 'content' => $content,
166 + 'action' => $action,
167 + 'usage' => array(
168 + 'prompt_tokens' => isset( $result[ 'prompt_tokens' ] ) ? $result[ 'prompt_tokens' ] : null,
169 + 'completion_tokens' => isset( $result[ 'completion_tokens' ] ) ? $result[ 'completion_tokens' ] : null,
170 + 'total_tokens' => isset( $result[ 'total_tokens' ] ) ? $result[ 'total_tokens' ] : null
171 + ),
172 + 'model' => isset( $result[ 'model' ] ) ? $result[ 'model' ] : null
173 + )
174 + );
175 + }
161 176
162 - $preset = $action !== 'custom' && isset( $presets[ $action ] ) ? $presets[ $action ] : null;
163 - $format_hint = isset( $preset['format'] ) && $preset['format']
164 - ? $preset['format']
165 - : ( $selection_type === 'inline'
166 - ? __( 'Return only plain text (no block markup, no quotes, no explanations).', 'betterdocs' )
167 - : __( 'Return only valid Gutenberg-compatible HTML using standard tags such as <p>, <h2>, <ul>, <ol>, <li>, <strong>, <em>, <a>, <code>. Do not include explanations, preambles, code fences, or markdown.', 'betterdocs' ) );
177 + protected function build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option ) {
178 + $core = trim( $instruction );
168 179
169 - $prompt = $core . "\n\n";
170 - $prompt .= __( 'Formatting requirements:', 'betterdocs' ) . ' ' . $format_hint . "\n\n";
171 - $prompt .= __( 'Content to work on:', 'betterdocs' ) . "\n";
172 - $prompt .= "---\n" . $selection . "\n---";
180 + $preset = 'custom' !== $action && isset( $presets[ $action ] ) ? $presets[ $action ] : null;
181 + $format_hint = isset( $preset[ 'format' ] ) && $preset[ 'format' ]
182 + ? $preset[ 'format' ]
183 + : ( 'inline' === $selection_type ? __( 'Return only plain text (no block markup, no quotes, no explanations).', 'betterdocs' )
184 + : __( 'Return only valid Gutenberg-compatible HTML using standard tags such as <p>, <h2>, <ul>, <ol>, <li>, <strong>, <em>, <a>, <code>. Do not include explanations, preambles, code fences, or markdown.', 'betterdocs' ) );
173 185
174 - return $prompt;
175 - }
186 + $prompt = $core . "\n\n";
187 + $prompt .= __( 'Formatting requirements:', 'betterdocs' ) . ' ' . $format_hint . "\n\n";
188 + $prompt .= __( 'Content to work on:', 'betterdocs' ) . "\n";
189 + $prompt .= "---\n" . $selection . "\n---";
176 190
177 - protected function clean_output( $content ) {
178 - $content = trim( $content );
191 + return $prompt;
192 + }
179 193
180 - $content = preg_replace( '/^```(?:html|HTML)?\s*\n?/', '', $content );
181 - $content = preg_replace( '/\n?```\s*$/', '', $content );
194 + protected function clean_output( $content ) {
195 + $content = trim( $content );
182 196
183 - return trim( $content );
184 - }
197 + // Strip a leading fence with any info-string (```html, ```markdown, ```jsx,
198 + // ```text, or a bare ```), not just ```html — otherwise the info-string line
199 + // leaks into the block as literal text.
200 + $content = preg_replace( '/^```[a-z]*\s*\n?/i', '', $content );
201 + $content = preg_replace( '/\n?```\s*$/', '', $content );
185 202
186 - public static function get_presets() {
187 - return [
188 - 'improve' => [
189 - 'prompt' => 'Improve the writing quality, clarity, grammar, and structure of the content below while preserving its original meaning and intent.'
190 - ],
191 - 'rewrite' => [
192 - 'prompt' => 'Rewrite the content below with different wording while keeping the same meaning, tone, and level of detail.'
193 - ],
194 - 'shorten' => [
195 - 'prompt' => 'Make the content below more concise. Preserve all essential information; remove redundancy and filler.'
196 - ],
197 - 'expand' => [
198 - 'prompt' => 'Expand the content below with more detail, examples, and clear explanations. Keep the original voice.'
199 - ],
200 - 'simplify' => [
201 - 'prompt' => 'Rewrite the content below in simpler language so it is easy to understand for a non-technical reader.'
202 - ],
203 - 'fix_grammar' => [
204 - 'prompt' => 'Fix only the grammar, spelling, and punctuation in the content below. Do not rewrite or change the meaning or style.'
205 - ],
206 - 'change_tone' => [
207 - 'prompt' => 'Rewrite the content below in a {option} tone while preserving its meaning.',
208 - 'needs_option' => true
209 - ],
210 - 'translate' => [
211 - 'prompt' => 'Translate the content below into {option}. Preserve meaning, tone, and any HTML structure.',
212 - 'needs_option' => true
213 - ],
214 - 'summarize' => [
215 - 'prompt' => 'Summarize the content below into 2 to 3 clear sentences.'
216 - ],
217 - 'continue' => [
218 - 'prompt' => 'Continue writing naturally from where the content below ends. Match its tone, style, and level of detail.'
219 - ],
220 - 'create_table' => [
221 - 'prompt' => 'Extract the statistics, figures, or comparable data from the content below and represent them as a single HTML table. Infer clear column headers. Include every distinct data point. If no tabular data can be reasonably inferred, return the best possible structured representation.',
222 - 'format' => 'Return only one complete <table> element with <thead> and <tbody>. Use <th scope="col"> for header cells. Do not include <style>, <script>, inline CSS classes, comments, explanations, preambles, code fences, or markdown.'
223 - ]
224 - ];
225 - }
203 + return trim( $content );
204 + }
205 +
206 + /**
207 + * The built-in Edit-with-AI actions, in display order.
208 + *
209 + * Single source of truth for the action chips (modal) and the prompt presets
210 + * (server). Each entry is the editable shape persisted under the `ai_edit_actions`
211 + * setting: { id, label, instruction, enabled, predefined }. Server-only metadata
212 + * (tone/language option, table formatting) lives in {@see self::predefined_extras()}
213 + * and is merged back in at resolve time — it is never stored or user-editable.
214 + *
215 + * @return array<int,array{id:string,label:string,instruction:string,enabled:bool,predefined:bool}>
216 + */
217 + public static function default_actions() {
218 + $defaults = array(
219 + 'improve' => array( __( 'Improve Writing', 'betterdocs' ), 'Improve the writing quality, clarity, grammar, and structure of the content below while preserving its original meaning and intent.' ),
220 + 'shorten' => array( __( 'Make Shorter', 'betterdocs' ), 'Make the content below more concise. Preserve all essential information; remove redundancy and filler.' ),
221 + 'expand' => array( __( 'Make Longer', 'betterdocs' ), 'Expand the content below with more detail, examples, and clear explanations. Keep the original voice.' ),
222 + 'simplify' => array( __( 'Simplify', 'betterdocs' ), 'Rewrite the content below in simpler language so it is easy to understand for a non-technical reader.' ),
223 + 'fix_grammar' => array( __( 'Fix Grammar', 'betterdocs' ), 'Fix only the grammar, spelling, and punctuation in the content below. Do not rewrite or change the meaning or style.' ),
224 + 'change_tone' => array( __( 'Change Tone', 'betterdocs' ), 'Rewrite the content below in a {option} tone while preserving its meaning.' ),
225 + 'translate' => array( __( 'Translate', 'betterdocs' ), 'Translate the content below into {option}. Preserve meaning, tone, and any HTML structure.' ),
226 + 'summarize' => array( __( 'Summarize', 'betterdocs' ), 'Summarize the content below into 2 to 3 clear sentences.' ),
227 + 'create_table' => array( __( 'Create Table', 'betterdocs' ), 'Extract the statistics, figures, or comparable data from the content below and represent them as a single HTML table. Infer clear column headers. Include every distinct data point. If no tabular data can be reasonably inferred, return the best possible structured representation.' ),
228 + );
229 +
230 + $actions = array();
231 + foreach ( $defaults as $id => $pair ) {
232 + $actions[] = array(
233 + 'id' => $id,
234 + 'label' => $pair[0],
235 + 'instruction' => $pair[1],
236 + 'enabled' => true,
237 + 'predefined' => true,
238 + );
239 + }
240 +
241 + return $actions;
242 + }
243 +
244 + /**
245 + * Server-only metadata for the predefined actions, keyed by id.
246 + *
247 + * Never persisted and not user-editable — merged into resolved presets so that
248 + * `change_tone`/`translate` keep their {option} substitution and `create_table`
249 + * keeps its single-<table> formatting contract.
250 + *
251 + * @return array<string,array{needs_option?:string,structural?:bool,format?:string}>
252 + */
253 + private static function predefined_extras() {
254 + return array(
255 + 'change_tone' => array( 'needs_option' => 'tone' ),
256 + 'translate' => array( 'needs_option' => 'language' ),
257 + 'create_table' => array(
258 + 'structural' => true,
259 + 'format' => 'Return only one complete <table> element with <thead> and <tbody>. Use <th scope="col"> for header cells. Do not include <style>, <script>, inline CSS classes, comments, explanations, preambles, code fences, or markdown.',
260 + ),
261 + );
262 + }
263 +
264 + /**
265 + * The resolved action list: stored overrides merged onto {@see self::default_actions()}.
266 + *
267 + * Predefined actions always appear (so newly shipped ones surface on sites with an
268 + * older stored value) carrying any saved enabled/label/instruction overrides; custom
269 + * (predefined:false) actions are appended in their stored order. Mirrors the
270 + * normalize-then-guarantee pattern of {@see \WPDeveloper\BetterDocs\Core\WriteWithAI::get_instructions()}.
271 + *
272 + * @return array<int,array{id:string,label:string,instruction:string,enabled:bool,predefined:bool}>
273 + */
274 + public static function get_actions() {
275 + $defaults = self::default_actions();
276 +
277 + $stored = betterdocs()->settings->get( 'ai_edit_actions', array() );
278 + $by_id = array();
279 + if ( is_array( $stored ) ) {
280 + foreach ( $stored as $item ) {
281 + if ( ! is_array( $item ) || empty( $item['id'] ) ) {
282 + continue;
283 + }
284 + $id = sanitize_key( (string) $item['id'] );
285 + $by_id[ $id ] = array(
286 + 'id' => $id,
287 + 'label' => isset( $item['label'] ) ? (string) $item['label'] : '',
288 + 'instruction' => isset( $item['instruction'] ) ? (string) $item['instruction'] : '',
289 + 'enabled' => isset( $item['enabled'] ) ? (bool) $item['enabled'] : true,
290 + 'predefined' => ! empty( $item['predefined'] ),
291 + );
292 + }
293 + }
294 +
295 + $actions = array();
296 + $seen = array();
297 +
298 + // Predefined actions first, in canonical order, with stored overrides applied.
299 + foreach ( $defaults as $default ) {
300 + $id = $default['id'];
301 + $seen[ $id ] = true;
302 + $override = isset( $by_id[ $id ] ) ? $by_id[ $id ] : array();
303 + $actions[] = array(
304 + 'id' => $id,
305 + 'label' => $default['label'], // label is locked for predefined.
306 + 'instruction' => isset( $override['instruction'] ) && '' !== trim( $override['instruction'] ) ? $override['instruction'] : $default['instruction'],
307 + 'enabled' => array_key_exists( 'enabled', $override ) ? $override['enabled'] : true,
308 + 'predefined' => true,
309 + );
310 + }
311 +
312 + // Custom actions (anything stored that isn't a predefined id), in stored order.
313 + foreach ( $by_id as $id => $item ) {
314 + if ( isset( $seen[ $id ] ) || $item['predefined'] ) {
315 + continue;
316 + }
317 + if ( '' === trim( $item['instruction'] ) ) {
318 + continue; // an action with no prompt would do nothing.
319 + }
320 + $actions[] = array(
321 + 'id' => $id,
322 + 'label' => '' !== trim( $item['label'] ) ? $item['label'] : $id,
323 + 'instruction' => $item['instruction'],
324 + 'enabled' => $item['enabled'],
325 + 'predefined' => false,
326 + );
327 + }
328 +
329 + return $actions;
330 + }
331 +
332 + /**
333 + * The enabled actions exposed to the editor modal, in its expected shape.
334 + *
335 + * @return array<int,array{id:string,label:string,prompt:string,needsOption:(string|false),structural:bool}>
336 + */
337 + public static function get_localized_actions() {
338 + $extras = self::predefined_extras();
339 + $actions = array();
340 +
341 + foreach ( self::get_actions() as $action ) {
342 + if ( empty( $action['enabled'] ) ) {
343 + continue;
344 + }
345 + $extra = isset( $extras[ $action['id'] ] ) ? $extras[ $action['id'] ] : array();
346 + $actions[] = array(
347 + 'id' => $action['id'],
348 + 'label' => $action['label'],
349 + 'prompt' => $action['instruction'],
350 + 'needsOption' => isset( $extra['needs_option'] ) ? $extra['needs_option'] : false,
351 + 'structural' => ! empty( $extra['structural'] ),
352 + );
353 + }
354 +
355 + return $actions;
356 + }
357 +
358 + /**
359 + * The enabled actions as prompt presets, keyed by id, for {@see self::generate()}.
360 + *
361 + * Derived from {@see self::get_actions()} so the stored settings are the single
362 + * source of truth; disabled actions are absent and therefore rejected by the route.
363 + *
364 + * @return array<string,array{prompt:string,needs_option?:string,format?:string}>
365 + */
366 + public static function get_presets() {
367 + $extras = self::predefined_extras();
368 + $presets = array();
369 +
370 + foreach ( self::get_actions() as $action ) {
371 + if ( empty( $action['enabled'] ) ) {
372 + continue;
373 + }
374 + $preset = array( 'prompt' => $action['instruction'] );
375 + if ( isset( $extras[ $action['id'] ] ) ) {
376 + $preset = array_merge( $preset, $extras[ $action['id'] ] );
377 + }
378 + $presets[ $action['id'] ] = $preset;
379 + }
380 +
381 + return $presets;
382 + }
226 383 }