gateway = $gateway; $this->store = $store; $this->clock = $clock; } /** * The menu badge: pending changes counted as the Approvals list shows them, a plan once however many items it has * (the list shows at most 100 rows per status; the badge counts them all). * It runs on every admin page, so it reads only ids and tools (Store::open_change_heads), never arguments or previews. * * @param Store $store Store. * @param int $now Current time. * @return int */ public static function waiting( Store $store, int $now ): int { return count( self::fold_plans( $store->open_change_heads( 'pending', $now ) ) ); } /** * Rows without the items of the plans among them: a plan's items are decided on the plan's own screen. * * @param array> $rows Change rows (uuid, tool and, for plans, args_json). * @return array> */ private static function fold_plans( array $rows ): array { // A set keyed by item id: linear in rows and items, since the badge runs on every admin page. $in_plans = array(); foreach ( $rows as $row ) { if ( Gateway::PLAN_TOOL === $row['tool'] ) { foreach ( Gateway::plan_item_ids( $row ) as $item ) { $in_plans[ $item ] = true; } } } return array_values( array_filter( $rows, static function ( array $row ) use ( $in_plans ): bool { return ! isset( $in_plans[ (string) $row['uuid'] ] ); } ) ); } /** * Approvals: open changes, or one change in detail. * * @param array $query Sanitized query fields (Request::query()); `change` opens that change. * @return string */ public function approvals( array $query ): string { if ( isset( $query['change'] ) && is_string( $query['change'] ) ) { return $this->detail( $query['change'] ); } $now = $this->clock->now(); $rows = array(); foreach ( array( 'pending', 'approved' ) as $status ) { foreach ( $this->store->changes( array( 'status' => $status ), 100, 0 ) as $row ) { if ( (int) $row['expires_at'] > $now ) { $rows[] = $row; } } } $rows = self::fold_plans( $rows ); $html = Ui::open( __( 'FlitAgent approvals', 'flitagent' ) ) . $this->notice(); $html .= '

' . esc_html__( 'Changes AI agents asked to make. Nothing changes until you approve (unless your policy approves them automatically) and the agent then applies the change before the approval expires. Approved changes stay listed until they are applied or expire.', 'flitagent' ) . '

'; if ( array() === $rows ) { return $html . self::empty_card() . ''; } // Only a waiting change has a Review button; approved ones read View. if ( in_array( 'pending', array_column( $rows, 'status' ), true ) ) { $html .= '

' . esc_html__( 'Click Review to see exactly what will change, then approve or reject it.', 'flitagent' ) . '

'; } $cells = array(); foreach ( $rows as $row ) { $cells[] = array( 'summary' => '' . esc_html( (string) $row['summary'] ) . '', 'requested' => esc_html( $this->age( (int) $row['created_at'] ) ), 'by' => esc_html( Client::display_name( (string) $row['client_name'] ) ) . '' . esc_html( /* translators: %s: the WordPress user the app acts as */ sprintf( __( 'as %s', 'flitagent' ), self::user( (int) $row['requester_user_id'] ) ) ) . '', 'tool' => Ui::tool_name( (string) $row['tool'] ), 'tier' => esc_html( (string) $row['tier'] ), 'status' => Ui::chip( (string) $row['status'] ), // Date and time each keep to one line; a narrow card wraps between them, never at the date's hyphens. 'expires' => '' . esc_html( gmdate( 'Y-m-d', (int) $row['expires_at'] ) ) . ' ' . esc_html( gmdate( 'H:i', (int) $row['expires_at'] ) ) . '', 'details' => self::review_button( $row ), ); } $columns = array( 'summary' => __( 'Summary', 'flitagent' ), 'requested' => __( 'Requested', 'flitagent' ), 'by' => __( 'Requested by', 'flitagent' ), 'tool' => __( 'Tool', 'flitagent' ), 'tier' => __( 'Tier', 'flitagent' ), 'status' => __( 'Status', 'flitagent' ), 'expires' => __( 'Expires (UTC)', 'flitagent' ), 'details' => __( 'Details', 'flitagent' ), ); return $html . '
' . self::list_table( 'flitagent-changes', $columns, $cells, __( 'Changes waiting for approval', 'flitagent' ) ) . '
'; } /** * A row's way into the change: Review while it waits for a decision, View once approved. The label names the change * for screen readers, since every row has the same button text. * * @param array $row A change row (a plan's first change stands for the plan). * @return string */ private static function review_button( array $row ): string { $approved = 'approved' === (string) $row['status']; $verb = $approved ? _x( 'View', 'button: open an approved change', 'flitagent' ) : _x( 'Review', 'button: open a change to approve or reject it', 'flitagent' ); return '' . esc_html( $verb ) . Ui::icon( 'chevron-right', 14 ) . ''; } /** * The Nothing waiting card: the shield, the title and where to look instead. * * @return string */ private static function empty_card(): string { $activity = '' . esc_html__( 'Activity', 'flitagent' ) . ''; return '
' . Ui::icon( 'shield-check', 32 ) . '

' . esc_html__( 'No changes are waiting.', 'flitagent' ) . '

' . '

' . esc_html__( 'When an app proposes a change, it shows here and in the FlitAgent menu badge.', 'flitagent' ) . ' ' . sprintf( /* translators: %s: link to the Activity screen, text "Activity" */ esc_html__( 'See what already happened in %s.', 'flitagent' ), $activity ) . '

'; } /** * Activity: receipts, newest first, filterable by tool and status. * * @param array $query Sanitized query fields (Request::query()): `tool`, `status`, `paged`. * @return string */ public function activity( array $query ): string { $where = array(); foreach ( array( 'tool', 'status' ) as $filter ) { if ( isset( $query[ $filter ] ) && is_string( $query[ $filter ] ) && '' !== $query[ $filter ] ) { $where[ $filter ] = sanitize_key( $query[ $filter ] ); } } $page = isset( $query['paged'] ) ? max( 1, absint( $query['paged'] ) ) : 1; $rows = $this->store->receipts( $where, self::PER_PAGE + 1, ( $page - 1 ) * self::PER_PAGE ); $more = count( $rows ) > self::PER_PAGE; $rows = array_slice( $rows, 0, self::PER_PAGE ); $html = Ui::open( __( 'FlitAgent activity', 'flitagent' ) ) . $this->notice(); $html .= '

' . esc_html__( 'Receipts of every write made, refused or failed through FlitAgent, newest first. Receipts are an audit record kept in this site\'s database; they are not tamper-proof.', 'flitagent' ) . '

