[label, capability, module id or null]. A destination with a module id appears only while * that module is on (and its prerequisites, such as WooCommerce, are met), and only for people who hold its capability. The slugs are the * existing page addresses; nothing here changes a URL or a permission. */ public const NAV = [ 'aiwend' => ['Overview', Capabilities::MANAGE, null], 'aiwend-connections' => ['AI Connections', Capabilities::MANAGE, null], 'aiwend-bots' => ['Assistants', Capabilities::MANAGE_BOTS, null], 'aiwend-knowledge' => ['Knowledge', Capabilities::MANAGE_KNOWLEDGE, null], 'aiwend-conversations' => ['Conversations', Capabilities::EXPORT_CONVERSATIONS, 'chat'], // the page is registered by the Chat module, so the link exists only while Chat is on 'aiwend-content-studio' => ['Content Studio', Capabilities::USE_CONTENT_STUDIO, 'content_studio'], 'aiwend-woocommerce-ai' => ['WooCommerce AI', Capabilities::USE_WOOCOMMERCE_AI, 'woocommerce_ai'], 'aiwend-forms' => ['AI Forms', Capabilities::MANAGE_FORMS, 'ai_forms'], 'aiwend-mcp' => ['MCP', Capabilities::MANAGE, 'mcp'], 'aiwend-usage' => ['Usage', Capabilities::VIEW_USAGE, null], 'aiwend-diagnostics' => ['Diagnostics', Capabilities::VIEW_USAGE, null], 'aiwend-settings' => ['Settings', Capabilities::MANAGE, null], ]; /** Voluntary support page of the developer: a plain link, nothing is requested from it, loaded from it or tracked. */ public const SUPPORT_URL = 'https://buymeacoffee.com/aiwend'; /** Decorative inline icons (static literals; always rendered with aria-hidden by the caller). */ public const ICON_HEART = ''; public const ICON_EXTERNAL = ''; /** Screens on which the "came from the guided setup" marker is carried along, so the way back survives ordinary navigation there. */ public const MARKED_PAGES = ['aiwend-connections', 'aiwend-bots']; /** * The assistant number carried by the guided setup's marker (aiwend_setup), or 0. Only a positive whole number from an administrator * counts; the marker is never an address, so it can only ever lead to the Overview link that this class builds itself. */ public static function setupMarker(): int { // Read-only marker, validated below as a plain number; a state-changing request has passed its nonce check before this is read. $raw = Input::post('aiwend_setup') !== '' ? Input::post('aiwend_setup') : Input::get('aiwend_setup'); return preg_match('/^[1-9][0-9]{0,9}$/', $raw) === 1 && current_user_can(Capabilities::MANAGE) ? (int) $raw : 0; } public static function url(string $page, array $args = []): string { $marker = in_array($page, self::MARKED_PAGES, true) && !isset($args['aiwend_setup']) ? self::setupMarker() : 0; return add_query_arg(array_merge(['page' => $page], $args, $marker > 0 ? ['aiwend_setup' => $marker] : []), admin_url('admin.php')); } /** Nonce action of the "Use in answers" confirmation screen for one source. */ public static function publishNonceAction(int $sourceId): string { return 'aiwend_publish_screen_' . $sourceId; } /** Address of the confirmation screen for one source; the link carries a nonce so the screen cannot be opened (and a token issued) from a foreign page. */ public static function publishUrl(int $sourceId): string { return self::url('aiwend-publish', ['source' => $sourceId, '_wpnonce' => wp_create_nonce(self::publishNonceAction($sourceId))]); } /** * The HTML an Aiwend admin screen may contain. Screens assemble their markup from escaped parts; this allowlist is applied to the * assembled markup at output as a second, independent barrier (wp_kses): it admits the structural tags, form controls and * plain data/ARIA attributes the screens use, and nothing that can run script (no script/object/embed, no on* handlers, no * javascript: URLs, no srcdoc, no formaction). The decorative SVG icons are the only vector markup. * * @return array> */ public static function allowedHtml(): array { static $cache = null; if ($cache !== null) { return $cache; } $g = ['class' => true, 'id' => true, 'title' => true, 'role' => true, 'lang' => true, 'dir' => true, 'hidden' => true, 'tabindex' => true, 'style' => true, 'data-*' => true] // wp_kses supports the "data-*" wildcard only; an "aria-*" key matches nothing, so every ARIA attribute was silently stripped. Listed one by one. + array_fill_keys(['aria-label', 'aria-labelledby', 'aria-describedby', 'aria-hidden', 'aria-live', 'aria-atomic', 'aria-busy', 'aria-current', 'aria-invalid', 'aria-disabled', 'aria-required', 'aria-expanded', 'aria-controls', 'aria-haspopup', 'aria-checked', 'aria-selected', 'aria-valuenow', 'aria-valuemin', 'aria-valuemax', 'aria-valuetext'], true); $field = $g + ['name' => true, 'value' => true, 'disabled' => true, 'required' => true, 'readonly' => true, 'autocomplete' => true, 'form' => true, 'autofocus' => true]; $tags = []; foreach (['p', 'span', 'div', 'strong', 'em', 'b', 'i', 'small', 'code', 'pre', 'q', 'section', 'article', 'aside', 'header', 'nav', 'main', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'summary', 'thead', 'tbody', 'tfoot', 'caption', 'fieldset', 'legend', 'datalist', 'br', 'hr'] as $t) { $tags[$t] = $g; } $tags['a'] = $g + ['href' => true, 'target' => true, 'rel' => true, 'download' => true]; $tags['img'] = $g + ['src' => true, 'alt' => true, 'width' => true, 'height' => true, 'loading' => true]; $tags['table'] = $g; $tags['tr'] = $g; $tags['th'] = $g + ['scope' => true, 'colspan' => true, 'rowspan' => true]; $tags['td'] = $g + ['colspan' => true, 'rowspan' => true]; $tags['details'] = $g + ['open' => true, 'name' => true]; $tags['form'] = $g + ['method' => true, 'action' => true, 'enctype' => true, 'novalidate' => true, 'accept-charset' => true]; $tags['label'] = $g + ['for' => true]; $tags['input'] = $field + ['type' => true, 'checked' => true, 'min' => true, 'max' => true, 'step' => true, 'minlength' => true, 'maxlength' => true, 'size' => true, 'placeholder' => true, 'pattern' => true, 'list' => true, 'accept' => true, 'multiple' => true, 'inputmode' => true, 'spellcheck' => true]; $tags['textarea'] = $field + ['rows' => true, 'cols' => true, 'minlength' => true, 'maxlength' => true, 'placeholder' => true, 'wrap' => true, 'spellcheck' => true]; $tags['select'] = $field + ['multiple' => true, 'size' => true]; $tags['option'] = $g + ['value' => true, 'selected' => true, 'disabled' => true, 'label' => true]; $tags['optgroup'] = $g + ['label' => true, 'disabled' => true]; $tags['button'] = $field + ['type' => true]; $tags['progress'] = $g + ['value' => true, 'max' => true]; $tags['meter'] = $g + ['value' => true, 'min' => true, 'max' => true, 'low' => true, 'high' => true, 'optimum' => true]; $tags['iframe'] = $g + ['src' => true, 'width' => true, 'height' => true, 'loading' => true, 'sandbox' => true, 'referrerpolicy' => true]; // Decorative icons (static constants of this class). kses keeps attribute names as written, so the camel-cased SVG names are listed as written. $tags['svg'] = ['xmlns' => true, 'viewbox' => true, 'viewBox' => true, 'width' => true, 'height' => true, 'focusable' => true, 'aria-hidden' => true, 'class' => true]; $tags['path'] = ['fill' => true, 'd' => true]; $cache = $tags; return $tags; } /** Markup assembled from escaped parts, passed once more through the allowlist at the moment of output. */ public static function kses(string $html): string { return wp_kses($html, self::allowedHtml()); } /** Opens the screen: wrap, compact brand header with internal navigation and the AI status. */ /** $actions: trusted markup built by the caller with escaped values. */ public static function open(Plugin $plugin, string $active, string $title, string $subtitle = '', string $actions = ''): void { $labels = [ 'aiwend' => __('Overview', 'aiwend'), 'aiwend-connections' => __('AI Connections', 'aiwend'), 'aiwend-bots' => __('Assistants', 'aiwend'), 'aiwend-knowledge' => __('Knowledge', 'aiwend'), 'aiwend-conversations' => __('Conversations', 'aiwend'), 'aiwend-content-studio' => __('Content Studio', 'aiwend'), 'aiwend-woocommerce-ai' => __('WooCommerce AI', 'aiwend'), 'aiwend-forms' => __('AI Forms', 'aiwend'), 'aiwend-mcp' => __('MCP', 'aiwend'), 'aiwend-usage' => __('Usage', 'aiwend'), 'aiwend-diagnostics' => __('Diagnostics', 'aiwend'), 'aiwend-settings' => __('Settings', 'aiwend'), ]; echo '
'; $logo = Brand::url(64); echo ($logo !== null ? '' : '') . 'Aiwend'; echo ''; echo wp_kses( self::aiStatus($plugin), AdminView::allowedHtml()); echo '
'; echo '

' . esc_html($title) . '

'; if ($subtitle !== '') { echo '

' . esc_html($subtitle) . '

'; } echo wp_kses( '
' . $actions . '

', AdminView::allowedHtml()); $marker = $active !== 'aiwend' ? self::setupMarker() : 0; if ($marker > 0) { $bot = $plugin->bots->find(get_current_blog_id(), $marker); echo '

← ' . esc_html__('Back to guided setup', 'aiwend') . ' ' . esc_html__('You came here from the guided setup. Nothing you save here is lost when you go back.', 'aiwend') . '

'; } } public static function close(): void { echo '
'; } /** Actual AI state, never decorative: simulated demo, or no provider. */ public static function aiStatus(Plugin $plugin): string { foreach ($plugin->bots->all(get_current_blog_id()) as $bot) { if ($plugin->ai()->resolve($bot)['mode'] !== 'demo') { return '' . esc_html__('Real AI in use', 'aiwend') . ' ' . esc_html__('(at least one assistant uses an AI connection; messages are sent to the provider)', 'aiwend') . ''; } } if ($plugin->mockProviderEnabled()) { return ' ' . esc_html__('Simulated demo', 'aiwend') . ' (' . esc_html(DemoNotice::brief()) . ')'; } return ' ' . esc_html__('No AI responses', 'aiwend') . ''; } /** @param 'neutral'|'success'|'warn'|'danger'|'info'|'brand' $tone */ public static function badge(string $label, string $tone = 'neutral'): string { return '' . esc_html($label) . ''; } /** Renders the outcome of the previous action (never a success style for a failure). */ public static function notice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $code = isset($_GET['aiwend_notice']) ? sanitize_key(Input::get('aiwend_notice')) : ''; if (str_starts_with($code, 'index_')) { return; // shown inside the index panel, where the action was taken (the page can be very long above it) } $messages = [ 'saved' => [__('Changes saved.', 'aiwend'), 'success'], 'budget_session_refused' => [__('The limit per conversation was not saved: it is lower than the least one answer can reserve, so no conversation could ever be answered. The saved value is unchanged and the figures are shown beside the field. The other limits were saved.', 'aiwend'), 'error'], 'budget_session_invalid' => [__('The limit per conversation was not saved: enter a whole number of units, 1 or more. The saved value is unchanged. The other limits were saved.', 'aiwend'), 'error'], 'budget_session_low' => [__('Saved, but the limit per conversation is lower than one answer from an AI provider reserves. Only the simulated demo can answer within it. The figures are shown beside the field.', 'aiwend'), 'warning'], 'budget_session_tight' => [__('Saved. The limit per conversation allows fewer than two answers from an AI provider, so conversations will end quickly. The figures are shown beside the field.', 'aiwend'), 'warning'], 'rate_saved' => [__('Custom rate saved. Estimated costs of that model now use it. Estimates are not your provider’s invoice.', 'aiwend'), 'success'], 'rate_removed' => [__('Custom rate removed. The model is priced from the bundled list again, or stays unknown if the list does not have it.', 'aiwend'), 'success'], 'rate_missing' => [__('That custom rate was not found.', 'aiwend'), 'error'], 'rate_provider' => [__('Not saved: choose OpenAI, Anthropic or Google Gemini.', 'aiwend'), 'error'], 'rate_model' => [__('Not saved: enter the model name exactly as it appears in your provider account (letters, numbers and . _ : / @ + - only).', 'aiwend'), 'error'], 'rate_currency' => [__('Not saved: enter the currency as a three-letter code in capitals, for example USD or EUR.', 'aiwend'), 'error'], 'rate_rate' => [__('Not saved: rates must be plain numbers above zero with up to six decimals (for example 0.075). A cached rate, if given, cannot be higher than the input rate.', 'aiwend'), 'error'], 'rate_confirm' => [__('Not saved: tick the box to confirm this is the rate your provider charges you.', 'aiwend'), 'error'], 'rate_limit' => [__('Not saved: the list of custom rates is full. Remove one first.', 'aiwend'), 'error'], 'recover_reclassified' => [__('The reservation is now listed as outcome unknown (uncertain). Its held units were not changed, nothing was charged or released, and the provider was not contacted. Resolve it in the list of requests that need reconciliation once you know what your provider billed.', 'aiwend'), 'success'], 'recover_released' => [__('The reservation was released. It had never been sent to the provider, so its held units were returned to your available capacity. Nothing was charged.', 'aiwend'), 'success'], 'recover_not_stale' => [__('Nothing was changed: that reservation is not old enough to be handled here yet.', 'aiwend'), 'error'], 'recover_conflict' => [__('Nothing was changed: that reservation was already handled or changed a moment ago. Reload the page to see its current state.', 'aiwend'), 'error'], 'recover_confirm' => [__('Nothing was changed: tick the confirmation box to continue.', 'aiwend'), 'error'], 'job_retried' => [__('The task was queued again. It runs on the next background run, with the usual delays between attempts.', 'aiwend'), 'success'], 'job_not_retryable' => [__('The task was not changed: it is not a kind of task that can be retried from here, it is no longer stopped, or it does not belong to this site.', 'aiwend'), 'error'], 'upgrade_retried' => [__('The database update finished.', 'aiwend'), 'success'], 'upgrade_still_failing' => [__('The database update still did not finish. Aiwend keeps trying about once a minute; ask your host to check the PHP error log and the database permissions.', 'aiwend'), 'error'], 'repair_queued' => [__('The check was queued. It runs on the next background run.', 'aiwend'), 'success'], 'repair_unavailable' => [__('The check was not queued: the Knowledge module is off.', 'aiwend'), 'error'], 'connection_checked' => [__('Account access checked. Model availability is confirmed; text generation and answer quality have not been tested.', 'aiwend'), 'success'], 'connection_failed' => [__('Connection action failed. Check the credential, site encryption key, account access, model selection and usage configuration. No provider error body is displayed.', 'aiwend'), 'error'], 'connection_in_use' => [__('Connection was not removed. Detach its assistants first, or check that it still exists.', 'aiwend'), 'error'], 'connection_saved' => [__('Connection saved. The key is stored encrypted on this site. Next: check access.', 'aiwend'), 'success'], 'check_failed_auth' => [__('Access check failed: the provider rejected the key (invalid, expired, revoked, or no API access or billing set up).', 'aiwend'), 'error'], 'check_failed_rate_limited' => [__('Access check failed: the provider is rate limiting this key. Try again later.', 'aiwend'), 'error'], 'check_failed_quota_exhausted' => [__('Access check failed: the provider account has reached a spend or usage limit. Raise the limit in the provider console, or wait until it resets.', 'aiwend'), 'error'], 'check_failed_workspace_key' => [__('Access check failed: Anthropic says this key is not scoped to a workspace, and Aiwend does not send a workspace header. In the Claude Console, create a new key inside a workspace (choose that workspace when creating the key) and save the new key here.', 'aiwend'), 'error'], 'check_failed_unavailable' => [__('Access check failed: the provider could not be reached or is unavailable. Nothing was changed at the provider.', 'aiwend'), 'error'], 'check_failed_unexpected' => [__('Access check failed: the provider returned an unexpected response.', 'aiwend'), 'error'], 'test_done' => [__('Test message finished. See the result below.', 'aiwend'), 'success'], 'test_failed' => [__('The test message did not complete. See the result below.', 'aiwend'), 'error'], 'test_blocked' => [__('Test not sent: check access first, choose one of the connection’s models, and make sure a site usage limit is set and available.', 'aiwend'), 'error'], 'default_saved' => [__('Site default saved. Assistants set to “Use site default” now use it.', 'aiwend'), 'success'], 'default_cleared' => [__('Site default removed. Assistants set to “Use site default” stop answering until a default is set.', 'aiwend'), 'success'], 'confirm_required' => [__('Not saved: tick the confirmation box that describes the effect of this change.', 'aiwend'), 'error'], 'connection_disabled' => [__('Connection disabled. Assistants using it stop answering until it is enabled and checked again, or reassigned.', 'aiwend'), 'success'], 'connection_enabled' => [__('Connection enabled. Check access again before it is used.', 'aiwend'), 'success'], 'keys_rotated' => [__('Saved keys re-encrypted under the current encryption key.', 'aiwend'), 'success'], 'keys_rotated_others_pending' => [__('This site\'s saved keys were re-encrypted under the current encryption key. Other sites of this installation still have keys on an older key: keep the older key until they have done the same.', 'aiwend'), 'success'], 'keys_rotation_none' => [__('Nothing to re-encrypt: every saved key already uses the current encryption key.', 'aiwend'), 'success'], 'keys_rotation_partial' => [__('Some saved keys could not be re-encrypted and were left exactly as they were. Make sure the older encryption key is still configured, then try again.', 'aiwend'), 'error'], 'key_replaced' => [__('Key replaced. The previous access check no longer applies; check access again.', 'aiwend'), 'success'], 'connection_deleted' => [__('Connection removed from this site, including its encrypted key. The key itself still exists at the provider; revoke it there if needed.', 'aiwend'), 'success'], 'ai_saved' => [__('AI & model saved.', 'aiwend'), 'success'], 'ai_saved_limit_low' => [__('AI & model saved. With this maximum answer length, one answer reserves more than the limit per conversation allows, so new conversations cannot be answered. Raise the limit on the Usage screen or shorten the answer length.', 'aiwend'), 'warning'], 'created' => [__('Assistant created. Next: add the knowledge it should answer from.', 'aiwend'), 'success'], 'deleted' => [__('Deleted.', 'aiwend'), 'success'], 'assistant_deleted' => [__('Assistant deleted. Its knowledge sources, connection and usage history were not touched.', 'aiwend'), 'success'], 'assistant_deleting' => [__('Assistant deleted and out of service. Its conversations are being erased in the background; this can take a few minutes.', 'aiwend'), 'success'], 'assistant_deleting_waiting' => [__('Assistant deleted and out of service. Some conversations are still to be erased: turn the Chat module on, or press Continue on this screen.', 'aiwend'), 'error'], 'delete_choose' => [__('Nothing was deleted: choose what happens to the conversations of this assistant.', 'aiwend'), 'error'], 'delete_confirm' => [__('Nothing was deleted: type DELETE exactly to confirm.', 'aiwend'), 'error'], 'conversation_deleted' => [__('Conversation deleted from this site, with its messages, sources shown and feedback. External provider records and backups are not affected.', 'aiwend'), 'success'], 'queued' => [__('Added. It is indexed in the background, usually within a minute of a visit to your site; on a quiet site it can take longer. If it stays Queued, press Process now on the Knowledge screen.', 'aiwend'), 'success'], 'process_done' => [__('Indexing ran now. The status of each source is shown below.', 'aiwend'), 'success'], 'process_partial' => [__('Some sources were indexed. More are waiting: a run handles only a few at a time. Press Process now again, or leave the rest to the background run.', 'aiwend'), 'success'], 'process_busy' => [__('A run just started. Wait a few seconds and reload this page.', 'aiwend'), 'warning'], 'process_none' => [__('Nothing was waiting to be indexed.', 'aiwend'), 'success'], 'replaced' => [__('File received. The new version is processed in the background; until it is ready, answers keep using the previous version.', 'aiwend'), 'success'], 'attached' => [__('Knowledge attached to the assistant.', 'aiwend'), 'success'], 'detached' => [__('Removed from this assistant. The source itself is still in the library.', 'aiwend'), 'success'], 'page_created' => [__('A draft page with the chat was created. It is not published.', 'aiwend'), 'success'], 'sample_added' => [__('Sample knowledge added. It is processed in the background.', 'aiwend'), 'success'], 'invalid' => [__('Some values were not valid. Please check the form.', 'aiwend'), 'error'], 'provider_invalid' => [__('Not saved: choose one of the available providers.', 'aiwend'), 'error'], 'destination_required' => [__('Not saved: tick the box that confirms where your key and your visitors\' messages are sent.', 'aiwend'), 'error'], 'key_required' => [__('Not saved: paste the API key from your provider.', 'aiwend'), 'error'], 'key_wrong_provider' => [__('Not saved: this key looks like a key from a different AI provider. Nothing was sent anywhere. Paste the key that belongs to this connection\'s provider.', 'aiwend'), 'error'], 'avatar_invalid' => [__('Appearance not saved: choose a JPEG, PNG, WebP or GIF image from the Media Library for the custom avatar, or pick another avatar option.', 'aiwend'), 'error'], 'appearance_invalid' => [__('Appearance not saved. The name (80 characters), welcome message (300) and suggested questions (up to 4, 120 each) must be plain text without the characters < and >. Also check the launcher label (1–60), listed color, edge spacing (8–160 pixels), and comma-separated positive page IDs.', 'aiwend'), 'error'], 'not_found' => [__('That item was not found, or it was already handled.', 'aiwend'), 'error'], 'nothing_selected' => [__('Nothing was selected.', 'aiwend'), 'error'], 'setup_modules_on' => [__('Chat and Knowledge are on. Visitors still see nothing until you publish an assistant.', 'aiwend'), 'success'], 'setup_modules_partial' => [__('Only one of Chat and Knowledge could be turned on. The step below shows which one is still off and why. Setup cannot continue until both are on.', 'aiwend'), 'error'], 'setup_assistant_created' => [__('Assistant created as a private draft. The next step is shown below.', 'aiwend'), 'success'], 'setup_assistant_exists' => [__('You already have an assistant, so no second one was created. Continuing with it.', 'aiwend'), 'success'], 'setup_budget_invalid' => [__('Limit not saved: enter a whole number of units greater than 0.', 'aiwend'), 'error'], 'setup_locked' => [__('Nothing was changed: that step is not available yet. Finish the earlier steps, or fix what the step lists, and try again.', 'aiwend'), 'error'], 'setup_published' => [__('Assistant published (Active and Public). Visitors see it only on pages where you add the block or shortcode.', 'aiwend'), 'success'], 'module_prerequisite' => [__('That module needs another plugin that is not active on this site. Install and activate it from Plugins, then turn the module on; Aiwend never installs plugins for you.', 'aiwend'), 'error'], 'module_error' => [__('That module cannot be changed right now (check its requirements and dependencies).', 'aiwend'), 'error'], 'uninstall_keep' => [__('Saved: your data is kept when the plugin is deleted.', 'aiwend'), 'success'], 'uninstall_delete' => [__('Saved: when the plugin is deleted, all Aiwend data of this site will be permanently deleted. Back up first.', 'aiwend'), 'success'], 'uninstall_confirm' => [__('Not saved: type DELETE exactly to turn on permanent deletion.', 'aiwend'), 'error'], 'setup_required' => [__('Create an encryption key first (Aiwend settings), or add AIWEND_SECRET_KEY to wp-config.php, before saving provider keys.', 'aiwend'), 'error'], 'not_published' => [__('Only published, public, non-password-protected content can be added.', 'aiwend'), 'error'], 'upload_missing' => [__('Choose a TXT or Markdown file to upload.', 'aiwend'), 'error'], 'upload_pdf_unavailable' => [__('PDF files are not supported in this edition of Aiwend. Upload a TXT or Markdown file, or add the content as a post or page.', 'aiwend'), 'error'], 'upload_type' => [\Aiwend\Knowledge\PdfSupport::available() ? __('Only TXT, Markdown and PDF files are supported.', 'aiwend') : __('Only TXT and Markdown files are supported.', 'aiwend'), 'error'], 'upload_size' => [__('The file is larger than the 2 MB limit.', 'aiwend'), 'error'], 'upload_binary' => [\Aiwend\Knowledge\PdfSupport::available() ? __('This file could not be read as text. If it is a text file, use Save As in your editor, choose UTF-8 as the encoding and upload it again, or upload a PDF document.', 'aiwend') : __('This file could not be read as text. If it is a text file, use Save As in your editor, choose UTF-8 as the encoding and upload it again.', 'aiwend'), 'error'], 'published' => [__('Approved for answers. Assistants with this source attached will now quote it.', 'aiwend'), 'success'], 'publish_stale' => [__('Not published: the source or the list of assistants changed, or the confirmation expired. Review it again and confirm.', 'aiwend'), 'error'], 'publish_required' => [__('Not done: making knowledge public needs an administrator and the confirmation. Add it as Private, or ask an administrator to approve it.', 'aiwend'), 'error'], 'cannot_create_page' => [__('You do not have permission to create pages.', 'aiwend'), 'error'], ]; $messages += IndexPanel::notices(); if (isset($messages[$code])) { [$text, $type] = $messages[$code]; printf('

%3$s', esc_attr($type), $type === 'error' ? 'alert' : 'status', esc_html($text)); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $next = isset($_GET['aiwend_next']) ? esc_url_raw(Input::get('aiwend_next')) : ''; // Phase 89 (P88-3): the link text comes from this fixed map keyed by the notice code, never from the URL, and the // target must match the kind of page that notice leads to; a crafted link cannot show its own words inside a genuine notice. $follow = [ 'page_created' => [__('Open the draft page', 'aiwend'), admin_url('post.php?post=')], 'queued' => [__('Next: attach it to an assistant', 'aiwend'), admin_url('admin.php?page=aiwend-bots')], ]; if ($next !== '' && isset($follow[$code]) && str_starts_with($next, $follow[$code][1])) { echo ' ' . esc_html($follow[$code][0]) . ''; } echo '

'; } } /** Hidden fields and nonce for an admin-post action. */ public static function form(string $do, string $back, array $hidden = [], bool $multipart = false, string $attrs = ''): void { echo wp_kses(sprintf('
', esc_url(admin_url('admin-post.php')), $multipart ? ' enctype="multipart/form-data"' : '', $attrs), AdminView::allowedHtml()); echo ''; foreach ($hidden as $k => $v) { echo ''; } // The same fields as wp_nonce_field(), without its fixed id="_wpnonce", which would be // duplicated when a page has several forms. $marker = self::setupMarker(); if ($marker > 0) { echo ''; } echo ''; wp_referer_field(); } public static function help(string $summary, string $body): string { return '
' . esc_html($summary) . '

' . esc_html($body) . '

'; } }