serviceFactory = $serviceFactory; $this->wp = $wp; } public function register(): void { $this->wp->addAction('admin_menu', [$this, 'registerMenu']); $this->wp->addAction('admin_menu', [$this, 'hideDuplicateRootSubmenu'], 999); $this->wp->addAction('admin_post_feca_mailshots_data_sources_api', [$this, 'handleApi']); $this->wp->addAction('rest_api_init', [$this, 'registerRestRoutes']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_save', [$this, 'handleUiSave']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_delete', [$this, 'handleUiDelete']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_duplicate', [$this, 'handleUiDuplicate']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_validate', [$this, 'handleUiValidate']); $this->wp->addAction('admin_post_feca_mailshots_data_sources_ui_preview', [$this, 'handleUiPreview']); } public function registerMenu(): void { $this->wp->addMenuPage('FECA Mailshots', 'FECA Mailshots', self::CAPABILITY, 'feca-mailshot', [$this, 'renderRoot']); $this->wp->addSubmenuPage('feca-mailshot', 'Mailshot Data Sources', 'Data Sources', self::CAPABILITY, 'feca-mailshot-data-sources', [$this, 'render']); } public function hideDuplicateRootSubmenu(): void { if (function_exists('remove_submenu_page')) { remove_submenu_page('feca-mailshot', 'feca-mailshot'); } } public function renderRoot(): void { $target = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots'); if (!headers_sent()) { header('Location: ' . $target, true, 302); exit; } echo '

FECA Mailshots

Open Mailshots

'; } public function render(): void { if (!$this->wp->currentUserCan(self::CAPABILITY)) { echo 'Permission denied'; return; } $action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $filter = trim((string) ($this->wp->requestParam('q', '') ?? '')); $sort = (string) ($this->wp->requestParam('sort', 'name') ?? 'name'); if (!in_array($sort, ['name', 'updated_desc', 'updated_asc'], true)) { $sort = 'name'; } $items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort); $draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY); $requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); $draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0; $useDraft = is_array($draft) && ( $requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId) ); $editId = $requestedEditId; if ($editId <= 0 && $useDraft && $draftId > 0) { $editId = $draftId; } $editItem = null; foreach ($items as $row) { if ((int) ($row['ID'] ?? 0) === $editId) { $editItem = $row; break; } } $name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : ''; $dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : ''; if ($useDraft && 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(); echo '
'; echo $this->renderAdminUiStyles(); echo '

Mailshot Data Sources

'; if ($result !== null) { $ok = !empty($result['ok']); $title = $ok ? 'Action succeeded.' : 'Action failed.'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error'; echo '
'; echo '' . htmlspecialchars($title) . ''; if (!empty($result['errors']) && is_array($result['errors'])) { echo '

' . htmlspecialchars(implode('; ', $result['errors'])) . '

'; } if (!empty($result['warnings']) && is_array($result['warnings'])) { echo '

Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '

'; } if (isset($result['count'])) { echo '

Preview count: ' . (int) $result['count'] . '

'; } echo '
'; echo ''; } echo '
Total data sources: ' . count($items) . '
'; echo '
'; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo '
'; echo '
'; echo '

'; echo '
'; echo '
'; echo '
'; echo '

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

'; echo $this->modalErrorHtml($result); echo $this->hiddenNonceField(self::NONCE_ACTION); if ($editId > 0) { echo ''; } else { echo ''; } echo ''; echo ''; echo ''; echo ''; echo '

'; echo ' '; echo ' '; echo ' '; echo ' '; echo ' '; echo '

'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '

Build DSL

'; echo '

Construct a DSL sentence from sources and constraints.

'; echo '
'; echo '
'; echo '

Data Sources

'; foreach (array_keys($sourceFieldsMap) as $builtInSource) { $escapedSource = htmlspecialchars((string) $builtInSource, ENT_QUOTES); echo '
'; } echo '
'; echo 'Add custom source
'; echo ' '; echo ' '; echo ''; echo '
    '; echo '
    '; echo '
    '; echo '

    Constraints / Filters

    '; echo '
    '; echo '

    '; echo '
    '; echo '
    '; echo 'Generated DSL'; echo ''; echo '

    '; echo '

    '; echo '
    '; echo '

    '; echo ' '; echo ''; echo '

    '; if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) { $rows = $result['rows']; echo '
    '; echo '

    Preview (first ' . count($rows) . ' rows)

    '; if ($rows === []) { echo '

    No rows returned.

    '; } else { $columns = []; foreach ($rows as $r) { if (!is_array($r)) { continue; } foreach (array_keys($r) as $k) { $columns[(string) $k] = true; } } $columns = array_keys($columns); echo ''; foreach ($columns as $col) { echo ''; } echo ''; foreach ($rows as $r) { if (!is_array($r)) { continue; } echo ''; foreach ($columns as $col) { echo ''; } echo ''; } echo '
    ' . htmlspecialchars($col) . '
    ' . htmlspecialchars((string) ($r[$col] ?? '')) . '
    '; } echo '
    '; } echo '

    Existing Data Sources

    '; echo '
    '; echo ''; foreach ($items as $row) { $id = (int) ($row['ID'] ?? 0); $editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&edit_id=' . $id . '&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort)); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } if ($items === []) { echo ''; } echo '
    IDNameDSL SentenceUpdated AtActions
    ' . $id . '' . htmlspecialchars((string) ($row['name'] ?? '')) . '' . htmlspecialchars((string) ($row['dsl_text'] ?? '')) . '' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . 'Edit '; echo '
    '; echo ''; echo $this->hiddenNonceField(self::NONCE_ACTION); echo ''; echo ''; echo '
    '; echo '
    '; echo ''; echo $this->hiddenNonceField(self::NONCE_ACTION); echo ''; echo ''; echo '
    No data sources found.
    '; echo '
    '; echo ''; echo '
    '; } public function handleUiSave(): void { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); $id = $idRaw === '' ? null : (int) $idRaw; $name = (string) ($this->wp->requestParam('name', '') ?? ''); $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $result = $this->service()->save( $id, $name, $dsl ); $this->persistResultAndDraft( self::RESULT_OPTION_KEY, self::DRAFT_OPTION_KEY, $result, ['id' => $id, 'name' => $name, 'dsl_text' => $dsl] ); $this->redirectToEditId($result, $id); } public function handleUiValidate(): void { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $name = (string) ($this->wp->requestParam('name', '') ?? ''); $validation = $this->service()->validateDsl($dsl); $result = [ 'ok' => ($validation['errors'] ?? []) === [], 'errors' => $validation['errors'] ?? [], 'warnings' => $validation['warnings'] ?? [], ]; $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); $id = $idRaw === '' ? null : (int) $idRaw; $this->wp->updateOption(self::DRAFT_OPTION_KEY, ['id' => $id, 'name' => $name, 'dsl_text' => $dsl]); $this->redirectToEditId($result, $id); } public function handleUiPreview(): void { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $name = (string) ($this->wp->requestParam('name', '') ?? ''); $preview = $this->service()->preview($dsl, 50); $result = [ 'ok' => ($preview['errors'] ?? []) === [], 'errors' => $preview['errors'] ?? [], 'warnings' => $preview['warnings'] ?? [], 'count' => (int) ($preview['count'] ?? 0), 'rows' => is_array($preview['rows'] ?? null) ? $preview['rows'] : [], ]; $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); $idRaw = trim((string) ($this->wp->requestParam('id', '') ?? '')); $id = $idRaw === '' ? null : (int) $idRaw; $this->wp->updateOption(self::DRAFT_OPTION_KEY, ['id' => $id, 'name' => $name, 'dsl_text' => $dsl]); $this->redirectToEditId($result, $id); } public function handleUiDelete(): void { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); $result = ['ok' => true]; try { if ($id > 0) { $this->service()->delete($id); } } catch (\Throwable $e) { $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-mailshot-data-sources'), true, 302); exit; } } public function handleUiDuplicate(): void { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); $result = ['ok' => false, 'errors' => ['Data source ID is required.']]; try { if ($id > 0) { $result = $this->service()->duplicate($id); } } catch (\Throwable $e) { $result = ['ok' => false, 'errors' => [$e->getMessage()]]; } $this->wp->updateOption(self::RESULT_OPTION_KEY, $result); $editId = !empty($result['ok']) && isset($result['id']) ? (int) $result['id'] : 0; $url = $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources' . ($editId > 0 ? '&edit_id=' . $editId : '')); if (!headers_sent()) { header('Location: ' . $url, true, 302); exit; } } public function handleApi(): void { if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) { return; } $op = (string) ($this->wp->requestParam('op', 'list') ?? 'list'); try { if ($op === 'list') { $this->wp->sendJson(['ok' => true, 'items' => $this->service()->list()]); return; } if ($op === 'get') { $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); $item = $id > 0 ? $this->service()->get($id) : null; if ($item === null) { $this->wp->sendJson(['ok' => false, 'error' => 'Data source not found'], 404); return; } $this->wp->sendJson(['ok' => true, 'item' => $item]); return; } if ($op === 'fields') { $this->wp->sendJson(['ok' => true, 'fields' => $this->service()->sourceFields()]); return; } if ($op === 'schemas') { $this->wp->sendJson(['ok' => true, 'items' => $this->service()->listSchemas()]); return; } if ($op === 'tables') { $schema = (string) ($this->wp->requestParam('schema', '') ?? ''); $this->wp->sendJson(['ok' => true, 'items' => $this->service()->listTables($schema)]); return; } if ($op === 'source_fields') { $source = (string) ($this->wp->requestParam('source', '') ?? ''); $this->wp->sendJson(['ok' => true, 'source' => $source, 'fields' => $this->service()->sourceFieldsForSource($source)]); return; } if ($op === 'validate') { $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $this->wp->sendJson(['ok' => true] + $this->service()->validateDsl($dsl)); return; } if ($op === 'preview') { $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $limit = (int) ($this->wp->requestParam('limit', '50') ?? '50'); $this->wp->sendJson(['ok' => true] + $this->service()->preview($dsl, $limit)); return; } if ($op === 'save') { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $idRaw = $this->wp->requestParam('id', ''); $id = $idRaw === '' ? null : (int) $idRaw; $name = (string) ($this->wp->requestParam('name', '') ?? ''); $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $this->wp->sendJson($this->service()->save($id, $name, $dsl)); return; } if ($op === 'delete') { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); $this->service()->delete($id); $this->wp->sendJson(['ok' => true]); return; } if ($op === 'duplicate') { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { return; } $id = (int) ($this->wp->requestParam('id', '0') ?? '0'); $this->wp->sendJson($this->service()->duplicate($id)); return; } $this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400); } catch (\Throwable $e) { $this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500); } } public function registerRestRoutes(): void { if (!function_exists('register_rest_route')) { return; } register_rest_route('mailshots/v1', '/data-sources', [ 'methods' => 'GET', 'callback' => [$this, 'restList'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources', [ 'methods' => 'POST', 'callback' => [$this, 'restCreate'], 'permission_callback' => [$this, 'restCanManage'], ]); register_rest_route('mailshots/v1', '/data-sources/(?P\d+)', [ 'methods' => 'GET', 'callback' => [$this, 'restGetOne'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources/(?P\d+)', [ 'methods' => 'PUT', 'callback' => [$this, 'restUpdate'], 'permission_callback' => [$this, 'restCanManage'], ]); register_rest_route('mailshots/v1', '/data-sources/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [$this, 'restDelete'], 'permission_callback' => [$this, 'restCanManage'], ]); register_rest_route('mailshots/v1', '/data-sources/validate', [ 'methods' => 'POST', 'callback' => [$this, 'restValidate'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources/preview', [ 'methods' => 'POST', 'callback' => [$this, 'restPreview'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources/(?P\d+)/preview', [ 'methods' => 'POST', 'callback' => [$this, 'restPreviewById'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources/schemas', [ 'methods' => 'GET', 'callback' => [$this, 'restSchemas'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources/tables', [ 'methods' => 'GET', 'callback' => [$this, 'restTables'], 'permission_callback' => [$this, 'restCanRead'], ]); register_rest_route('mailshots/v1', '/data-sources/source-fields', [ 'methods' => 'GET', 'callback' => [$this, 'restSourceFields'], 'permission_callback' => [$this, 'restCanRead'], ]); } private function service(): \FecaMailshots\Application\DataSourceService { return ($this->serviceFactory)(); } public function restCanRead(): bool { return $this->wp->currentUserCan(self::CAPABILITY); } public function restCanManage(): bool { return $this->wp->currentUserCan(self::CAPABILITY); } /** @return array */ public function restList() { return ['ok' => true, 'items' => $this->service()->list()]; } /** @return array */ public function restGetOne($request) { $id = (int) $this->reqParam($request, 'id', 0); $item = $id > 0 ? $this->service()->get($id) : null; if ($item === null) { return ['ok' => false, 'error' => 'Data source not found']; } return ['ok' => true, 'item' => $item]; } /** @return array */ public function restCreate($request) { $name = (string) $this->reqParam($request, 'name', ''); $dsl = (string) $this->reqParam($request, 'dsl_text', ''); return $this->service()->save(null, $name, $dsl); } /** @return array */ public function restUpdate($request) { $id = (int) $this->reqParam($request, 'id', 0); $name = (string) $this->reqParam($request, 'name', ''); $dsl = (string) $this->reqParam($request, 'dsl_text', ''); return $this->service()->save($id > 0 ? $id : null, $name, $dsl); } /** @return array */ public function restDelete($request) { $id = (int) $this->reqParam($request, 'id', 0); try { if ($id > 0) { $this->service()->delete($id); } return ['ok' => true]; } catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; } } /** @return array */ public function restValidate($request) { $dsl = (string) $this->reqParam($request, 'dsl_text', ''); return ['ok' => true] + $this->service()->validateDsl($dsl); } /** @return array */ public function restPreview($request) { $dsl = (string) $this->reqParam($request, 'dsl_text', ''); $limit = (int) $this->reqParam($request, 'limit', 50); return ['ok' => true] + $this->service()->preview($dsl, $limit); } /** @return array */ public function restPreviewById($request) { $id = (int) $this->reqParam($request, 'id', 0); $item = $id > 0 ? $this->service()->get($id) : null; if (!is_array($item)) { return ['ok' => false, 'error' => 'Data source not found']; } $dsl = (string) ($item['dsl_text'] ?? ''); $limit = (int) $this->reqParam($request, 'limit', 50); return ['ok' => true] + $this->service()->preview($dsl, $limit); } /** @return array */ public function restSchemas() { return ['ok' => true, 'items' => $this->service()->listSchemas()]; } /** @return array */ public function restTables($request) { $schema = (string) $this->reqParam($request, 'schema', ''); return ['ok' => true, 'items' => $this->service()->listTables($schema)]; } /** @return array */ public function restSourceFields($request) { $source = (string) $this->reqParam($request, 'source', ''); if ($source === '') { return ['ok' => true, 'fields' => $this->service()->sourceFields()]; } return ['ok' => true, 'source' => $source, 'fields' => $this->service()->sourceFieldsForSource($source)]; } /** @param list> $items @return list> */ private function filteredAndSortedItems(array $items, string $filter, string $sort): array { if ($filter !== '') { $needle = strtolower($filter); $items = array_values(array_filter($items, static function (array $row) use ($needle): bool { $name = strtolower((string) ($row['name'] ?? '')); $dsl = strtolower((string) ($row['dsl_text'] ?? '')); return strpos($name, $needle) !== false || strpos($dsl, $needle) !== false; })); } usort($items, static function (array $a, array $b) use ($sort): int { if ($sort === 'updated_desc' || $sort === 'updated_asc') { $av = (string) ($a['updated_at'] ?? ''); $bv = (string) ($b['updated_at'] ?? ''); $cmp = strcmp($av, $bv); return $sort === 'updated_desc' ? -$cmp : $cmp; } return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')); }); return $items; } /** @param array $result */ private function redirectToEditId(array $result, ?int $currentEditId): void { $editId = $currentEditId; if (!empty($result['ok']) && isset($result['id'])) { $editId = (int) $result['id']; } $url = $this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : '')); if (!headers_sent()) { header('Location: ' . $url, true, 302); exit; } } /** * @param mixed $request * @param mixed $default * @return mixed */ private function reqParam($request, string $key, $default = null) { if (is_object($request) && method_exists($request, 'get_param')) { $value = $request->get_param($key); return $value === null ? $default : $value; } if (is_array($request) && array_key_exists($key, $request)) { return $request[$key]; } return $default; } }