PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.8
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.8
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / abilities / zipai / builder / import-html.php

import-html.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.8, at inc/abilities/zipai/builder/import-html.php

651 lines 29.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import HTML — the door an external AI client uses to turn HTML into real
4 * Spectra blocks on this site. The clients are Claude, Codex, Gemini, Cursor
5 * and ChatGPT. The client writes the HTML first.
6 *
7 * The client's own model writes the HTML. The ZIP AI service then converts it.
8 * The converter runs server-side. It is fail-closed on the block styling
9 * contract. The service commits the result back over the MCP of this site.
10 * This ability is the gate in front of that work. It checks that the site is
11 * connected. It decides whether the request needs explicit consent. It turns
12 * every service error into text the calling agent can act on.
13 *
14 * CONSENT: `match_site` is the default. It only ADDS a page. The other layouts
15 * write site-level state. So they run in two phases. The first call returns the
16 * impact and a `confirm_token`. It writes NOTHING. No human is in the loop at a
17 * tool call. So the second call is the consent record.
18 *
19 * @since 0.0.8
20 * @package zip-ai
21 */
22
23 namespace ZipAI\MCP\Classes\Abilities\Zipai\Builder;
24
25 use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
26 use ZipAI\MCP\Classes\Core\Helper;
27 use ZipAI\MCP\Classes\Core\Import_Impact;
28 use ZipAI\MCP\Classes\Core\Response;
29 use ZipAI\MCP\Classes\Core\Tool_Types;
30 use ZipAI\MCP\Classes\Services\Brain_Client;
31
32 defined( 'ABSPATH' ) || exit;
33
34 /**
35 * Ability: import externally-authored HTML as native blocks.
36 */
37 class ImportHtml extends Abstract_Ability {
38
39 /**
40 * Consent window. Long enough for an agent to relay the impact list and come
41 * back, short enough that a token cannot be replayed a session later.
42 */
43 const CONFIRM_TTL = 600;
44
45 /**
46 * Repeat-call window. A tool call that times out client-side is commonly
47 * re-issued; inside this window the same HTML returns the SAME page instead
48 * of creating a duplicate.
49 */
50 const REPEAT_TTL = 600;
51
52 /**
53 * Configures the ability's id, label, description and metadata.
54 *
55 * @return void
56 */
57 public function configure() {
58 $this->id = 'zipai/import-html';
59 $this->label = 'Import HTML as Blocks';
60 $this->description = 'Import HTML as native Spectra/Gutenberg blocks on this WordPress site. Call zipai/get-site-context FIRST and write HTML that matches its palette, fonts and brand, with a slug that does not collide with an existing page. HTML written blind clashes with the site\'s design. Write plain semantic HTML and put ALL per-block styling in `className` (utility classes); never in a `style` attribute, and never as backgroundColor/textColor/boxShadow attributes, which the importer rejects outright. Images: reference full URLs (remote ones are re-hosted into the media library on import); ask the user for their images before inventing stock, and where no real image exists use an explicit https://placehold.co/{width}x{height} placeholder with a descriptive alt. Never a guessed URL: it imports as a broken-image swap. Defaults are non-destructive: the page is created as a DRAFT and your existing header, footer and colours are kept. Any layout that changes site-wide design returns an impact list plus a confirm_token first and writes nothing until you call again with that token. Show the user that list and get their approval before you do.';
61 // Matches the endpoint that reaches it: the external MCP route demands
62 // `manage_options`, and credentials are only issued from an admin-only
63 // screen. A lower bar here would read as "editors can import" when no
64 // editor can get through the door.
65 $this->capability = 'manage_options';
66 // Hidden from the AI chat catalog (`visibility: internal`). The chat
67 // agent must not see this: it would loop back into the server's own
68 // import path and cannot complete the two-phase confirm-token handshake.
69 // External MCP clients still get it via an explicit tool list.
70 $this->meta['visibility'] = 'internal';
71 }
72
73 /**
74 * Returns the tool-type classification for this ability.
75 *
76 * @return string One of the Tool_Types constants.
77 */
78 public function get_tool_type() {
79 return Tool_Types::ACTION;
80 }
81
82 /**
83 * Annotated destructive because the SAME tool performs `replace_site`, which
84 * overwrites the site's header, footer, colours and fonts. The default layout
85 * only adds a draft page, but a client cannot know which layout a call will
86 * carry when it decides whether to prompt — so it should always prompt. The
87 * two-phase confirm token is the real gate; this is the client-side one.
88 *
89 * @return array{readonly: bool, destructive: bool, idempotent: bool}
90 */
91 public function get_annotations() {
92 return array(
93 'readonly' => false,
94 'destructive' => true,
95 // A repeat call inside the dedupe window returns the same page, but
96 // outside it a second call creates a second page.
97 'idempotent' => false,
98 );
99 }
100
101 /**
102 * Returns the JSON Schema for this ability's input arguments.
103 *
104 * @return array<string,mixed> JSON Schema describing accepted arguments.
105 */
106 public function get_input_schema() {
107 return array(
108 'type' => 'object',
109 'required' => array( 'html' ),
110 'additionalProperties' => false,
111 'properties' => array(
112 'html' => array(
113 'type' => 'string',
114 'description' => 'The full HTML of ONE page. Inline any CSS in a <style> tag; external stylesheets are not fetched. Per-block styling must ride className only.',
115 'minLength' => 1,
116 ),
117 'title' => array(
118 'type' => 'string',
119 'description' => 'Page title. Falls back to the <title>/<h1> found in the HTML.',
120 'maxLength' => 250,
121 ),
122 'slug' => array(
123 'type' => 'string',
124 'description' => 'Optional URL slug. A taken slug is resolved to slug-2, slug-3 …; the slug actually used is returned.',
125 'maxLength' => 200,
126 ),
127 'layout' => array(
128 'type' => 'string',
129 'enum' => array( 'match_site', 'standalone', 'replace_site' ),
130 'default' => 'match_site',
131 'description' => 'match_site (default, non-destructive): keep this site\'s header, footer and colours; only the page body is imported. standalone: the page renders with its OWN header and footer, other pages untouched. replace_site: the imported header, footer, colours and fonts replace the site\'s design EVERYWHERE. standalone and replace_site require confirmation.',
132 ),
133 'status' => array(
134 'type' => 'string',
135 'enum' => array( 'draft', 'publish' ),
136 'default' => 'draft',
137 'description' => 'draft (default) lets the user review before anything is public.',
138 ),
139 'set_homepage' => array(
140 'type' => 'boolean',
141 'default' => false,
142 'description' => 'Make this page the site\'s front page. Changes what visitors see first, so it requires confirmation.',
143 ),
144 'sideload_images' => array(
145 'type' => 'boolean',
146 'default' => true,
147 'description' => 'Re-host remote images in the media library. Leave on unless the images must keep pointing at their original URLs.',
148 ),
149 'confirm_token' => array(
150 'type' => 'string',
151 'description' => 'The token from this ability\'s previous impact response. Only send it after the user has approved the listed changes.',
152 ),
153 ),
154 );
155 }
156
157 /**
158 * Import the supplied HTML.
159 *
160 * @param array<string,mixed> $args Validated input arguments.
161 * @return array<string,mixed> Standardized success or error response.
162 */
163 public function execute( $args ) {
164 $html = isset( $args['html'] ) && is_string( $args['html'] ) ? trim( $args['html'] ) : '';
165 if ( '' === $html ) {
166 return Response::error( 'html is required.' );
167 }
168
169 $layout = $this->enum_arg( $args, 'layout', array( 'match_site', 'standalone', 'replace_site' ), 'match_site' );
170 $status = $this->enum_arg( $args, 'status', array( 'draft', 'publish' ), 'draft' );
171 $title = isset( $args['title'] ) && is_string( $args['title'] ) ? sanitize_text_field( $args['title'] ) : '';
172 $slug = isset( $args['slug'] ) && is_string( $args['slug'] ) ? sanitize_title( $args['slug'] ) : '';
173 $set_homepage = ! empty( $args['set_homepage'] );
174 $sideload_images = ! isset( $args['sideload_images'] ) || (bool) $args['sideload_images'];
175 $confirm_token = isset( $args['confirm_token'] ) && is_string( $args['confirm_token'] ) ? $args['confirm_token'] : '';
176
177 // A draft cannot serve as the front page — visitors would get nothing.
178 // Coerced before the fingerprint so the consent covers what really runs.
179 if ( $set_homepage ) {
180 $status = 'publish';
181 }
182
183 // Gate 1 — the account. Checked before anything else: it is the most
184 // common reason a fresh site cannot import, and it is fixable by the
185 // user in one step.
186 if ( '' === Helper::get_decrypted_auth_token() ) {
187 return Response::error(
188 'This site is not connected to a ZIP AI account, so HTML cannot be converted.',
189 'Ask the user to open WP Admin → Settings → ZIP AI and connect their account, then call this tool again.'
190 );
191 }
192
193 $destructive = $this->is_destructive( $layout, $set_homepage );
194
195 // Gate 2 — kept even though the ability now requires `manage_options` (so
196 // an admin satisfies it): it states the intent, and it is the check that
197 // still holds if the ability's own capability is ever lowered.
198 if ( $destructive && ! current_user_can( 'edit_theme_options' ) ) {
199 return Response::error(
200 sprintf( 'This account may create pages but not change the site design, so layout "%s" is not permitted.', $layout ),
201 'Retry with layout "match_site", which only adds a page.'
202 );
203 }
204
205 $fingerprint = $this->fingerprint( $html, $layout, $status, $set_homepage );
206
207 // Gate 3 — repeat call, checked BEFORE consent. A destructive call that
208 // timed out client-side is re-issued with its already-consumed token; if
209 // consent ran first, the retry would bounce off "no longer valid" and
210 // drag the user through a second approval for a page that already
211 // exists. Returning the previous result writes nothing, so no consent is
212 // being skipped.
213 //
214 // The repeat key is DELIBERATELY wider than the consent fingerprint:
215 // slug, title and the sideload choice do not change what the user
216 // consented to, but they DO change what gets created — the same HTML
217 // re-imported with a different slug or title is a NEW page the caller
218 // asked for, not a duplicate submission to swallow.
219 $repeat_key = 'zipai_import_done_' . md5(
220 implode( '|', array( $fingerprint, $slug, $title, $sideload_images ? '1' : '0' ) )
221 );
222 $previous = get_transient( $repeat_key );
223 if ( is_array( $previous ) ) {
224 return Response::success(
225 sprintf(
226 'This HTML was already imported a moment ago. The existing page is "%s"; nothing was created a second time.',
227 $this->str_field( $previous, 'page_url' )
228 ),
229 array(
230 'post_id' => $this->int_field( $previous, 'post_id' ),
231 'page_url' => $this->str_field( $previous, 'page_url' ),
232 'slug' => $this->str_field( $previous, 'slug' ),
233 'block_count' => $this->int_field( $previous, 'block_count' ),
234 'status' => $this->str_field( $previous, 'status' ),
235 'layout' => $this->str_field( $previous, 'layout' ),
236 'repeat' => true,
237 )
238 );
239 }
240
241 // Gate 4 — consent. The first destructive call NEVER writes; it returns
242 // what would change plus the token that authorises it.
243 if ( $destructive ) {
244 $consent = $this->check_consent( $confirm_token, $fingerprint, $layout, $set_homepage );
245 if ( ! $consent['granted'] ) {
246 return $consent['response'];
247 }
248 }
249
250 $payload = array(
251 'wp_url' => get_site_url(),
252 'layout' => $layout,
253 'sideload_images' => $sideload_images,
254 // Never true from this door: the HTML is externally authored and
255 // nobody consented to script injection. The service strips
256 // scripts and imports the rest.
257 'scripts_acknowledged' => false,
258 // Sent as a one-entry `pages[]` rather than the bare `html`
259 // field: only the pages form carries a title and slug.
260 'pages' => array(
261 array(
262 'html' => $html,
263 'slug' => $slug,
264 'title' => $title,
265 'is_homepage' => $set_homepage,
266 ),
267 ),
268 );
269
270 $result = Brain_Client::post( '/import', $payload );
271
272 // Self-heal the one failure this flow cannot ask the user to fix mid-call:
273 // WordPress rejected the application password the service has on file. Only
274 // code running INSIDE WordPress holds the admin identity to mint a new one,
275 // and we are running inside WordPress right now — so re-mint, re-bind and
276 // retry ONCE instead of returning an error the agent can only relay.
277 //
278 // Safe to retry: the credential is rejected on the service's FIRST call
279 // into the site, so nothing was written on the failed attempt.
280 if ( ! $result['ok'] && 'wp_credential_rejected' === $result['code'] && Helper::reprovision_app_password() ) {
281 $result = Brain_Client::post( '/import', $payload );
282 }
283
284 if ( ! $result['ok'] ) {
285 return $this->map_failure( $result );
286 }
287
288 // Consume the consent token only now that the service accepted the write.
289 // Burning it inside check_consent meant a pre-write failure (service
290 // unreachable, theme drift, load shed) killed the token while the failure
291 // text told the agent to retry — dragging the user through a second
292 // approval for a change that never happened. The fingerprint still binds
293 // the surviving token to exactly the consequences that were approved.
294 if ( $destructive ) {
295 delete_transient( 'zipai_import_confirm_' . $fingerprint );
296 }
297
298 return $this->map_success( $result['data'], $layout, $status, $repeat_key );
299 }
300
301 /**
302 * Turn a successful service response into the agent-facing result.
303 *
304 * @param array<string,mixed> $data Decoded server response.
305 * @param string $layout Requested layout.
306 * @param string $status Requested post status.
307 * @param string $repeat_key Transient key guarding repeat calls.
308 * @return array<string,mixed>
309 */
310 private function map_success( array $data, string $layout, string $status, string $repeat_key ) {
311 $pages = isset( $data['pages'] ) && is_array( $data['pages'] ) ? $data['pages'] : array();
312 $page = isset( $pages[0] ) && is_array( $pages[0] ) ? $pages[0] : array();
313
314 // The service reports per-page success inside a 2xx. A page that failed
315 // must not be announced as an import.
316 //
317 // A 2xx failure row is NOT proof nothing was written: the committer
318 // creates the page, then writes GBS / the Style Guide / the shared
319 // chrome, and a throw at any of those steps leaves a real `post_id` on
320 // the row (the site-wide chrome write does exactly this). A page that
321 // exists is reported rather than denied. Claiming "nothing was added"
322 // here left a PUBLISHED page behind (the draft flip below never runs)
323 // while telling the agent otherwise.
324 if ( empty( $page['success'] ) ) {
325 $detail = isset( $page['error'] ) && is_string( $page['error'] ) ? $page['error'] : 'the service did not say why';
326 $failed_id = $this->int_field( $page, 'post_id' );
327 $failed_url = $this->str_field( $page, 'page_url' );
328 $failure_data = array();
329 $next = 'Fix the reported problem in the HTML and call this tool again.';
330
331 if ( $failed_id > 0 ) {
332 $failure_data['post_id'] = $failed_id;
333 if ( '' !== $failed_url ) {
334 $failure_data['page_url'] = $failed_url;
335 }
336 // Created as `publish` by the committer, and the draft flip is
337 // downstream of this branch — so it is live right now.
338 $failure_data['status'] = 'publish';
339 $detail .= sprintf( ' The page WAS created (post %d) and is PUBLISHED. Tell the user, and delete it if they do not want it.', $failed_id );
340 $next = 'Tell the user the page exists and is public, then fix the reported problem and re-run.';
341 } else {
342 $next .= ' Nothing was added to the site.';
343 }
344
345 // A styling-contract rejection arrives on the page ROW, not as a typed
346 // status — the importer catches its own throw per page. It signals the
347 // CONVERTER emitted a banned block attribute (inline CSS in the
348 // submitted HTML is fine and gets translated), so this is our bug, not
349 // the caller's: pass the exact blocks and keys through for a report
350 // rather than telling the agent to rewrite its HTML.
351 if ( isset( $page['violations'] ) && is_array( $page['violations'] ) ) {
352 $failure_data['violations'] = $page['violations'];
353 $next = 'This is a converter fault on our side, not a problem with your HTML. Report the listed blocks and keys to the user and do not simply retry; the same HTML will fail identically.';
354 }
355
356 return Response::error( 'The page could not be imported: ' . $detail, $next, $failure_data );
357 }
358
359 $post_id = $this->int_field( $page, 'post_id' );
360 $page_url = $this->str_field( $page, 'page_url' );
361
362 // The service publishes; honour a `draft` request here, where the post is
363 // local. Done after the commit so chrome/design writes are complete —
364 // which means the page IS briefly public. If the flip fails, the caller
365 // must be told the page is live rather than trusting `status: draft`.
366 $effective_status = $status;
367 if ( 'draft' === $status && $post_id > 0 ) {
368 $flipped = wp_update_post(
369 array(
370 'ID' => $post_id,
371 'post_status' => 'draft',
372 ),
373 true
374 );
375 if ( is_wp_error( $flipped ) ) {
376 $effective_status = 'publish';
377 }
378 }
379
380 $data_out = array(
381 'post_id' => $post_id,
382 'page_url' => $page_url,
383 'slug' => $this->str_field( $page, 'slug' ),
384 'block_count' => $this->int_field( $page, 'block_count' ),
385 'status' => $effective_status,
386 'layout' => $layout,
387 );
388
389 set_transient( $repeat_key, $data_out, self::REPEAT_TTL );
390
391 $message = sprintf(
392 'Imported %d blocks into "%s" (%s).',
393 $data_out['block_count'],
394 '' !== $page_url ? $page_url : 'the new page',
395 'draft' === $effective_status ? 'saved as a draft, not public yet' : 'published'
396 );
397 if ( $effective_status !== $status ) {
398 $message .= ' NOTE: the page could not be switched back to a draft, so it is PUBLIC. Tell the user.';
399 }
400 // A slug the site had to rewrite is worth saying out loud — the agent may
401 // have told the user a URL that does not exist.
402 $requested = $this->str_field( $page, 'submitted_slug' );
403 if ( '' !== $requested && '' !== $data_out['slug'] && $requested !== $data_out['slug'] ) {
404 $message .= sprintf( ' The slug "%s" was taken, so the page uses "%s".', $requested, $data_out['slug'] );
405 }
406 return Response::success( $message, $data_out );
407 }
408
409 /**
410 * Turn a service failure into an actionable agent response.
411 *
412 * @param array{ok: bool, status: int, code: string, message: string, data: array<string,mixed>} $result Client result.
413 * @return array<string,mixed>
414 */
415 private function map_failure( array $result ) {
416 $code = $result['code'];
417 $message = $result['message'];
418 $data = array();
419
420 // One table, so a new service code cannot quietly fall through to a
421 // generic message. `suggestion` is the agent's next action, not prose.
422 $suggestions = array(
423 'no_wordpress_credentials' => 'Ask the user to reconnect WordPress in WP Admin → Settings → ZIP AI.',
424 'wp_credential_rejected' => 'WordPress refused the stored application password, so nothing was written. Ask the user to open WP Admin → Settings → ZIP AI and reconnect the site (this re-issues the password), then call this tool again. Do not retry before they do; it will fail identically.',
425 'contract_violation' => 'This is a converter fault on our side, not a problem with the submitted HTML; inline CSS is legitimate and gets translated. Nothing was imported. Report the listed blocks and attributes to the user; do not simply retry, the same HTML will fail identically.',
426 'payload_too_large' => 'The HTML is too large for one call. Split it into separate pages and import them one at a time.',
427 'import_busy' => 'Wait for the number of seconds in retry_after_seconds, then call this tool again.',
428 'import_in_progress' => 'Another import is already running on this site. Wait for it to finish, then retry.',
429 'theme_drift' => 'The site theme changed while importing. Nothing was written; call this tool again.',
430 'chrome_reader_pending' => 'This site\'s ZIP AI plugin is too old to apply an imported header and footer. Ask the user to update it, or retry with layout "match_site".',
431 'site_mismatch' => 'This site is not the one the connected ZIP AI account is bound to. Ask the user to reconnect it.',
432 'brain_unreachable' => 'The ZIP AI service could not be reached. Nothing was written; retry in a minute.',
433 'bad_response' => 'The ZIP AI service replied in a form this site could not read. Retry; if it repeats, the user should contact support.',
434 );
435
436 // 413 never reaches the service's own taxonomy — the request is refused
437 // at the transport by its byte cap.
438 if ( 413 === $result['status'] ) {
439 $code = 'payload_too_large';
440 $message = 'The HTML is larger than the import service accepts in one request.';
441 }
442
443 if ( 'contract_violation' === $code && isset( $result['data']['violations'] ) && is_array( $result['data']['violations'] ) ) {
444 // Structured, because this is the one failure the calling model can
445 // fix by itself — but only if it is told which block carried which
446 // banned attribute.
447 $data['violations'] = $result['data']['violations'];
448 }
449 if ( 'import_busy' === $code ) {
450 $retry = $this->int_field( $result['data'], 'retry_after_seconds' );
451 if ( $retry > 0 ) {
452 $data['retry_after_seconds'] = $retry;
453 }
454 }
455 $suggestion = isset( $suggestions[ $code ] ) ? $suggestions[ $code ] : 'Report this to the user; nothing was imported.';
456
457 return Response::error( $message, $suggestion, $data );
458 }
459
460 /**
461 * Whether a request needs explicit confirmation before it may write.
462 *
463 * @param string $layout Requested layout.
464 * @param bool $set_homepage Whether the front page would change.
465 * @return bool
466 */
467 private function is_destructive( string $layout, bool $set_homepage ) {
468 return Import_Impact::needs_confirmation( $layout, $set_homepage );
469 }
470
471 /**
472 * Verify a confirmation token, or produce the impact response that mints one.
473 *
474 * @param string $token Token supplied by the caller.
475 * @param string $fingerprint Fingerprint of this exact request.
476 * @param string $layout Requested layout.
477 * @param bool $set_homepage Whether the front page would change.
478 * @return array{granted: bool, response: array<string,mixed>}
479 */
480 private function check_consent( string $token, string $fingerprint, string $layout, bool $set_homepage ) {
481 $key = 'zipai_import_confirm_' . $fingerprint;
482
483 if ( '' !== $token ) {
484 $expected = get_transient( $key );
485 if ( is_string( $expected ) && hash_equals( $expected, $token ) ) {
486 // NOT consumed here: execute() burns the token only after the
487 // service accepts the write, so a pre-write failure leaves it
488 // valid for the retry the failure text asks for. Single-use
489 // still holds — a successful write deletes it, and the repeat
490 // gate answers the window between the write and its expiry.
491 return array(
492 'granted' => true,
493 'response' => array(),
494 );
495 }
496 // A token that does not match this request is not an error to
497 // paper over: either it expired, or the HTML/layout changed after
498 // approval — which means the user approved different consequences.
499 return array(
500 'granted' => false,
501 'response' => Response::error(
502 'That confirmation is no longer valid: it expired, was already used, or the request changed after it was approved.',
503 'Call this tool again WITHOUT confirm_token to get the current impact list, show it to the user, and use the new token.'
504 ),
505 );
506 }
507
508 // The impact copy is shared with the wizard, so it is translated — but
509 // this consumer is an AI client whose surrounding text is English. Pin
510 // the locale so the consent block is not half-translated.
511 $switched = function_exists( 'switch_to_locale' ) ? switch_to_locale( 'en_US' ) : false;
512 $impact = Import_Impact::for_layout( $layout, $set_homepage );
513 if ( $switched ) {
514 restore_current_locale();
515 }
516 $fresh = wp_generate_password( 32, false );
517 set_transient( $key, $fresh, self::CONFIRM_TTL );
518
519 // The token and the impact list are repeated IN THE MESSAGE, not only in
520 // `data`. Some MCP transports keep just the message string on a failed
521 // tool call and drop every structured field — losing the token there
522 // makes the consent flow impossible to complete, because the agent is
523 // told to come back with something it was never given.
524 $narrative = "NOTHING WAS IMPORTED. This request changes more than the new page, so it needs the user's approval first.\n\nWHAT WILL CHANGE:\n- "
525 . implode( "\n- ", $impact['change'] )
526 . "\n\nWHAT WILL NOT CHANGE:\n- "
527 . implode( "\n- ", $impact['keep'] )
528 . "\n\nReversible: " . $impact['reversible']
529 . "\n\nShow the lists above to the user verbatim. ONLY if they approve, call this tool again with the same arguments plus confirm_token: "
530 . $fresh
531 . sprintf( ' (valid for %d seconds).', self::CONFIRM_TTL );
532
533 return array(
534 'granted' => false,
535 'response' => Response::error(
536 $narrative,
537 'Relay will_change and will_not_change to the user in your own message, then repeat the call with confirm_token.',
538 array(
539 'needs_confirmation' => true,
540 'confirm_token' => $fresh,
541 'expires_in_seconds' => self::CONFIRM_TTL,
542 'will_change' => $impact['change'],
543 'will_not_change' => $impact['keep'],
544 'reversible' => $impact['reversible'],
545 )
546 ),
547 );
548 }
549
550 /**
551 * Stable fingerprint of one import request. Binds a confirmation to the exact
552 * consequences the user approved: change the HTML, the layout, the status or
553 * the homepage flag and the old token stops working.
554 *
555 * @param string $html Page HTML.
556 * @param string $layout Requested layout.
557 * @param string $status Requested post status.
558 * @param bool $set_homepage Whether the front page would change.
559 * @return string
560 */
561 private function fingerprint( string $html, string $layout, string $status, bool $set_homepage ) {
562 return md5(
563 implode(
564 '|',
565 array(
566 (string) get_current_user_id(),
567 get_stylesheet(),
568 $layout,
569 $status,
570 $set_homepage ? '1' : '0',
571 md5( $html ),
572 )
573 )
574 );
575 }
576
577 /**
578 * Read an enum argument, falling back to the default for anything unexpected.
579 *
580 * @param array<string,mixed> $args Input arguments.
581 * @param string $key Argument name.
582 * @param string[] $allowed Allowed values.
583 * @param string $default Default value.
584 * @return string
585 */
586 private function enum_arg( array $args, string $key, array $allowed, string $default ) {
587 $value = isset( $args[ $key ] ) && is_string( $args[ $key ] ) ? $args[ $key ] : '';
588 return in_array( $value, $allowed, true ) ? $value : $default;
589 }
590
591 /**
592 * Read a string out of a decoded service response. Everything crossing that
593 * boundary is untyped, so each field is narrowed at the point of use — the
594 * same discipline the importer's own `strField`/`numField` readers apply to
595 * WordPress REST responses.
596 *
597 * @param array<mixed,mixed> $row Decoded row.
598 * @param string $key Field name.
599 * @return string Empty string when absent or not a string.
600 */
601 private function str_field( array $row, string $key ) {
602 return isset( $row[ $key ] ) && is_string( $row[ $key ] ) ? $row[ $key ] : '';
603 }
604
605 /**
606 * Read an integer out of a decoded service response.
607 *
608 * @param array<mixed,mixed> $row Decoded row.
609 * @param string $key Field name.
610 * @return int Zero when absent or not numeric.
611 */
612 private function int_field( array $row, string $key ) {
613 return isset( $row[ $key ] ) && is_numeric( $row[ $key ] ) ? (int) $row[ $key ] : 0;
614 }
615
616 /**
617 * Returns the JSON Schema for this ability's response.
618 *
619 * @return array<string,mixed> JSON Schema describing the response shape.
620 */
621 public function get_output_schema() {
622 return array(
623 'type' => 'object',
624 'required' => array( 'success' ),
625 'properties' => array(
626 'success' => array( 'type' => 'boolean' ),
627 'message' => array( 'type' => 'string' ),
628 'error' => array( 'type' => 'string' ),
629 'suggestion' => array( 'type' => 'string' ),
630 'data' => array(
631 'type' => 'object',
632 'properties' => array(
633 'post_id' => array( 'type' => 'integer' ),
634 'page_url' => array( 'type' => 'string' ),
635 'slug' => array( 'type' => 'string' ),
636 'block_count' => array( 'type' => 'integer' ),
637 'status' => array( 'type' => 'string' ),
638 'layout' => array( 'type' => 'string' ),
639 'needs_confirmation' => array( 'type' => 'boolean' ),
640 'confirm_token' => array( 'type' => 'string' ),
641 'will_change' => array( 'type' => 'array' ),
642 'will_not_change' => array( 'type' => 'array' ),
643 'reversible' => array( 'type' => 'string' ),
644 'violations' => array( 'type' => 'array' ),
645 ),
646 ),
647 ),
648 );
649 }
650 }
651