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 '
';
}
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 '
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 '| ' . htmlspecialchars($col) . ' | ';
}
echo '
';
foreach ($rows as $r) {
if (!is_array($r)) {
continue;
}
echo '';
foreach ($columns as $col) {
echo '| ' . htmlspecialchars((string) ($r[$col] ?? '')) . ' | ';
}
echo '
';
}
echo '
';
}
echo '
';
}
echo '
Existing Data Sources
';
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;
}
}