diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0f37f3c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,39 @@ +# Wordpress coding rules + +- Escape every rendered variable. +- Never trust request data. +- Validate first, sanitize second. +- Use nonces + capability checks for state changes. +- Use WordPress APIs before custom code. +- Use `$wpdb->prepare()` for dynamic SQL. +- Prefix all identifiers. +- Wrap user-facing strings in i18n functions. +- Keep templates dumb. +- Prefer readable, hook-friendly code. +- Trap all possible errors for on-screen display. Do not allow Wordpress Cricital Errors to occur. +- Interact with third-party software via well-known interfaces, not custom DOM probing. +- Don't embed magic numbers in the code - such as known memory limits for a particular operation. +- All production configuration is via admin setup page (or user's profile page for per-user setup) + +# Project Rules + +Provide user error feedback on the pane/modal that was enabling user-interaction. +Prevent exiting the page with an error that causes loss of data already entered, instead +require that the user fix the error or "Quit" the page. + +Obey rules in requirements/environment.md + +# Generated JS/PHP Quoting Safety Rules + +1. Never hand-escape JS strings inside PHP string literals when avoidable. +2. When emitting JS from PHP, pass dynamic string values via `json_encode(..., JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)` and assign them to JS variables. +3. Prefer PHP `nowdoc`/`heredoc` blocks for inline JS/HTML over long concatenated quoted strings. +4. Do not use JS regex literals inside PHP-quoted JS when a non-regex alternative exists (`split/join`, `replaceAll`, explicit loops). +5. If regex is required, construct with `new RegExp(...)` from safely encoded string parts, not literal `/.../` in PHP-quoted code. +6. Avoid nested quote composition (`"\'` or `\\"` chains). Build tokens with template variables + encoded parts. +7. For Twig token generation, standardize one helper function and reuse it everywhere; do not duplicate escaping logic. +8. After any change to embedded JS in PHP, run: + - `php -l ` + - project-wide PHP lint for plugin files before finishing. +9. If a change touches emitted JS, include a runtime sanity check step: open affected admin page and confirm browser console has no syntax errors. +10. In fixes for syntax/runtime issues, prefer minimal reversible changes first, then refactor for cleanliness only after recovery. diff --git a/dist/feca_mailshots_plugin-0.1.92.zip b/dist/feca_mailshots_plugin-0.1.92.zip new file mode 100644 index 0000000..662d4b4 Binary files /dev/null and b/dist/feca_mailshots_plugin-0.1.92.zip differ diff --git a/docs/requirements_review_2026-04-23.md b/docs/requirements_review_2026-04-23.md new file mode 100644 index 0000000..086d139 --- /dev/null +++ b/docs/requirements_review_2026-04-23.md @@ -0,0 +1,49 @@ +# Requirements Review - 2026-04-23 + +## Scope +- Reviewed files under `requirements/` against current implementation in `feca_mailshots_plugin/`. + +## Findings + +### 1) High: Packaging script requirement not implemented +- Requirement: + - `requirements/environment.md` states `scripts/` must contain a packaging script that creates an uploadable WordPress plugin package. + - Reference: `/home/adrians/persistent/nosnap/development/mailshots-plugin/requirements/environment.md:62` +- Current implementation: + - `scripts/` currently contains: + - `cleanup_testing_artefacts.sh` + - `deploy_remote.sh` + - `mysql_tunnel.sh` + - `run_fixture_server.sh` + - `sql/` + - No packaging script is present. +- Gap: + - Requirement is unmet. + +### 2) High: Data source REST capability split requirement not implemented +- Requirement: + - Data source API auth/capabilities require: + - create/update/delete => management capability (e.g. `manage_mailshots`) + - read/preview/validate => read capability (e.g. `read_mailshots`) + - References: + - `/home/adrians/persistent/nosnap/development/mailshots-plugin/requirements/mailshot_data_source.md:427` + - `/home/adrians/persistent/nosnap/development/mailshots-plugin/requirements/mailshot_data_source.md:428` +- Current implementation: + - `DataSourcesAdminPage` defines one capability only: + - `private const CAPABILITY = 'edit_pages';` + - `/home/adrians/persistent/nosnap/development/mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:13` + - Both REST permission callbacks use the same capability: + - `restCanRead()` -> `currentUserCan(self::CAPABILITY)` + - `/home/adrians/persistent/nosnap/development/mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:584` + - `restCanManage()` -> `currentUserCan(self::CAPABILITY)` + - `/home/adrians/persistent/nosnap/development/mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php:589` +- Gap: + - Requirement for separate read/manage capability model is unmet. + +## Notes +- This review focused on direct, verifiable requirement-to-code mismatches. +- Files reviewed include: + - `requirements/environment.md` + - `requirements/mailshot_data_source.md` + - `requirements/third_party_software.md` + - `feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php` diff --git a/feca_mailshots_plugin/feca_mailshots_plugin.php b/feca_mailshots_plugin/feca_mailshots_plugin.php index 9a48ea1..7ae4147 100644 --- a/feca_mailshots_plugin/feca_mailshots_plugin.php +++ b/feca_mailshots_plugin/feca_mailshots_plugin.php @@ -3,7 +3,7 @@ * Plugin Name: FECA Mailshots * Plugin URI: https://fenedge.co.uk/ * Description: FECA mailshots plugin. - * Version: 0.1.26 + * Version: 0.1.93 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: FECA diff --git a/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php b/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php new file mode 100644 index 0000000..ccca06e --- /dev/null +++ b/feca_mailshots_plugin/src/Admin/AdminRequestHelpers.php @@ -0,0 +1,254 @@ +wp->requestParam($name, $default) ?? $default); + } + + private function requestInt(string $name, int $default = 0): int + { + return (int) ($this->wp->requestParam($name, (string) $default) ?? (string) $default); + } + + private function requestOptionalInt(string $name = 'id'): ?int + { + $raw = trim($this->requestString($name, '')); + return $raw === '' ? null : (int) $raw; + } + + private function enforceCapabilityOrJson(string $capability): bool + { + if ($this->wp->currentUserCan($capability)) { + return true; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return false; + } + + private function enforcePostOrJson(): bool + { + if ($this->requestMethod() === 'POST') { + return true; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Method not allowed. Use POST.'], 405); + return false; + } + + private function enforceNonceOrJson(string $nonceAction): bool + { + $nonce = trim($this->requestString('_wpnonce', '')); + if ($nonce !== '' && $this->wp->verifyNonce($nonce, $nonceAction)) { + return true; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Invalid or missing nonce.'], 403); + return false; + } + + private function enforceMutationGuardOrJson(string $capability, string $nonceAction): bool + { + if ($this->mutationGuardValid($capability, $nonceAction)) { + return true; + } + if (!$this->wp->currentUserCan($capability)) { + $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + return false; + } + if ($this->requestMethod() !== 'POST') { + $this->wp->sendJson(['ok' => false, 'error' => 'Method not allowed. Use POST.'], 405); + return false; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Invalid or missing nonce.'], 403); + return false; + } + + private function mutationGuardValid(string $capability, string $nonceAction): bool + { + if (!$this->wp->currentUserCan($capability)) { + return false; + } + if ($this->requestMethod() !== 'POST') { + return false; + } + $nonce = trim($this->requestString('_wpnonce', '')); + if ($nonce === '') { + return false; + } + return $this->wp->verifyNonce($nonce, $nonceAction); + } + + private function hiddenNonceField(string $nonceAction): string + { + return ''; + } + + private function redirectTo(string $url): void + { + if (!headers_sent()) { + header('Location: ' . $url, true, 302); + exit; + } + } + + private function redirectToAdminPage(string $page, ?int $editId = null): void + { + $url = $this->wp->adminUrl('admin.php?page=' . $page . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); + $this->redirectTo($url); + } + + private function uploadedFileToBase64(string $fileField): ?string + { + if (!isset($_FILES[$fileField]) || !is_array($_FILES[$fileField])) { + return null; + } + + $error = (int) ($_FILES[$fileField]['error'] ?? UPLOAD_ERR_NO_FILE); + if ($error === UPLOAD_ERR_NO_FILE) { + return null; + } + if ($error !== UPLOAD_ERR_OK) { + throw new \RuntimeException('File upload failed for "' . $fileField . '" with error code ' . $error . '.'); + } + + $tmp = (string) ($_FILES[$fileField]['tmp_name'] ?? ''); + if ($tmp === '' || !is_file($tmp)) { + throw new \RuntimeException('Uploaded file for "' . $fileField . '" is not available.'); + } + + $bytes = file_get_contents($tmp); + if ($bytes === false) { + throw new \RuntimeException('Unable to read uploaded file for "' . $fileField . '".'); + } + + return base64_encode($bytes); + } + + /** @return array|null */ + private function consumeOptionArray(string $optionKey): ?array + { + $raw = $this->wp->getOption($optionKey, null); + $this->wp->deleteOption($optionKey); + return is_array($raw) ? $raw : null; + } + + /** + * Persists result banner payload and draft state in a consistent way: + * - always stores result payload + * - clears draft on success + * - stores draft on failure when provided + * @param array $result + * @param array|null $draft + */ + private function persistResultAndDraft(string $resultOptionKey, string $draftOptionKey, array $result, ?array $draft): void + { + $this->wp->updateOption($resultOptionKey, $result); + if (!empty($result['ok'])) { + $this->wp->deleteOption($draftOptionKey); + return; + } + if (is_array($draft)) { + $this->wp->updateOption($draftOptionKey, $draft); + } + } + + /** @param array|null $result */ + private function modalErrorHtml(?array $result): string + { + if ($result === null || !empty($result['ok']) || empty($result['errors']) || !is_array($result['errors'])) { + return ''; + } + $html = '
'; + $html .= 'Please fix the following:
    '; + foreach ($result['errors'] as $error) { + $html .= '
  • ' . htmlspecialchars((string) $error) . '
  • '; + } + $html .= '