'; $html .= $this->filters( $where ); if ( array() === $rows ) { return $html . '

' . esc_html__( 'No receipts match.', 'flitagent' ) . '

'; } $cells = array(); foreach ( $rows as $row ) { // What the design's columns drop (tier, user, arguments, result) opens from the row, so no field is lost. $fields = array( __( 'Tier', 'flitagent' ) => esc_html( (string) $row['tier'] ), __( 'User', 'flitagent' ) => esc_html( self::user( (int) $row['user_id'] ) ), __( 'Arguments', 'flitagent' ) => self::json_full( (string) $row['args_json'] ), __( 'Result', 'flitagent' ) => self::json_full( (string) $row['result_json'] ), __( 'Receipt', 'flitagent' ) => '' . esc_html( (string) $row['uuid'] ) . '', ); if ( '' !== self::rollback( $row, false ) && ( '' !== (string) $row['rolled_back_by'] || '' !== (string) $row['rolls_back'] ) ) { $fields[ __( 'Rollback', 'flitagent' ) ] = esc_html( self::rollback( $row, false ) ); } $details = '
' . esc_html__( 'Details', 'flitagent' ) . '
'; foreach ( $fields as $label => $value ) { $details .= '
' . esc_html( $label ) . '
' . $value . '
'; } $details .= '
'; $cells[] = array( 'tool' => Ui::tool_name( (string) $row['tool'] ) . $details, 'time' => esc_html( self::utc( (int) $row['created_at'] ) ), 'status' => '
' . Ui::chip( (string) $row['status'] ) . ( '' !== (string) $row['rolled_back_by'] ? Ui::chip( 'rolled_back' ) : '' ) . '
', 'approval' => esc_html( self::approval( (string) $row['approval'] ) ), 'client' => esc_html( Client::display_name( (string) $row['client_name'] ) ), 'rollback' => self::rollback( $row ), 'receipt' => '' . self::short_id_html( (string) $row['uuid'] ) . '', ); }//end foreach $columns = array( 'tool' => __( 'Tool', 'flitagent' ), 'time' => __( 'Time (UTC)', 'flitagent' ), 'status' => __( 'Status', 'flitagent' ), 'approval' => __( 'Approval', 'flitagent' ), 'client' => __( 'Client', 'flitagent' ), 'rollback' => __( 'Rollback', 'flitagent' ), 'receipt' => __( 'Receipt', 'flitagent' ), ); $html .= '
' . self::list_table( 'flitagent-receipts', $columns, $cells, __( 'Receipts', 'flitagent' ) ) . '
' . '

