feca-mailshots-plugin/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php

849 lines
56 KiB
PHP

<?php
declare(strict_types=1);
namespace FecaMailshots\Admin;
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;
private WordPressFacade $wp;
/** @param callable(): \FecaMailshots\Application\DataSourceService $serviceFactory */
public function __construct(callable $serviceFactory, WordPressFacade $wp)
{
$this->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 '<div class="wrap"><h1>FECA Mailshots</h1><p><a href="' . htmlspecialchars($target) . '">Open Mailshots</a></p></div>';
}
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;
if ($editId > 0) {
try {
$editItem = $this->service()->get($editId);
} catch (\Throwable $e) {
$editItem = null;
}
}
$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);
$builtInSources = $this->service()->knownSources();
echo '<div class="wrap feca-mailshots-admin">';
echo $this->renderAdminUiStyles();
echo '<h1>Mailshot Data Sources</h1>';
if ($result !== null) {
$ok = !empty($result['ok']);
$title = $ok ? 'Action succeeded.' : 'Action failed.';
$bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div id="ds-result-banner" class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
}
if (!empty($result['warnings']) && is_array($result['warnings'])) {
echo '<p class="feca-banner-note">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>';
}
if (isset($result['count'])) {
echo '<p class="feca-banner-note">Preview count: ' . (int) $result['count'] . '</p>';
}
echo '</div>';
echo '<script>(function(){var n=document.getElementById("ds-result-banner");if(!n){return;}window.setTimeout(function(){if(n&&n.parentNode){n.parentNode.removeChild(n);}},30000);})();</script>';
}
echo '<div class="feca-panel"><strong>Total data sources:</strong> ' . count($items) . '</div>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" class="feca-form">';
echo '<input type="hidden" name="page" value="feca-mailshot-data-sources">';
echo '<div class="feca-control-row">';
echo '<div class="feca-control feca-control-min-360">';
echo '<label for="ds_filter_q"><strong>Filter</strong></label>';
echo '<input id="ds_filter_q" class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence">';
echo '</div>';
echo '<div class="feca-control feca-control-min-260">';
echo '<label for="ds_filter_sort"><strong>Sort</strong></label>';
echo '<select id="ds_filter_sort" name="sort">';
foreach (['name' => 'Name', 'updated_desc' => 'Updated (newest)', 'updated_asc' => 'Updated (oldest)'] as $value => $label) {
$selected = $sort === $value ? ' selected' : '';
echo '<option value="' . htmlspecialchars($value, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($label) . '</option>';
}
echo '</select>';
echo '</div>';
echo '<div class="feca-control"><button class="button" type="submit">Apply</button></div>';
echo '</div>';
echo '</form>';
echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
echo '<div id="ds-editor-modal" class="feca-modal-overlay">';
echo '<div class="feca-modal-shell">';
echo '<form method="post" action="' . $action . '" class="feca-form" id="ds-editor-form">';
echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
echo $this->modalErrorHtml($result);
echo $this->hiddenNonceField(self::NONCE_ACTION);
if ($editId > 0) {
echo '<input type="hidden" name="id" value="' . $editId . '" id="ds-editor-id">';
} else {
echo '<input type="hidden" name="id" value="" id="ds-editor-id">';
}
echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="ds_name">Name</label></th><td><input class="regular-text" type="text" id="ds_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '" data-initial="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ds_dsl">DSL Sentence</label></th><td><textarea id="ds_dsl" name="dsl_text" rows="7" class="large-text code" data-initial="' . htmlspecialchars($dsl, ENT_QUOTES) . '">' . htmlspecialchars($dsl) . '</textarea></td></tr>';
echo '</table>';
echo '<p class="feca-button-row">';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">Save</button> ';
echo '<button type="button" class="button" id="ds-close-editor">Quit</button> ';
echo '<button type="button" class="button" id="ds-validate-btn">Validate Sentence</button> ';
echo '<button type="button" class="button" id="ds-preview-btn">Preview Recipients</button> ';
echo '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> ';
echo '</p>';
echo '<div id="ds-inline-result" class="feca-banner feca-banner-muted feca-hidden"></div>';
echo '<div id="ds-inline-preview" class="feca-banner feca-hidden"></div>';
echo '</form>';
echo '</div></div>';
echo '<div id="ds-builder-modal" class="feca-modal-overlay-high">';
echo '<div class="feca-builder-shell">';
echo '<h2 class="feca-modal-title">Build DSL</h2>';
echo '<p class="feca-banner-note">Construct a DSL sentence from sources and constraints.</p>';
echo '<div class="feca-builder-grid">';
echo '<div class="feca-builder-col-left">';
echo '<h3 class="feca-modal-title">Data Sources</h3>';
foreach ($builtInSources as $builtInSource) {
$escapedSource = htmlspecialchars((string) $builtInSource, ENT_QUOTES);
echo '<label><input type="checkbox" class="ds-source-built" value="' . $escapedSource . '"> ' . $escapedSource . '</label><br>';
}
echo '<br>';
echo '<strong>Add custom source</strong><br>';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
echo '</select></label> ';
echo '<label>Table <select id="ds-builder-table"><option value="">Select table</option></select></label> ';
echo '<button type="button" class="button button-small" id="ds-builder-add-custom">Add</button>';
echo '<ul id="ds-builder-custom-list"></ul>';
echo '</div>';
echo '<div class="feca-builder-col-right">';
echo '<h3 class="feca-modal-title">Constraints / Filters</h3>';
echo '<div id="ds-builder-rows"></div>';
echo '<p class="feca-button-row"><button type="button" class="button button-small" id="ds-builder-add-row">Add Constraint Row</button></p>';
echo '</div></div>';
echo '<div class="feca-builder-output">';
echo '<strong>Generated DSL</strong>';
echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>';
echo '<p id="ds-builder-error" class="feca-error-text"></p>';
echo '<p id="ds-builder-table-error" class="feca-error-text"></p>';
echo '</div>';
echo '<p class="feca-button-row">';
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Save</button> ';
echo '<button type="button" class="button" id="ds-builder-cancel">Quit</button>';
echo '</p></div></div>';
if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) {
$rows = $result['rows'];
echo '<div class="feca-panel">';
echo '<h2 class="feca-modal-title">Preview (first ' . count($rows) . ' rows)</h2>';
if ($rows === []) {
echo '<p>No rows returned.</p>';
} 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 '<table class="widefat striped"><thead><tr>';
foreach ($columns as $col) {
echo '<th>' . htmlspecialchars($col) . '</th>';
}
echo '</tr></thead><tbody>';
foreach ($rows as $r) {
if (!is_array($r)) {
continue;
}
echo '<tr>';
foreach ($columns as $col) {
echo '<td>' . htmlspecialchars((string) ($r[$col] ?? '')) . '</td>';
}
echo '</tr>';
}
echo '</tbody></table>';
}
echo '</div>';
}
echo '<h2>Existing Data Sources</h2>';
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>DSL Sentence</th><th>Updated At</th><th>Actions</th></tr></thead><tbody>';
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 '<tr>';
echo '<td>' . $id . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['name'] ?? '')) . '</td>';
echo '<td class="feca-truncate-420"><code>' . htmlspecialchars((string) ($row['dsl_text'] ?? '')) . '</code></td>';
echo '<td>' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_duplicate">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small">Duplicate</button>';
echo '</form> ';
echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small feca-button-danger" onclick="return confirm(\'Delete this data source?\');">Delete</button>';
echo '</form></td>';
echo '</tr>';
}
if ($items === []) {
echo '<tr><td colspan="5">No data sources found.</td></tr>';
}
echo '</tbody></table>';
echo '</div></div>';
echo '<script>';
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
'sourceFields' => [],
'restBase' => '/wp-json/mailshots/v1/data-sources',
'adminPostApi' => $this->wp->adminUrl('admin-post.php?action=feca_mailshots_data_sources_api'),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
echo '(function(){';
echo 'var cfg=window.fecaDataSourcesBuilderConfig||{};';
echo 'var dslInput=document.getElementById("ds_dsl");';
echo 'var modal=document.getElementById("ds-builder-modal");';
echo 'var openBtn=document.getElementById("ds-build-dsl-open");';
echo 'var cancelBtn=document.getElementById("ds-builder-cancel");';
echo 'var applyBtn=document.getElementById("ds-builder-apply");';
echo 'var addRowBtn=document.getElementById("ds-builder-add-row");';
echo 'var rowsWrap=document.getElementById("ds-builder-rows");';
echo 'var out=document.getElementById("ds-builder-output");';
echo 'var err=document.getElementById("ds-builder-error");';
echo 'var tableErr=document.getElementById("ds-builder-table-error");';
echo 'var editorModal=document.getElementById("ds-editor-modal");';
echo 'var editorForm=document.getElementById("ds-editor-form");';
echo 'var validateBtn=document.getElementById("ds-validate-btn");';
echo 'var previewBtn=document.getElementById("ds-preview-btn");';
echo 'var inlineResult=document.getElementById("ds-inline-result");';
echo 'var inlinePreview=document.getElementById("ds-inline-preview");';
echo 'var openNew=document.getElementById("ds-open-new");';
echo 'var closeEditor=document.getElementById("ds-close-editor");';
echo 'var editorId=document.getElementById("ds-editor-id");';
echo 'var editorName=document.getElementById("ds_name");';
echo 'var nonceField=editorForm?editorForm.querySelector("input[name=\"_wpnonce\"]"):null;';
echo 'var builtChecks=[].slice.call(document.querySelectorAll(".ds-source-built"));';
echo 'var customList=document.getElementById("ds-builder-custom-list");';
echo 'var schemaSel=document.getElementById("ds-builder-schema");';
echo 'var tableSel=document.getElementById("ds-builder-table");';
echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");';
echo 'var customSources=[];';
echo 'var sourceOrder=[];';
echo 'var constraints=[];';
echo 'var sourceFieldRequests={};';
echo 'var schemasLoaded=false;';
echo 'var schemasLoading=false;';
echo 'var isDirty=false;';
echo 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}';
echo 'if(editorForm){editorForm.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});editorForm.addEventListener("submit",function(){isDirty=false;});}';
echo 'var filters=[["selected-renewal","Renewal is selected","none"],["pending-renewal","Renewal is pending","none"],["primary-contact","Contact is primary","none"],["fen1-contact","Contact is FEN1","none"],["member-account","Account is member","none"],["affiliate-account","Account is affiliate","none"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council","none"],["account-has-article-in-issue","Account has article in issue","issue"],["selected","Advertiser is selected","none"],["issue","Issue is","issue"],["pending-invoice","Invoice is pending","none"],["selected-invoice","Invoice is selected","none"],["invoice-ids","Invoice ID is one of","ids"]];';
echo 'function selectedSources(){var selected={};builtChecks.forEach(function(c){if(c.checked){selected[c.value]=true;}});customSources.forEach(function(v){selected[v]=true;});var s=[];sourceOrder.forEach(function(src){if(selected[src]&&s.indexOf(src)===-1){s.push(src);}});builtChecks.forEach(function(c){if(c.checked&&s.indexOf(c.value)===-1){s.push(c.value);}});customSources.forEach(function(v){if(s.indexOf(v)===-1){s.push(v);}});return s;}';
echo 'function noteSourceSelected(src){if(src&&sourceOrder.indexOf(src)===-1){sourceOrder.push(src);}}';
echo 'function noteSourceDeselected(src){sourceOrder=sourceOrder.filter(function(v){return v!==src;});}';
echo 'function updateCustomList(){customList.innerHTML="";customSources.forEach(function(src,i){var li=document.createElement("li");li.textContent=src+" ";var b=document.createElement("button");b.type="button";b.className="button-link-delete";b.textContent="Remove";b.onclick=function(){noteSourceDeselected(src);customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}';
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";tableSel.style.color="#1d2327";if(tableErr){tableErr.textContent="";}var pickLabel=function(t){if(typeof t==="string"){return t.trim();}if(t===null||t===undefined){return "";}if(typeof t==="number"){return String(t);}if(typeof t==="object"){var direct=[t.table_name,t.name,t.table,t.label];for(var i=0;i<direct.length;i++){var dv=String(direct[i]||"").trim();if(dv){return dv;}}var keys=Object.keys(t||{});for(var k=0;k<keys.length;k++){var key=String(keys[k]||"").trim();if(/^Tables_in_/i.test(key)){var vv=String(t[key]||"").trim();if(vv){return vv;}}}var vals=Object.values(t||{});for(var j=0;j<vals.length;j++){var v=String(vals[j]||"").trim();if(v){return v;}}}return "";};var fill=function(items){tableSel.innerHTML="<option value=\"\">Select table</option>";var invalid=0;(items||[]).forEach(function(t){var label=pickLabel(t);if(!label){invalid++;return;}var o=document.createElement("option");o.value=label;o.textContent=label;tableSel.appendChild(o);});if((items||[]).length===0&&tableErr){tableErr.textContent="No tables returned for this schema. Check DB grants: SELECT and SHOW VIEW on schema tables.";}if(invalid>0&&tableErr){tableErr.textContent="Received "+invalid+" table entries without names; showing only valid table names.";}};var showErr=function(msg){tableSel.innerHTML="<option value=\"\">Select table</option>";if(tableErr){tableErr.textContent=msg||"Table lookup failed. Resolve the error before continuing.";}if(err){err.textContent="Table lookup failed: "+(msg||"unknown error");}};var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema);fetch(u,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}fill((j&&j.items)||[]);}).catch(function(e){showErr(e&&e.message?e.message:"Need MySQL grants on the selected schema.");});}';
$lazyMetadataJs = <<<'JS'
function loadSchemas(){
if(schemasLoaded||schemasLoading||!schemaSel){return;}
schemasLoading=true;
var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=schemas";
fetch(u,{credentials:"same-origin"}).then(function(r){
if(!r.ok){throw new Error("API "+r.status);}
return r.json();
}).then(function(j){
if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}
schemaSel.innerHTML="<option value=\"\">Select schema</option>";
(j.items||[]).forEach(function(schema){
var value=String(schema||"").trim();
if(!value){return;}
var option=document.createElement("option");
option.value=value;
option.textContent=value;
schemaSel.appendChild(option);
});
schemasLoaded=true;
schemasLoading=false;
}).catch(function(e){
schemasLoading=false;
if(err){err.textContent="Schema lookup failed: "+(e&&e.message?e.message:"unknown error");}
});
}
function sourceFields(source){
var map=cfg.sourceFields||{};
if(Object.prototype.hasOwnProperty.call(map,source)){return map[source];}
if(!source||sourceFieldRequests[source]){return [];}
sourceFieldRequests[source]=true;
var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source);
fetch(u,{credentials:"same-origin"}).then(function(r){
if(!r.ok){throw new Error("API "+r.status);}
return r.json();
}).then(function(j){
delete sourceFieldRequests[source];
if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}
var fields=(j&&j.fields)||[];
map[source]=fields;
cfg.sourceFields=map;
if(fields.length){renderRows();updateDsl();return;}
if(err){err.textContent="Source field lookup returned no fields for "+source+".";}
}).catch(function(e){
delete sourceFieldRequests[source];
if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}
});
return [];
}
JS;
echo $lazyMetadataJs;
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
echo 'function filterArgType(name){for(var i=0;i<filters.length;i++){if(filters[i][0]===name){return filters[i][2]||"none";}}return "none";}';
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.className="feca-builder-row";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.className="feca-builder-filter-select";f.onchange=function(){row.filter=f.value;renderRows();updateDsl();};box.appendChild(f);var argType=filterArgType(row.filter);if(argType!=="none"){var arg=document.createElement("input");arg.type="text";arg.value=row.filterArg||"";arg.placeholder=argType==="ids"?"IDs, comma separated":"Issue ID";arg.oninput=function(){row.filterArg=arg.value;updateDsl();};box.appendChild(arg);}}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"],["blank","is blank"]],row.op);op.onchange=function(){row.op=op.value;renderRows();updateDsl();};box.appendChild(op);if(row.op==="blank"){rowsWrap.appendChild(box);return;}var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
echo 'function isBareIdent(v){var s=String(v||"");if(!s){return false;}var first=s.charAt(0);if(!((first>="A"&&first<="Z")||(first>="a"&&first<="z")||first==="_")){return false;}for(var i=1;i<s.length;i++){var ch=s.charAt(i);if(!((ch>="A"&&ch<="Z")||(ch>="a"&&ch<="z")||(ch>="0"&&ch<="9")||ch==="_"||ch==="-")){return false;}}return true;}';
echo 'function ident(v){var s=String(v||"");return isBareIdent(s)?s:("`"+s.split("`").join("``")+"`");}';
echo 'function unquoteIdent(v){var s=String(v||"");if(s.length>=2&&s.charAt(0)==="`"&&s.charAt(s.length-1)==="`"){return s.substring(1,s.length-1).split("``").join("`");}return s;}';
echo 'function fieldRef(source,field){return ident(source)+"."+ident(field);}';
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}var argType=filterArgType(r.filter);if(argType==="none"){t=r.filter;}else{var raw=String(r.filterArg||"").trim();if(!raw){continue;}if(argType==="ids"){var ids=raw.split(",").map(function(v){return v.trim();}).filter(function(v){return /^\\d+$/.test(v);});if(ids.length===0){continue;}t=r.filter+"("+ids.join(", ")+")";}else{if(!/^\\d+$/.test(raw)){continue;}t=r.filter+"("+raw+")";}}}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=fieldRef(r.lhsSource,r.lhsField);if(r.op==="blank"){t=lhs+" blank";}else if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=fieldRef(r.rhsSource,r.rhsField);if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}';
echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];sourceOrder=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
echo 'function splitAndTerms(text){var out=[];var cur="";var depth=0;var inQuote=false;for(var i=0;i<text.length;i++){var ch=text.charAt(i);if(ch==="\'"){inQuote=!inQuote;cur+=ch;continue;}if(!inQuote){if(ch==="("){depth++;cur+=ch;continue;}if(ch===")"){if(depth>0){depth--;}cur+=ch;continue;}if(depth===0&&text.substring(i,i+5)===" and "){if(cur.trim()){out.push(cur.trim());}cur="";i+=4;continue;}}cur+=ch;}if(cur.trim()){out.push(cur.trim());}return out;}';
echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:unquoteIdent(s.substring(0,idx)),field:unquoteIdent(s.substring(idx+1))};}';
echo 'function parseLiteralValue(text){var v=String(text||"").trim();if(!v){return null;}if(v.length>=2&&v.charAt(0)==="\'"&&v.charAt(v.length-1)==="\'"){return v.substring(1,v.length-1);}if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return null;}';
echo 'function parseCompareTerm(term){var rawTerm=String(term||"").trim();var bm=/^(.+?)\\s+blank$/i.exec(rawTerm);if(bm){var blankRef=splitFieldRef(bm[1]);if(!blankRef){return null;}return {kind:"compare",negate:false,filter:"selected-renewal",lhsSource:blankRef.source,lhsField:blankRef.field,op:"blank",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};}var m=/^(.+?)\\s+(=|!=|contains|starts-with|ends-with|in)\\s+(.+)$/.exec(rawTerm);if(!m){return null;}var lhsRef=splitFieldRef(m[1]);if(!lhsRef){return null;}var op=String(m[2]||"");var rhsRaw=String(m[3]||"").trim();if(!rhsRaw){return null;}var row={kind:"compare",negate:false,filter:"selected-renewal",lhsSource:lhsRef.source,lhsField:lhsRef.field,op:op,rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};if(op==="in"){if(rhsRaw.charAt(0)!=="("||rhsRaw.charAt(rhsRaw.length-1)!==")"){return null;}rhsRaw=rhsRaw.substring(1,rhsRaw.length-1).trim();if(!rhsRaw){return null;}}var rhsRef=splitFieldRef(rhsRaw);if(rhsRef){row.rhsMode="field";row.rhsSource=rhsRef.source;row.rhsField=rhsRef.field;return row;}var literal=parseLiteralValue(rhsRaw);if(literal===null){return null;}row.rhsLiteral=literal;return row;}';
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var lower=text.toLowerCase();var whereIndex=lower.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var wherePart=whereIndex>=0?text.substring(whereIndex+7).trim():"";var bits=srcPart.split(" and ");sourceOrder=[];bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}noteSourceSelected(src);var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});constraints=[];if(wherePart){var known={};filters.forEach(function(f){known[f[0]]=true;});splitAndTerms(wherePart).forEach(function(term){var t=(term||"").trim();if(!t){return;}var negate=false;var lt=t.toLowerCase();if(lt.indexOf("not (")===0&&t.charAt(t.length-1)===")"){negate=true;t=t.substring(5,t.length-1).trim();}else if(lt.indexOf("not ")===0){negate=true;t=t.substring(4).trim();}if(known[t]){constraints.push({kind:"filter",negate:negate,filter:t,filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var fm=/^([a-z0-9_-]+)\\(([^)]*)\\)$/i.exec(t);if(fm&&known[fm[1]]){constraints.push({kind:"filter",negate:negate,filter:fm[1],filterArg:fm[2],lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var parsed=parseCompareTerm(t);if(parsed){parsed.negate=negate;constraints.push(parsed);}});}updateCustomList();renderRows();updateDsl();}';
echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};if(schemaSel.value){fetchTables(schemaSel.value);}}';
echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}noteSourceSelected(src);updateCustomList();renderRows();updateDsl();};}';
echo 'builtChecks.forEach(function(c){c.onchange=function(){if(c.checked){noteSourceSelected(c.value);}else{noteSourceDeselected(c.value);}renderRows();updateDsl();};});';
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
echo 'if(openBtn){openBtn.onclick=function(){loadSchemas();resetBuilder();prefillFromDsl();modal.style.display="block";};}';
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';
echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}modal.style.display="none";};}';
echo 'if(openNew){openNew.onclick=function(){if(!confirmDiscard()){return;}if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}isDirty=false;editorModal.style.display="block";};}';
echo 'if(closeEditor){closeEditor.onclick=function(){if(!confirmDiscard()){return;}editorModal.style.display="none";};}';
echo 'function esc(v){return String(v||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}';
echo 'function showInlineResult(ok,title,lines){if(!inlineResult){return;}inlineResult.style.display="block";inlineResult.style.borderColor=ok?"#8bc34a":"#ef9a9a";inlineResult.style.background=ok?"#f1f8e9":"#ffebee";var h="<strong>"+esc(title)+"</strong>";if(lines&&lines.length){h+="<ul style=\"margin:8px 0 0 18px;\">"+lines.map(function(x){return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";}inlineResult.innerHTML=h;}';
echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);if(nonceField&&nonceField.value){payload.set("_wpnonce",String(nonceField.value));}fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}';
echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");if(nonceField&&nonceField.value){payload.set("_wpnonce",String(nonceField.value));}fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}';
echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
echo 'var hasDraft=' . ($useDraft ? 'true' : 'false') . ';';
echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';';
echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';';
echo 'if((hasEdit||hasDraft)&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
echo '})();';
echo '</script>';
echo '</div>';
}
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<id>\d+)', [
'methods' => 'GET',
'callback' => [$this, 'restGetOne'],
'permission_callback' => [$this, 'restCanRead'],
]);
register_rest_route('mailshots/v1', '/data-sources/(?P<id>\d+)', [
'methods' => 'PUT',
'callback' => [$this, 'restUpdate'],
'permission_callback' => [$this, 'restCanManage'],
]);
register_rest_route('mailshots/v1', '/data-sources/(?P<id>\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<id>\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<string,mixed> */
public function restList()
{
return ['ok' => true, 'items' => $this->service()->list()];
}
/** @return array<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
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<string,mixed> */
public function restValidate($request)
{
$dsl = (string) $this->reqParam($request, 'dsl_text', '');
return ['ok' => true] + $this->service()->validateDsl($dsl);
}
/** @return array<string,mixed> */
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<string,mixed> */
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<string,mixed> */
public function restSchemas()
{
return ['ok' => true, 'items' => $this->service()->listSchemas()];
}
/** @return array<string,mixed> */
public function restTables($request)
{
$schema = (string) $this->reqParam($request, 'schema', '');
return ['ok' => true, 'items' => $this->service()->listTables($schema)];
}
/** @return array<string,mixed> */
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<array<string,mixed>> $items @return list<array<string,mixed>> */
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<string,mixed> $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;
}
}