'; + return $html; + } + + /** + * @param callable():array $list + * @param callable():array $save + * @param callable():array $delete + */ + private function handleBasicCrudApi(string $op, callable $list, callable $save, callable $delete): void + { + if ($op === 'list') { + $this->wp->sendJson($list()); + return; + } + if ($op === 'save') { + $this->wp->sendJson($save()); + return; + } + if ($op === 'delete') { + $this->wp->sendJson($delete()); + return; + } + $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); + } + + /** + * @param list $fieldIds + */ + private function modalEditorScript( + string $modalId, + string $openButtonId, + string $closeButtonId, + string $formId, + string $editorIdFieldId, + array $fieldIds, + bool $hasEdit, + bool $hasDraft, + bool $hasResult, + bool $resultOk, + ?string $defaultSelectFieldId = null, + ?string $defaultSelectValue = null, + ?string $uploadFieldId = null, + ?string $fileNameFieldId = null + ): string { + $cfg = [ + 'modalId' => $modalId, + 'openButtonId' => $openButtonId, + 'closeButtonId' => $closeButtonId, + 'formId' => $formId, + 'editorIdFieldId' => $editorIdFieldId, + 'fieldIds' => array_values($fieldIds), + 'hasEdit' => $hasEdit, + 'hasDraft' => $hasDraft, + 'hasResult' => $hasResult, + 'resultOk' => $resultOk, + 'defaultSelectFieldId' => $defaultSelectFieldId, + 'defaultSelectValue' => $defaultSelectValue, + 'uploadFieldId' => $uploadFieldId, + 'fileNameFieldId' => $fileNameFieldId, + ]; + return ''; + } +} diff --git a/feca_mailshots_plugin/src/Admin/AttachmentsAdminPage.php b/feca_mailshots_plugin/src/Admin/AttachmentsAdminPage.php index 8e4212d..642ce32 100644 --- a/feca_mailshots_plugin/src/Admin/AttachmentsAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/AttachmentsAdminPage.php @@ -9,8 +9,13 @@ use FecaMailshots\WordPress\WordPressFacade; final class AttachmentsAdminPage { + use AdminRequestHelpers; + private const CAPABILITY = 'edit_pages'; private const RESULT_OPTION_KEY = 'feca_mailshots_attachments_ui_result'; + private const DRAFT_OPTION_KEY = 'feca_mailshots_attachments_ui_draft'; + private const PAGE_SLUG = 'feca-mailshots-attachments'; + private const NONCE_ACTION = 'feca_mailshots_attachments'; /** @var callable(): AttachmentService */ private $serviceFactory; private WordPressFacade $wp; @@ -32,7 +37,7 @@ final class AttachmentsAdminPage public function registerMenu(): void { - $this->wp->addSubmenuPage('feca-mailshot', 'Attachments', 'Attachments', self::CAPABILITY, 'feca-mailshots-attachments', [$this, 'render']); + $this->wp->addSubmenuPage('feca-mailshot', 'Attachments', 'Attachments', self::CAPABILITY, self::PAGE_SLUG, [$this, 'render']); } public function render(): void @@ -42,7 +47,11 @@ final class AttachmentsAdminPage return; } $items = $this->service()->list(); + $draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY); $editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); + if ($editId <= 0 && is_array($draft) && isset($draft['id'])) { + $editId = (int) $draft['id']; + } $editItem = null; foreach ($items as $row) { if ((int) ($row['id'] ?? 0) === $editId) { @@ -50,11 +59,16 @@ final class AttachmentsAdminPage break; } } - $result = $this->result(); + $result = $this->consumeOptionArray(self::RESULT_OPTION_KEY); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $name = (string) ($editItem['name'] ?? ''); $fileName = (string) ($editItem['file_name'] ?? ''); - $mime = (string) ($editItem['mime_type'] ?? 'application/octet-stream'); + $base64 = ''; + if (is_array($draft)) { + $name = (string) ($draft['name'] ?? $name); + $fileName = (string) ($draft['file_name'] ?? $fileName); + $base64 = (string) ($draft['file_bytes_base64'] ?? ''); + } echo '