' . esc_html__( 'Each row opens to show the tier, user, exact arguments and result.', 'flitagent' ) . '

'; $base = add_query_arg( array( 'page' => 'flitagent-activity' ) + $where + array( '_wpnonce' => wp_create_nonce( Request::VIEW ) ), admin_url( 'admin.php' ) ); $pages = ''; if ( $page > 1 ) { $pages .= '' . esc_html__( 'Newer', 'flitagent' ) . ' '; } if ( $more ) { $pages .= '' . esc_html__( 'Older', 'flitagent' ) . ''; } return $html . ( '' === $pages ? '' : '

' . $pages . '

' ) . ''; } /** * Settings form, then any extra sections (forms of their own, so they cannot go inside this one). * * @param string $sections Extra sections, already escaped (the Connection section, DiagnosticsScreen). * @param string $paired The flitagent.com connection's scope, or '' when the site is not connected. * @return string */ public function settings( string $sections = '', string $paired = '' ): string { $enabled = WpPolicy::is_on( get_option( WpPolicy::OPTION_ENABLED, WpPolicy::DEFAULT_ENABLED ) ); $auto = get_option( WpPolicy::OPTION_AUTO, array() ); $auto = is_array( $auto ) ? $auto : array(); $ttl = WpPolicy::clamp_minutes( get_option( WpPolicy::OPTION_TTL, WpPolicy::DEFAULT_TTL ) ); $remove = WpPolicy::is_on( get_option( WpPolicy::OPTION_REMOVE_DATA, WpPolicy::DEFAULT_REMOVE_DATA ) ); $html = Ui::open( __( 'FlitAgent settings', 'flitagent' ) ) . $this->notice(); if ( is_multisite() ) { $html .= '

' . esc_html__( 'Writes are not available on multisite installs yet. Every write tool refuses, whatever is set here.', 'flitagent' ) . '

'; } $html .= '
' . '' . wp_nonce_field( 'flitagent_settings', '_wpnonce', true, false ); // Access for AI tools: the site's one access setting, the same as the setup guide's step 4. Read and propose changes // stores writes on (`1`), Read only off (`0`), as the Writes checkbox did. $html .= '

' . esc_html__( 'Access for AI tools', 'flitagent' ) . '

' . esc_html__( 'Access for AI tools', 'flitagent' ) . '' . '' . '' . '

' . esc_html__( 'Read and propose changes by default. One setting for every AI tool connected to this site, however it connected; tools follow a change straight away, with no need to reconnect. While Read only, no write tool is offered and nothing can be changed through FlitAgent.', 'flitagent' ) . '

' // A connection paired for reading only stays read whatever is chosen here. . ( 'read' === $paired ? '

' . esc_html__( 'This site is connected to flitagent.com for reading only, so AI tools stay Read only whatever is chosen here. Disconnect the site in FlitAgent → Connect and connect it again to allow changes.', 'flitagent' ) . '

' : '' ) . '
'; $html .= '

' . esc_html__( 'Auto-approve', 'flitagent' ) . '

' . esc_html__( 'Auto-approve', 'flitagent' ) . '
'; foreach ( $this->gateway->auto_approvable_tools() as $tool ) { $html .= ''; } $html .= '

' . esc_html__( 'Used only with Read and propose changes. Apply these tools\' low-risk, reversible changes without asking (for content tools: only plain text and simple blocks, and only while nothing becomes public). Everything else, including destructive changes, always needs your approval, whatever is ticked here.', 'flitagent' ) . '

'; $html .= '

' . esc_html__( 'minutes the agent has to apply a change after you approve it (5-60).', 'flitagent' ) . '
'; $html .= '

' . esc_html__( 'Uninstall', 'flitagent' ) . '

' . '

' . esc_html__( 'On by default: a reinstall starts fresh with the setup guide. Turn it off to keep the settings for a reinstall; deactivating the plugin keeps them either way. Deleting the plugin always disconnects this site and its AI tools from flitagent.com; after you reconnect, set up each AI tool again.', 'flitagent' ) . '

'; $html .= '

'; return $html . $sections . ''; } /** * The current user's one-time notice, then forgets it. * * @return string */ public function notice(): string { return Ui::notice_once(); } /** * One change: facts, preview diff, exact arguments, decision buttons. * * @param string $id Change UUID. * @return string */ private function detail( string $id ): string { $html = Ui::open( __( 'FlitAgent change', 'flitagent' ) ) . $this->notice(); $change = $this->store->change( $id ); if ( null === $change ) { return $html . '

' . esc_html__( 'Change not found.', 'flitagent' ) . '

'; } // Label => escaped value. $facts = array( __( 'Summary', 'flitagent' ) => esc_html( (string) $change['summary'] ), __( 'Status', 'flitagent' ) => Ui::chip( (string) $change['status'] ), __( 'Tool', 'flitagent' ) => Ui::tool_name( (string) $change['tool'] ), __( 'Tier', 'flitagent' ) => esc_html( (string) $change['tier'] ), __( 'Requester', 'flitagent' ) => esc_html( self::user( (int) $change['requester_user_id'] ) ), __( 'Client', 'flitagent' ) => esc_html( Client::display_name( (string) $change['client_name'] ) ), __( 'Requested', 'flitagent' ) => esc_html( $this->age( (int) $change['created_at'] ) ), __( 'Expires', 'flitagent' ) => esc_html( self::utc( (int) $change['expires_at'] ) . ' UTC' ), ); $html .= '
'; foreach ( $facts as $label => $value ) { $html .= '
' . esc_html( $label ) . '
' . $value . '
'; } $html .= '
'; $plan = Gateway::PLAN_TOOL === $change['tool'] ? null : $this->plan_of( $id ); if ( null !== $plan ) { $html .= '

' . esc_html__( 'This change is part of a plan. Review and decide it on the plan:', 'flitagent' ) . ' ' . esc_html( (string) $plan['summary'] ) . '

'; } $preview = Canonical::decode( $change['preview_json'] ); if ( Gateway::PLAN_TOOL === $change['tool'] ) { return $html . $this->plan_detail( $id, $change ) . self::back_link() . ''; } $body = self::preview_body( $preview ); if ( '' !== $body ) { $html .= '

' . esc_html( self::preview_heading( (string) $change['status'] ) ) . '

' . $body . '
'; } $buttons = ''; if ( null !== $plan ) { $buttons = ''; } elseif ( 'pending' === $change['status'] ) { $buttons .= ' '; } if ( null === $plan && in_array( $change['status'], array( 'pending', 'approved' ), true ) ) { $buttons .= ''; } // The decision buttons sit in the arguments card: what you approve is what you read just above them. $arguments = '

' . esc_html__( 'Exact arguments that will run', 'flitagent' ) . '

' . esc_html( self::pretty( Canonical::decode( $change['args_json'] ) ) ) . '
' . ( '' === $buttons ? '' : '

' . $buttons . '

' ) . '
'; if ( '' !== $buttons ) { $html .= '
' . '' . '' . self::decide_nonces( $id ) . $arguments . '
'; } else { $html .= $arguments; } return $html . self::back_link() . ''; } /** * The approve or reject form's two nonces: the form's own, then the one bound to this change (Actions::CHANGE_NONCE). * * @param string $id Change UUID. * @return string */ private static function decide_nonces( string $id ): string { return wp_nonce_field( Actions::DECIDE, '_wpnonce', true, false ) . ''; } /** * The link back to the Approvals list. * * @return string */ private static function back_link(): string { return '

' . esc_html__( 'Back to approvals', 'flitagent' ) . '

'; } /** * A plan: its items with a checkbox each (ticked by default) and one decision form. * * @param string $id Plan UUID. * @param array $change Plan row. * @return string */ private function plan_detail( string $id, array $change ): string { $pending = 'pending' === $change['status']; $rows = array(); $details = ''; foreach ( Gateway::plan_item_ids( $change ) as $item_id ) { $item = $this->store->change( $item_id ); if ( null === $item ) { continue; } // Approving the plan approves these items, so each one's preview and exact arguments are shown here. $details .= '

' . esc_html( (string) $item['summary'] ) . '

' . self::preview_body( Canonical::decode( $item['preview_json'] ) ) . '
' . esc_html__( 'Exact arguments that will run', 'flitagent' ) . '
' . esc_html( self::pretty( Canonical::decode( $item['args_json'] ) ) ) . '
'; // The checkbox is named by the change's summary link (aria-labelledby), so screen readers say what it selects. $label = 'flitagent-item-' . $item_id; $box = $pending ? '' : ''; $rows[] = array( 'select' => $box, 'summary' => '' . esc_html( (string) $item['summary'] ) . '', 'tool' => Ui::tool_name( (string) $item['tool'] ), 'tier' => esc_html( (string) $item['tier'] ), 'status' => Ui::chip( (string) $item['status'] ), ); }//end foreach $columns = array( 'select' => __( 'Include', 'flitagent' ), 'summary' => __( 'Change', 'flitagent' ), 'tool' => __( 'Tool', 'flitagent' ), 'tier' => __( 'Tier', 'flitagent' ), 'status' => __( 'Status', 'flitagent' ), ); $intro = '

' . esc_html__( 'Changes in this plan', 'flitagent' ) . '

' . '

' . esc_html__( 'They are applied in this order, each with its own receipt and rollback. Untick a change to leave it out; approving with nothing ticked rejects the plan. The preview and exact arguments of every change are below.', 'flitagent' ) . '

'; $table = self::list_table( 'flitagent-plan', $columns, $rows, __( 'Changes in this plan', 'flitagent' ) ); $buttons = ''; if ( $pending ) { $buttons .= ' '; } if ( in_array( $change['status'], array( 'pending', 'approved' ), true ) ) { $buttons .= ''; } $card = '
' . $intro . $table . ( '' === $buttons ? '' : '

' . $buttons . '

' ) . '
'; if ( '' !== $buttons ) { $card = '
' . '' . '' . '' . self::decide_nonces( $id ) . $card . '
'; } return $card . '

' . esc_html( self::preview_heading( (string) $change['status'] ) ) . '

' . $details; } /** * The plan a change belongs to, or null. * * @param string $id Change UUID. * @return array|null */ private function plan_of( string $id ): ?array { foreach ( $this->store->changes( array( 'tool' => Gateway::PLAN_TOOL ), 200, 0 ) as $plan ) { if ( in_array( $id, Gateway::plan_item_ids( $plan ), true ) ) { return $plan; } } return null; } /** * A preview: the Now / After legend and diffs when it has text fields to compare, then its data. Many previews have * none (a permanent delete has nothing after it, a cache flush has no before and after), so the legend shows only * above diff rows and the data starts open in their place; an empty preview gives nothing (e2e recheck 2026-10-30, N3). * * @param array $preview Preview. * @return string Escaped HTML, or '' for an empty preview. */ private static function preview_body( array $preview ): string { if ( array() === $preview ) { return ''; } $diffs = self::diffs( $preview ); return ( '' === $diffs ? '' : self::diff_legend() . $diffs ) . '
' . esc_html__( 'Preview data', 'flitagent' ) . '
' . esc_html( self::pretty( $preview ) ) . '
'; } /** * Side-by-side diffs of string fields in before/after (writes) or current/restored (rollbacks). * * @param array $preview Preview. * @return string */ private static function diffs( array $preview ): string { $html = ''; foreach ( array( 'before' => 'after', 'current' => 'restored', ) as $left => $right ) { if ( ! isset( $preview[ $left ], $preview[ $right ] ) || ! is_array( $preview[ $left ] ) || ! is_array( $preview[ $right ] ) ) { continue; } foreach ( $preview[ $right ] as $field => $value ) { $old = isset( $preview[ $left ][ $field ] ) ? $preview[ $left ][ $field ] : ''; if ( ! is_string( $old ) || ! is_string( $value ) ) { continue; } $diff = wp_text_diff( $old, $value, array( 'title_left' => esc_html__( 'Now', 'flitagent' ), 'title_right' => esc_html__( 'After', 'flitagent' ), ) ); $html .= '

' . esc_html( (string) $field ) . '

' . ( '' === $diff ? '

' . esc_html__( 'No visible difference.', 'flitagent' ) . '

' : '
' . wp_kses_post( $diff ) . '
' ); } } return $html; } /** * The preview's heading. "Nothing has changed yet" holds only while the change waits (pending, or approved and not * yet applied) or was stopped before it ran (stale: the target changed since approval); once it is applying, ran, * failed, was rejected or expired, the preview is just the preview. * * @param string $status Change status. * @return string */ private static function preview_heading( string $status ): string { return in_array( $status, array( 'pending', 'approved', 'stale' ), true ) ? __( 'Preview: nothing has changed yet', 'flitagent' ) : __( 'Preview', 'flitagent' ); } /** * The text legend of the diffs (A6: markers and a visible legend, never colour alone). * * @return string */ private static function diff_legend(): string { return '

' . esc_html__( '− Now (removed)', 'flitagent' ) . ' ' . '' . esc_html__( '+ After (added)', 'flitagent' ) . '

'; } /** * Tool and status filters. * * @param array $where Current filters. * @return string */ private function filters( array $where ): string { $tools = array(); foreach ( $this->gateway->tools() as $tool ) { $tools[] = $tool->name(); } $html = '
' . wp_nonce_field( Request::VIEW, '_wpnonce', false, false ); foreach ( array( 'tool' => $tools, 'status' => Gateway::RECEIPT_STATUSES, ) as $name => $choices ) { $current = isset( $where[ $name ] ) ? $where[ $name ] : ''; $html .= ' '; } return $html . '
'; } /** * "user:3" as a name. * * @param string $approval Approval source. * @return string */ private static function approval( string $approval ): string { if ( 0 === strpos( $approval, 'user:' ) ) { return sprintf( /* translators: %s: user name */ __( 'approved by %s', 'flitagent' ), self::user( (int) substr( $approval, 5 ) ) ); } return $approval; } /** * Rollback cell. Ids are shortened (first 4, last 2) with the full id as the title; `$short` false gives plain text * with the full ids, which the row's Details escape themselves. * * @param array $row Receipt row. * @param bool $short Escaped HTML with short ids (true) or plain text with full ids. * @return string */ private static function rollback( array $row, bool $short = true ): string { $by = (string) $row['rolled_back_by']; $of = (string) $row['rolls_back']; $show = static function ( string $id ) use ( $short ): string { return $short ? '' . self::short_id_html( $id ) . '' : $id; }; if ( '' !== $by ) { $text = sprintf( /* translators: %s: receipt id */ $short ? esc_html__( 'rolled back by %s', 'flitagent' ) : __( 'rolled back by %s', 'flitagent' ), $show( $by ) ); } elseif ( '' !== $of ) { $text = sprintf( /* translators: %s: receipt id */ $short ? esc_html__( 'rolls back %s', 'flitagent' ) : __( 'rolls back %s', 'flitagent' ), $show( $of ) ); } else { $view = Rollback::public_view( Canonical::decode( $row['rollback_json'] ) ); $text = $view['available'] ? $view['type'] : __( 'none', 'flitagent' ); $text = $short ? esc_html( $text ) : $text; } return $text; } /** * An id shown short, with the full id for screen readers (a title alone is not reliably announced). * * @param string $id Id. * @return string Escaped HTML. */ private static function short_id_html( string $id ): string { return '' . esc_html( $id ) . ''; } /** * An id as its first 4 and last 2 characters ("d40b…12"); short ids stay whole. * * @param string $id Id. * @return string */ private static function short_id( string $id ): string { return strlen( $id ) > 8 ? substr( $id, 0, 4 ) . '…' . substr( $id, -2 ) : $id; } /** * A user's display name, or "#id". * * @param int $user_id User id. * @return string */ private static function user( int $user_id ): string { $user = $user_id > 0 ? get_userdata( $user_id ) : false; return $user ? (string) $user->display_name : '#' . $user_id; } /** * "5 mins ago". * * @param int $time Unix time. * @return string */ private function age( int $time ): string { return sprintf( /* translators: %s: time difference */ __( '%s ago', 'flitagent' ), human_time_diff( $time, $this->clock->now() ) ); } /** * Unix time as "Y-m-d H:i" UTC. * * @param int $time Unix time. * @return string */ private static function utc( int $time ): string { return gmdate( 'Y-m-d H:i', $time ); } /** * Detail URL of a change, for the browser of the administrator viewing this page: its nonce is good only for them. A * link that leaves the session is WpPolicy::approve_url(), which has none and comes back here through Admin::change_link(). * * @param string $id Change UUID. * @return string */ public static function change_url( string $id ): string { return add_query_arg( array( 'page' => 'flitagent-approvals', 'change' => $id, '_wpnonce' => wp_create_nonce( Request::VIEW ), ), admin_url( 'admin.php' ) ); } /** * Pretty JSON. * * @param mixed $data Data. * @return string */ private static function pretty( $data ): string { return (string) wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } /** * A list table in core's markup (as WP_List_Table prints it): the first column is the primary one, with core's * toggle for narrow screens; every cell has its column class and label. * * @param string $name Extra table class. * @param array $columns Column key => label; the first is the primary column. * @param array> $rows Rows of column key => escaped cell HTML. * @param string $label Accessible name of the scroll region around the table. * @return string */ private static function list_table( string $name, array $columns, array $rows, string $label ): string { // A leading "select" column holds tick boxes: core's check column, its header only for screen readers. $primary = ''; foreach ( array_keys( $columns ) as $key ) { if ( 'select' !== $key ) { $primary = (string) $key; break; } } // A table wider than its card scrolls inside the card; the region takes focus so a keyboard reaches every column. $html = '
'; foreach ( $columns as $key => $label ) { if ( 'select' === $key ) { $html .= ''; continue; } $html .= ''; } $html .= ''; foreach ( $rows as $cells ) { $html .= ''; foreach ( $columns as $key => $label ) { $colname = ' data-colname="' . esc_attr( $label ) . '"'; if ( 'select' === $key ) { $html .= ''; } elseif ( $primary === $key ) { $html .= ''; } else { $html .= ''; } } $html .= ''; } return $html . '
' . esc_html( $label ) . '' . esc_html( $label ) . '
' . $cells[ $key ] . '' . $cells[ $key ] . '' . $cells[ $key ] . '
'; } /** * A JSON value for a row's Details: the complete value, escaped. Nothing is cut, so audit values stay whole. * * @param string $json JSON. * @return string */ private static function json_full( string $json ): string { return '' . esc_html( $json ) . ''; } }