Attachments

'; if ($result !== null) { @@ -76,7 +90,9 @@ final class AttachmentsAdminPage echo '

'; echo '
'; echo '

' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '

'; + echo $this->modalErrorHtml($result); echo ''; + echo $this->hiddenNonceField(self::NONCE_ACTION); if ($editId > 0) { echo ''; } else { @@ -85,13 +101,12 @@ final class AttachmentsAdminPage echo ''; echo ''; echo ''; - echo ''; echo ''; - echo ''; + echo ''; echo ''; echo '

'; if ($editId > 0) { - echo 'Cancel Edit'; + echo 'Cancel Edit'; } echo '

'; echo '
'; @@ -110,6 +125,7 @@ final class AttachmentsAdminPage echo 'Edit '; echo '
'; echo ''; + echo $this->hiddenNonceField(self::NONCE_ACTION); echo ''; echo ''; echo '
'; @@ -119,54 +135,49 @@ final class AttachmentsAdminPage echo 'No attachments found.'; } echo ''; - echo ''; + echo $this->modalEditorScript( + 'att-editor-modal', + 'att-open-new', + 'att-close-editor', + 'att-editor-form', + 'att-editor-id', + ['att_name', 'att_file_name', 'att_base64'], + $editId > 0, + is_array($draft), + $result !== null, + !empty($result['ok']), + null, + null, + 'att_file_upload', + 'att_file_name' + ); echo ''; } public function handleUiSave(): void { - if (!$this->wp->currentUserCan(self::CAPABILITY)) { - $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } - $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); - $id = $idRaw === '' ? null : (int) $idRaw; - $base64 = (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''); - if ($base64 === '' && isset($_FILES['file_upload']) && is_array($_FILES['file_upload']) && (int) ($_FILES['file_upload']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK) { - $tmp = (string) ($_FILES['file_upload']['tmp_name'] ?? ''); - if ($tmp !== '' && is_file($tmp)) { - $bytes = file_get_contents($tmp); - if ($bytes !== false) { - $base64 = base64_encode($bytes); - } - } - } - $payload = [ - 'name' => (string) ($this->wp->requestParam('name', '') ?? ''), - 'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''), - 'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'), - 'file_bytes_base64' => $base64, - ]; + $id = $this->requestOptionalInt('id'); + $uploadedBase64 = $this->uploadedFileToBase64('file_upload'); + $base64 = $uploadedBase64 !== null ? $uploadedBase64 : $this->requestString('file_bytes_base64', ''); + $payload = $this->attachmentPayload($base64); $result = $this->service()->save($id, $payload); - $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); + $this->persistResultAndDraft(self::RESULT_OPTION_KEY, self::DRAFT_OPTION_KEY, $result, ['id' => $id] + $payload); $editId = $id; if (!empty($result['ok']) && isset($result['id'])) { $editId = (int) $result['id']; } - $url = $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); - if (!headers_sent()) { - header('Location: ' . $url, true, 302); - exit; - } + $this->redirectToAdminPage(self::PAGE_SLUG, $editId); } public function handleUiDelete(): void { - if (!$this->wp->currentUserCan(self::CAPABILITY)) { - $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } - $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); + $id = $this->requestInt('id', 0); $result = ['ok' => true]; try { if ($id > 0) { @@ -176,48 +187,38 @@ final class AttachmentsAdminPage $result = ['ok' => false, 'errors' => [$e->getMessage()]]; } $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); - if (!headers_sent()) { - header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-attachments'), true, 302); - exit; - } + $this->redirectToAdminPage(self::PAGE_SLUG); } public function handleApi(): void { - if (!$this->wp->currentUserCan(self::CAPABILITY)) { - $this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403); + if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) { return; } - $op = (string) ($this->wp->requestParam('op', 'list') ?? 'list'); + $op = $this->requestString('op', 'list'); try { - if ($op === 'list') { - $this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]); - return; - } - - if ($op === 'save') { - $idRaw = $this->wp->requestParam('id', ''); - $id = $idRaw === '' ? null : (int) $idRaw; - $payload = [ - 'name' => (string) ($this->wp->requestParam('name', '') ?? ''), - 'file_name' => (string) ($this->wp->requestParam('file_name', '') ?? ''), - 'mime_type' => (string) ($this->wp->requestParam('mime_type', 'application/octet-stream') ?? 'application/octet-stream'), - 'file_bytes_base64' => (string) ($this->wp->requestParam('file_bytes_base64', '') ?? ''), - ]; - $this->wp->sendJson($this->service()->save($id, $payload)); - return; - } - - if ($op === 'delete') { - $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); - $this->service()->delete($id); - $this->wp->sendJson(['ok' => true]); - return; - } - - $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); + $this->handleBasicCrudApi( + $op, + fn(): array => ['ok' => true, 'items' => $this->service()->list()], + function (): array { + if (!$this->mutationGuardValid(self::CAPABILITY, self::NONCE_ACTION)) { + return ['ok' => false, 'error' => 'Mutation guard rejected request.']; + } + $id = $this->requestOptionalInt('id'); + $payload = $this->attachmentPayload($this->requestString('file_bytes_base64', '')); + return $this->service()->save($id, $payload); + }, + function (): array { + if (!$this->mutationGuardValid(self::CAPABILITY, self::NONCE_ACTION)) { + return ['ok' => false, 'error' => 'Mutation guard rejected request.']; + } + $id = $this->requestInt('id', 0); + $this->service()->delete($id); + return ['ok' => true]; + } + ); } catch (\Throwable $e) { $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); } @@ -228,11 +229,15 @@ final class AttachmentsAdminPage return ($this->serviceFactory)(); } - /** @return array|null */ - private function result(): ?array + /** @return array{name:string,file_name:string,mime_type:string,file_bytes_base64:string} */ + private function attachmentPayload(string $base64): array { - $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); - $this->wp->deleteOption(self::RESULT_OPTION_KEY); - return is_array($raw) ? $raw : null; + return [ + 'name' => $this->requestString('name', ''), + 'file_name' => $this->requestString('file_name', ''), + 'mime_type' => $this->requestString('mime_type', ''), + 'file_bytes_base64' => $base64, + ]; } + } diff --git a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php index f5730e9..c82f286 100644 --- a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php @@ -8,8 +8,12 @@ use FecaMailshots\WordPress\WordPressFacade; final class DataSourcesAdminPage { + use AdminRequestHelpers; + private const CAPABILITY = 'edit_pages'; private const RESULT_OPTION_KEY = 'feca_mailshots_data_sources_ui_result'; + private const DRAFT_OPTION_KEY = 'feca_mailshots_data_sources_ui_draft'; + private const NONCE_ACTION = 'feca_mailshots_data_sources'; /** @var callable(): \FecaMailshots\Application\DataSourceService */ private $serviceFactory; @@ -72,7 +76,11 @@ final class DataSourcesAdminPage } $items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort); + $draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY); $editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); + if ($editId <= 0 && is_array($draft) && isset($draft['id'])) { + $editId = (int) $draft['id']; + } $editItem = null; foreach ($items as $row) { if ((int) ($row['ID'] ?? 0) === $editId) { @@ -82,7 +90,11 @@ final class DataSourcesAdminPage } $name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : ''; $dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : ''; - $result = $this->result(); + if (is_array($draft)) { + $name = (string) ($draft['name'] ?? $name); + $dsl = (string) ($draft['dsl_text'] ?? $dsl); + } + $result = $this->consumeOptionArray(self::RESULT_OPTION_KEY); $sourceFieldsMap = $this->service()->sourceFields(); $schemaList = $this->service()->listSchemas(); @@ -116,14 +128,22 @@ final class DataSourcesAdminPage echo '
'; echo ''; - echo ' '; - echo '