HTML editor work

This commit is contained in:
Adrian Stephens 2026-04-22 09:06:03 +01:00
parent d7e1995da3
commit 1835e75924
37 changed files with 1316 additions and 139 deletions

3
.gitignore vendored
View File

@ -29,3 +29,6 @@ Thumbs.db
# Local test/coverage output # Local test/coverage output
coverage/ coverage/
.phpunit.result.cache .phpunit.result.cache
node_modules/
test-results/
playwright-report/

View File

@ -3,7 +3,7 @@
* Plugin Name: FECA Mailshots * Plugin Name: FECA Mailshots
* Plugin URI: https://fenedge.co.uk/ * Plugin URI: https://fenedge.co.uk/
* Description: FECA mailshots plugin. * Description: FECA mailshots plugin.
* Version: 0.1.6 * Version: 0.1.26
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: FECA * Author: FECA

View File

@ -74,7 +74,7 @@ final class AttachmentsAdminPage
echo '<div id="att-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="att-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:900px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div style="max-width:900px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>'; echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="att-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>'; echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">'; echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">';
if ($editId > 0) { if ($editId > 0) {
@ -119,7 +119,7 @@ final class AttachmentsAdminPage
echo '<tr><td colspan="6">No attachments found.</td></tr>'; echo '<tr><td colspan="6">No attachments found.</td></tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '<script>(function(){var modal=document.getElementById("att-editor-modal");var openBtn=document.getElementById("att-open-new");var closeBtn=document.getElementById("att-close-editor");if(openBtn&&modal){openBtn.addEventListener("click",function(){var id=document.getElementById("att-editor-id");if(id){id.value="";}["att_name","att_file_name","att_mime","att_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}});modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';if(hasEdit&&modal){modal.style.display="block";}})();</script>'; echo '<script>(function(){var modal=document.getElementById("att-editor-modal");var openBtn=document.getElementById("att-open-new");var closeBtn=document.getElementById("att-close-editor");var form=document.getElementById("att-editor-form");var isDirty=false;function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}if(openBtn&&modal){openBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}var id=document.getElementById("att-editor-id");if(id){id.value="";}["att_name","att_file_name","att_mime","att_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}isDirty=false;modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';var hasResult=' . ($result !== null ? 'true' : 'false') . ';var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';if(hasEdit&&modal&&(!hasResult||!resultOk)){modal.style.display="block";isDirty=false;}})();</script>';
echo '</div>'; echo '</div>';
} }
@ -232,6 +232,7 @@ final class AttachmentsAdminPage
private function result(): ?array private function result(): ?array
{ {
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null; return is_array($raw) ? $raw : null;
} }
} }

View File

@ -94,7 +94,7 @@ final class DataSourcesAdminPage
$bg = $ok ? '#f1f8e9' : '#ffebee'; $bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a'; $border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Action succeeded.' : 'Action failed.'; $title = $ok ? 'Action succeeded.' : 'Action failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">'; echo '<div id="ds-result-banner" style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . htmlspecialchars($title) . '</strong>'; echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
@ -106,6 +106,7 @@ final class DataSourcesAdminPage
echo '<p style="margin:6px 0 0 0;">Preview count: ' . (int) $result['count'] . '</p>'; echo '<p style="margin:6px 0 0 0;">Preview count: ' . (int) $result['count'] . '</p>';
} }
echo '</div>'; 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 style="display:flex;gap:12px;margin:12px 0;">'; echo '<div style="display:flex;gap:12px;margin:12px 0;">';
@ -128,7 +129,6 @@ final class DataSourcesAdminPage
echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>'; echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
echo '<div id="ds-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="ds-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="ds-close-editor">Close</button></p>';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">'; echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>'; echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
if ($editId > 0) { if ($editId > 0) {
@ -141,14 +141,18 @@ final class DataSourcesAdminPage
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 '<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 '</table>';
echo '<p>'; echo '<p>';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">' . ($editId > 0 ? 'Save' : 'Create') . '</button> '; echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">Save</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_data_sources_ui_validate">Validate Sentence</button> '; echo '<button type="button" class="button" id="ds-close-editor">Quit</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_data_sources_ui_preview">Preview Recipients</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 '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> ';
if ($editId > 0) { if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort))) . '">Discard Changes</a>'; echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort))) . '">Discard Changes</a>';
} }
echo '</p></form>'; echo '</p>';
echo '<div id="ds-inline-result" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#f9f9f9;"></div>';
echo '<div id="ds-inline-preview" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#fff;"></div>';
echo '</form>';
echo '</div></div>'; echo '</div></div>';
echo '<div id="ds-builder-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;">'; echo '<div id="ds-builder-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;">';
@ -161,10 +165,13 @@ final class DataSourcesAdminPage
echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br><br>';
echo '<strong>Add custom source</strong><br>'; echo '<strong>Add custom source</strong><br>';
$defaultSchema = in_array('fenedgec_members', $schemaList, true) ? 'fenedgec_members' : '';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>'; echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
foreach ($schemaList as $schema) { foreach ($schemaList as $schema) {
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '">' . htmlspecialchars((string) $schema) . '</option>'; $selected = ((string) $schema === $defaultSchema) ? ' selected' : '';
echo '<option value="' . htmlspecialchars((string) $schema, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars((string) $schema) . '</option>';
} }
echo '</select></label> '; echo '</select></label> ';
echo '<label>Table <select id="ds-builder-table"><option value="">Select table</option></select></label> '; echo '<label>Table <select id="ds-builder-table"><option value="">Select table</option></select></label> ';
@ -180,6 +187,7 @@ final class DataSourcesAdminPage
echo '<strong>Generated DSL</strong>'; echo '<strong>Generated DSL</strong>';
echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>'; echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>';
echo '<p id="ds-builder-error" style="color:#a00;margin:6px 0 0 0;"></p>'; echo '<p id="ds-builder-error" style="color:#a00;margin:6px 0 0 0;"></p>';
echo '<p id="ds-builder-table-error" style="color:#a00;margin:6px 0 0 0;"></p>';
echo '</div>'; echo '</div>';
echo '<p style="margin-top:12px;">'; echo '<p style="margin-top:12px;">';
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Apply</button> '; echo '<button type="button" class="button button-primary" id="ds-builder-apply">Apply</button> ';
@ -250,6 +258,7 @@ final class DataSourcesAdminPage
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([ echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
'sourceFields' => $sourceFieldsMap, 'sourceFields' => $sourceFieldsMap,
'restBase' => '/wp-json/mailshots/v1/data-sources', '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) . ';'; ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
echo '(function(){'; echo '(function(){';
echo 'var cfg=window.fecaDataSourcesBuilderConfig||{};'; echo 'var cfg=window.fecaDataSourcesBuilderConfig||{};';
@ -263,7 +272,13 @@ final class DataSourcesAdminPage
echo 'var rowsWrap=document.getElementById("ds-builder-rows");'; echo 'var rowsWrap=document.getElementById("ds-builder-rows");';
echo 'var out=document.getElementById("ds-builder-output");'; echo 'var out=document.getElementById("ds-builder-output");';
echo 'var err=document.getElementById("ds-builder-error");'; 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 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 openNew=document.getElementById("ds-open-new");';
echo 'var closeEditor=document.getElementById("ds-close-editor");'; echo 'var closeEditor=document.getElementById("ds-close-editor");';
echo 'var editorId=document.getElementById("ds-editor-id");'; echo 'var editorId=document.getElementById("ds-editor-id");';
@ -275,19 +290,26 @@ final class DataSourcesAdminPage
echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");'; echo 'var addCustomBtn=document.getElementById("ds-builder-add-custom");';
echo 'var customSources=[];'; echo 'var customSources=[];';
echo 'var constraints=[];'; echo 'var constraints=[];';
echo 'var filters=[["selected-renewal","Renewal is selected"],["pending-renewal","Renewal is pending"],["fen1-contact","Contact is FEN1"],["fen2-contact","Contact is FEN2"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];'; 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"],["pending-renewal","Renewal is pending"],["primary-contact","Contact is primary"],["fen1-contact","Contact is FEN1"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council"]];';
echo 'function selectedSources(){var s=[];builtChecks.forEach(function(c){if(c.checked){s.push(c.value);}});customSources.forEach(function(v){s.push(v);});return s;}'; echo 'function selectedSources(){var s=[];builtChecks.forEach(function(c){if(c.checked){s.push(c.value);}});customSources.forEach(function(v){s.push(v);});return s;}';
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(){customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}'; 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(){customSources.splice(i,1);updateCustomList();renderRows();updateDsl();};li.appendChild(b);customList.appendChild(li);});}';
echo 'function fetchTables(schema){tableSel.innerHTML="<option value=\"\">Loading...</option>";fetch(cfg.restBase+"/tables?schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(j){var items=(j&&j.items)||[];tableSel.innerHTML="<option value=\"\">Select table</option>";items.forEach(function(t){var o=document.createElement("option");o.value=t;o.textContent=t;tableSel.appendChild(o);});}).catch(function(){tableSel.innerHTML="<option value=\"\">Select table</option>";});}'; 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||"Unable to list tables for this schema.";} };fetch(cfg.restBase+"/tables?schema="+encodeURIComponent(schema),{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("REST "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"REST error");}fill((j&&j.items)||[]);}).catch(function(){fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=tables&schema="+encodeURIComponent(schema),{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.");});});}';
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}return [];}'; echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(j){var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();}}).catch(function(){});}return [];}';
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 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 addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}'; echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",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.style.border="1px solid #dcdcde";box.style.padding="8px";box.style.marginBottom="8px";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.style.marginLeft="8px";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.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}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"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);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 renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.style.border="1px solid #dcdcde";box.style.padding="8px";box.style.marginBottom="8px";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.style.marginLeft="8px";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.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}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"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);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 quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
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;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=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 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;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=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=[];constraints=[];updateCustomList();renderRows();updateDsl();}'; echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var whereIndex=text.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var bits=srcPart.split(" and ");bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}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);}});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 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};}'; echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:s.substring(0,idx),field: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 m=/^(.+?)\\s+(=|!=|contains|starts-with|ends-with|in)\\s+(.+)$/.exec(String(term||"").trim());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 ");bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}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,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);}updateCustomList();renderRows();updateDsl();};}'; 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);}updateCustomList();renderRows();updateDsl();};}';
echo 'builtChecks.forEach(function(c){c.onchange=updateDsl;});'; echo 'builtChecks.forEach(function(c){c.onchange=updateDsl;});';
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}'; echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
@ -295,10 +317,16 @@ final class DataSourcesAdminPage
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}'; echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';
echo 'if(resetBtn){resetBtn.onclick=function(){resetBuilder();};}'; echo 'if(resetBtn){resetBtn.onclick=function(){resetBuilder();};}';
echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}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(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}editorModal.style.display="block";};}'; 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(){editorModal.style.display="none";};}'; 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);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");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 hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
echo 'if(hasEdit&&editorModal){editorModal.style.display="block";}'; echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';';
echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';';
echo 'if(hasEdit&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
echo '})();'; echo '})();';
echo '</script>'; echo '</script>';
echo '</div>'; echo '</div>';
@ -675,6 +703,7 @@ final class DataSourcesAdminPage
private function result(): ?array private function result(): ?array
{ {
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null; return is_array($raw) ? $raw : null;
} }

View File

@ -227,6 +227,7 @@ final class MailshotTestAdminPage
private function result(): ?array private function result(): ?array
{ {
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null; return is_array($raw) ? $raw : null;
} }

View File

@ -88,6 +88,7 @@ final class MailshotsAdminPage
'Message' => (string) ($editItem['Message'] ?? ''), 'Message' => (string) ($editItem['Message'] ?? ''),
'PDFAttachment' => (string) ($editItem['PDFAttachment'] ?? ''), 'PDFAttachment' => (string) ($editItem['PDFAttachment'] ?? ''),
'PDFFilenameDerivedFrom' => (string) ($editItem['PDFFilenameDerivedFrom'] ?? ''), 'PDFFilenameDerivedFrom' => (string) ($editItem['PDFFilenameDerivedFrom'] ?? ''),
'RecipientEmailField' => (string) ($editItem['RecipientEmailField'] ?? ''),
'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''), 'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''),
]; ];
@ -110,7 +111,6 @@ final class MailshotsAdminPage
echo '<p><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>'; echo '<p><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>';
echo '<div id="ms-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="ms-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="ms-close-editor">Close</button></p>';
echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>'; echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">'; echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
@ -121,48 +121,34 @@ final class MailshotsAdminPage
} }
echo '<table class="form-table" role="presentation">'; echo '<table class="form-table" role="presentation">';
echo '<tr><th scope="row"><label for="ms_purpose">Purpose</label></th><td><input class="regular-text" type="text" id="ms_purpose" name="Purpose" value="' . htmlspecialchars($form['Purpose'], ENT_QUOTES) . '"></td></tr>'; echo '<tr><th scope="row"><label for="ms_purpose">Purpose</label></th><td><input class="regular-text" type="text" id="ms_purpose" name="Purpose" value="' . htmlspecialchars($form['Purpose'], ENT_QUOTES) . '"></td>';
echo '<tr><th scope="row"><label for="ms_ds">Data Source</label></th><td><select id="ms_ds" name="DataSource"><option value="">Select data source</option>'; echo '<th scope="row"><label for="ms_ds">Data Source</label></th><td><select id="ms_ds" name="DataSource"><option value="">Select data source</option>';
foreach ($dataSources as $ds) { foreach ($dataSources as $ds) {
$sel = $ds === $form['DataSource'] ? ' selected' : ''; $sel = $ds === $form['DataSource'] ? ' selected' : '';
echo '<option value="' . htmlspecialchars($ds, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($ds) . '</option>'; echo '<option value="' . htmlspecialchars($ds, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($ds) . '</option>';
} }
echo '</select> '; echo '</select></td></tr>';
if ($form['DataSource'] !== '') {
echo '<a class="button button-small" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $editId)) . '">Refresh Source Fields</a>';
}
echo '</td></tr>';
echo '<tr><th scope="row">Available Tokens</th><td>'; echo '<tr><th scope="row">Available Tokens</th><td colspan="3"><div id="ms-token-controls"></div><p class="description" id="ms-token-help">Select a data source to view tokens.</p></td></tr>';
if (($tokenData['errors'] ?? []) !== []) {
echo '<p class="description" style="color:#a00;">' . htmlspecialchars(implode('; ', (array) $tokenData['errors'])) . '</p>';
} elseif (($tokenData['tokens'] ?? []) === []) {
echo '<p class="description">Select a data source to view tokens.</p>';
} else {
foreach ((array) $tokenData['tokens'] as $token) {
if (!is_array($token)) {
continue;
}
$tokenText = (string) ($token['token'] ?? '');
$fieldText = (string) ($token['field'] ?? '');
echo '<button type="button" class="button button-small ms-token-btn" data-token="' . htmlspecialchars($tokenText, ENT_QUOTES) . '" style="margin-right:6px;margin-bottom:6px;" title="' . htmlspecialchars($fieldText, ENT_QUOTES) . '">' . htmlspecialchars($tokenText) . '</button>';
}
echo '<p class="description">Click a token to insert it at cursor in Subject/Message/PDF fields.</p>';
}
echo '</td></tr>';
echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>'; echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td colspan="3"><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>';
echo '<tr><th scope="row"><label for="ms_message">Message (HTML)</label></th><td><textarea id="ms_message" name="Message" rows="10" class="large-text code">' . htmlspecialchars($form['Message']) . '</textarea></td></tr>'; echo '<tr><th scope="row">Message</th><td><button type="button" class="button" id="ms-edit-message">Edit Message</button> <span class="description" id="ms-message-meta"></span><textarea id="ms_message" name="Message" rows="10" class="large-text code" style="display:none;">' . htmlspecialchars($form['Message']) . '</textarea><div id="ms-message-snippet" class="description" style="margin-top:8px;"></div></td>';
echo '<tr><th scope="row"><label for="ms_pdfa">PDF Attachment Template (HTML)</label></th><td><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code">' . htmlspecialchars($form['PDFAttachment']) . '</textarea></td></tr>'; echo '<th scope="row">PDF Attachment</th><td><button type="button" class="button" id="ms-edit-pdf">Edit PDF Attachment</button> <span class="description" id="ms-pdf-meta"></span><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code" style="display:none;">' . htmlspecialchars($form['PDFAttachment']) . '</textarea><div id="ms-pdf-snippet" class="description" style="margin-top:8px;"></div></td></tr>';
echo '<tr><th scope="row"><label for="ms_pdfname">PDF Filename Derived From</label></th><td><select id="ms_pdfname" name="PDFFilenameDerivedFrom"><option value="">(none)</option>'; echo '<tr><th scope="row"><label for="ms_pdfname">PDF Filename Derived From</label></th><td><select id="ms_pdfname" name="PDFFilenameDerivedFrom"><option value="">(none)</option>';
foreach ($pdfFieldOptions as $f) { foreach ($pdfFieldOptions as $f) {
$sel = $f === $form['PDFFilenameDerivedFrom'] ? ' selected' : ''; $sel = $f === $form['PDFFilenameDerivedFrom'] ? ' selected' : '';
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>'; echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
} }
echo '</select></td></tr>'; echo '</select></td>';
echo '<th scope="row"><label for="ms_email_field">Recipient Email Field</label></th><td><select id="ms_email_field" name="RecipientEmailField"><option value="">Select field</option>';
foreach ($pdfFieldOptions as $f) {
$sel = $f === $form['RecipientEmailField'] ? ' selected' : '';
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
}
echo '</select><p class="description">Explicit field from data source row used as recipient email for run/send.</p></td></tr>';
echo '<tr><th scope="row">Attachments</th><td>'; echo '<tr><th scope="row">Attachments</th><td colspan="3">';
echo '<select id="ms_attachment_pick"><option value="">Select attachment</option>'; echo '<select id="ms_attachment_pick"><option value="">Select attachment</option>';
foreach ($attachmentNames as $name) { foreach ($attachmentNames as $name) {
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . '</option>'; echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . '</option>';
@ -176,17 +162,33 @@ final class MailshotsAdminPage
echo '<input type="hidden" name="AttachmentNamesCsv" id="ms_attachment_csv" value="' . htmlspecialchars(implode(', ', $selectedAttachments), ENT_QUOTES) . '">'; echo '<input type="hidden" name="AttachmentNamesCsv" id="ms_attachment_csv" value="' . htmlspecialchars(implode(', ', $selectedAttachments), ENT_QUOTES) . '">';
echo '</td></tr>'; echo '</td></tr>';
echo '<tr><th scope="row"><label for="ms_cc">CC</label></th><td><input class="regular-text" type="text" id="ms_cc" name="CC" value="' . htmlspecialchars($form['CC'], ENT_QUOTES) . '"></td></tr>'; echo '<tr><th scope="row"><label for="ms_cc">CC</label></th><td><input class="regular-text" type="text" id="ms_cc" name="CC" value="' . htmlspecialchars($form['CC'], ENT_QUOTES) . '"></td>';
echo '<tr><th scope="row"><label for="ms_bcc">BCC</label></th><td><input class="regular-text" type="text" id="ms_bcc" name="BCC" value="' . htmlspecialchars($form['BCC'], ENT_QUOTES) . '"></td></tr>'; echo '<th scope="row"><label for="ms_bcc">BCC</label></th><td><input class="regular-text" type="text" id="ms_bcc" name="BCC" value="' . htmlspecialchars($form['BCC'], ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>'; echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td colspan="3"><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>';
echo '</table>'; echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Mailshot' : 'Create Mailshot') . '</button> '; echo '<p><button type="submit" class="button button-primary">Save</button> ';
echo '<button type="button" class="button" id="ms-close-editor">Quit</button> ';
if ($editId > 0) { if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">Discard Changes</a> '; echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">Discard Changes</a> ';
} }
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">New</a></p>'; echo '</p>';
echo '</form>'; echo '</form>';
echo '<div id="ms-template-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<h3 id="ms-template-title" style="margin-top:0;">Template Editor</h3>';
echo '<p><button type="button" class="button button-small" id="ms-template-mode-visual">Visual</button> <button type="button" class="button button-small" id="ms-template-mode-code">Code</button></p>';
echo '<div id="ms-template-token-controls" style="margin-bottom:10px;"></div>';
echo '<div id="ms-template-pdf-assets" style="display:none;margin-bottom:10px;"><label>PDF Asset <select id="ms_pdf_asset_pick"><option value="">Select PDF asset</option></select></label> <button type="button" class="button button-small" id="ms_pdf_asset_insert">Insert Asset Token</button><p class="description">Inserts Twig helper token, example: {{ pdf_asset(\'logo_asset\') }}.</p></div>';
if (function_exists('wp_enqueue_editor')) {
wp_enqueue_editor();
}
echo '<textarea id="ms-template-html" rows="14" class="large-text code"></textarea>';
echo '<textarea id="ms-template-code" rows="14" class="large-text code" style="display:none;font-family:monospace;"></textarea>';
echo '<p class="description" style="margin-top:6px;">Use HTML for rich editing and Plain Text to view/edit exact HTML source (same underlying content).</p>';
echo '<p><button type="button" class="button button-primary" id="ms-template-save">Save</button> <button type="button" class="button" id="ms-template-close">Quit</button></p>';
echo '</div></div>';
echo '</div></div>'; echo '</div></div>';
echo '<h2>Existing Mailshots</h2>'; echo '<h2>Existing Mailshots</h2>';
@ -213,53 +215,531 @@ final class MailshotsAdminPage
echo '<script> echo '<script>
(function () { (function () {
var lastTarget = null;
var editorModal = document.getElementById("ms-editor-modal"); var editorModal = document.getElementById("ms-editor-modal");
var openNew = document.getElementById("ms-open-new"); var openNew = document.getElementById("ms-open-new");
var closeEditor = document.getElementById("ms-close-editor"); var closeEditor = document.getElementById("ms-close-editor");
var editor = document.getElementById("mailshot-editor"); var editor = document.getElementById("mailshot-editor");
if (!editor) { return; } if (!editor) { return; }
var adminApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_mailshots_api'), JSON_UNESCAPED_SLASHES) . ';
var pdfApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_pdf_assets_api'), JSON_UNESCAPED_SLASHES) . ';
var currentTokens = ' . json_encode(array_values(array_filter((array) ($tokenData['tokens'] ?? []), static fn($t): bool => is_array($t))), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';
var lastTarget = null;
var isDirty = false;
var markDirty = function () { isDirty = true; };
var confirmDiscard = function () {
if (!isDirty) { return true; }
return window.confirm("You have unsaved changes. Close without saving?");
};
var dsSelect = document.getElementById("ms_ds");
var tokenControls = document.getElementById("ms-token-controls");
var tokenHelp = document.getElementById("ms-token-help");
var pdfNameSelect = document.getElementById("ms_pdfname");
var emailSelect = document.getElementById("ms_email_field");
var subjectField = document.getElementById("ms_subject");
var messageField = document.getElementById("ms_message");
var pdfField = document.getElementById("ms_pdfa");
var templateModal = document.getElementById("ms-template-modal");
var templateTitle = document.getElementById("ms-template-title");
var templateClose = document.getElementById("ms-template-close");
var templateSave = document.getElementById("ms-template-save");
var templateHtml = document.getElementById("ms-template-html");
var templateCode = document.getElementById("ms-template-code");
var templateModeVisualBtn = document.getElementById("ms-template-mode-visual");
var templateModeCodeBtn = document.getElementById("ms-template-mode-code");
var templateTokenControls = document.getElementById("ms-template-token-controls");
var templatePdfAssets = document.getElementById("ms-template-pdf-assets");
var pdfAssetPick = document.getElementById("ms_pdf_asset_pick");
var pdfAssetInsert = document.getElementById("ms_pdf_asset_insert");
var openMessageEditor = document.getElementById("ms-edit-message");
var openPdfEditor = document.getElementById("ms-edit-pdf");
var messageSnippet = document.getElementById("ms-message-snippet");
var messageMeta = document.getElementById("ms-message-meta");
var pdfSnippet = document.getElementById("ms-pdf-snippet");
var pdfMeta = document.getElementById("ms-pdf-meta");
var activeTemplateField = null;
var templateDirty = false;
var templateRichEditor = null;
var templateCodeCanonicalRaw = "";
var templateCodeEdited = false;
var templateMode = "visual";
var cleanTokenLabel = function (value) {
var text = String(value || "");
text = text.replace(/{{\\s*|\\s*}}/g, "");
text = text.replace(/fenedgec_members_/gi, "");
return text.trim();
};
var stripHtml = function (html) {
var s = String(html || "");
s = s.replace(/<style[\\s\\S]*?<\\/style>/gi, " ");
s = s.replace(/<script[\\s\\S]*?<\\/script>/gi, " ");
s = s.replace(/<[^>]+>/g, " ");
s = s.replace(/\\s+/g, " ").trim();
return s;
};
var htmlEditorWrap = function () {
return document.getElementById("wp-ms-template-html-wrap");
};
var getTemplateHtmlValue = function () {
if (templateRichEditor) {
return String(templateRichEditor.getContent({ format: "raw" }) || "");
}
return String((templateHtml && templateHtml.value) || "");
};
var setTemplateHtmlValue = function (value) {
var htmlValue = String(value || "");
if (templateRichEditor) {
templateRichEditor.setContent(htmlValue);
templateRichEditor.save();
}
if (templateHtml) {
templateHtml.value = htmlValue;
}
};
var scrubEditorArtifacts = function (value) {
var htmlValue = String(value || "");
htmlValue = htmlValue.replace(/<span\b[^>]*\bdata-mce-type=(["\'])bookmark\1[^>]*>[\s\S]*?<\/span>/gi, "");
htmlValue = htmlValue.replace(/<span\b[^>]*\bclass=(["\'])mce_SELRES_(?:start|end)\1[^>]*>[\s\S]*?<\/span>/gi, "");
htmlValue = htmlValue.replace(/\uFEFF/g, "");
return htmlValue;
};
var prettyPrintHtml = function (value) {
var src = String(value || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
var tokens = src.split(/(<[^>]+>)/g).filter(function (t) { return t !== ""; });
var out = [];
var indent = 0;
var rawBlock = "";
var pad = function (n) { return " ".repeat(Math.max(0, n)); };
var voidTag = /^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/i;
for (var i = 0; i < tokens.length; i++) {
var token = String(tokens[i] || "");
var trimmed = token.trim();
if (!trimmed) { continue; }
if (rawBlock && trimmed.toLowerCase().indexOf("</" + rawBlock) === 0) {
indent = Math.max(0, indent - 1);
out.push(pad(indent) + trimmed);
rawBlock = "";
continue;
}
if (rawBlock) {
var rawLines = token.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (var rl = 0; rl < rawLines.length; rl++) {
var line = rawLines[rl];
if (line.trim() === "") {
out.push("");
} else {
out.push(pad(indent) + line.trim());
}
}
continue;
}
if (trimmed.indexOf("</") === 0) {
indent = Math.max(0, indent - 1);
}
if (trimmed.charAt(0) === "<") {
out.push(pad(indent) + trimmed);
var openTag = /^<([a-zA-Z0-9:-]+)/.exec(trimmed);
var closeSelf = /\/>\s*$/.test(trimmed);
var isClose = /^<\//.test(trimmed);
var isBang = /^<!/.test(trimmed) || /^<\?/.test(trimmed);
if (!isClose && !isBang && openTag && !closeSelf && !voidTag.test(openTag[1])) {
indent++;
var tagLower = String(openTag[1] || "").toLowerCase();
if (tagLower === "style" || tagLower === "script") {
rawBlock = tagLower;
}
}
} else {
var textLines = token.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
for (var tl = 0; tl < textLines.length; tl++) {
var tline = textLines[tl];
if (tline.trim() === "") {
out.push("");
} else {
out.push(pad(indent) + tline.trim());
}
}
}
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
};
var syncTemplateTextareaFromEditor = function () {
if (templateRichEditor) {
templateRichEditor.save();
}
if (!templateHtml) { return; }
var cleaned = scrubEditorArtifacts(templateHtml.value || "");
if (cleaned !== templateHtml.value) {
templateHtml.value = cleaned;
if (templateRichEditor) {
templateRichEditor.setContent(cleaned);
templateRichEditor.save();
}
}
};
var setTemplateMode = function (mode) {
templateMode = (mode === "code") ? "code" : "visual";
var wrap = htmlEditorWrap();
if (templateMode === "code") {
syncTemplateTextareaFromEditor();
templateCodeCanonicalRaw = scrubEditorArtifacts(String((templateHtml && templateHtml.value) || ""));
templateCodeEdited = false;
if (templateCode) {
templateCode.value = prettyPrintHtml(templateCodeCanonicalRaw);
templateCode.style.display = "block";
}
if (wrap) {
wrap.style.display = "none";
var tabs = wrap.querySelector(".wp-editor-tabs");
if (tabs) { tabs.style.display = "none"; }
} else if (templateHtml) {
templateHtml.style.display = "none";
}
} else {
var codeValue = templateCode ? String(templateCode.value || "") : "";
if (templateCode && templateCodeEdited) {
var cleaned = scrubEditorArtifacts(codeValue);
if (templateRichEditor) {
templateRichEditor.setContent(cleaned);
templateRichEditor.save();
}
if (templateHtml) {
templateHtml.value = cleaned;
}
}
if (templateCode) {
templateCode.style.display = "none";
}
if (wrap) {
wrap.style.display = "block";
var tabs2 = wrap.querySelector(".wp-editor-tabs");
if (tabs2) { tabs2.style.display = "none"; }
} else if (templateHtml) {
templateHtml.style.display = "block";
}
}
};
var tokenFieldOptions = function (tokens) {
var out = [];
(tokens || []).forEach(function (t) {
if (!t || typeof t !== "object") { return; }
var field = String(t.field || "").trim();
if (field && out.indexOf(field) === -1) { out.push(field); }
});
return out;
};
var repopulateFieldSelect = function (sel, values, emptyLabel) {
if (!sel) { return; }
var prev = sel.value || "";
sel.innerHTML = "";
var first = document.createElement("option");
first.value = "";
first.textContent = emptyLabel;
sel.appendChild(first);
values.forEach(function (v) {
var o = document.createElement("option");
o.value = v;
o.textContent = v;
sel.appendChild(o);
});
if (prev && values.indexOf(prev) !== -1) {
sel.value = prev;
} else {
sel.value = "";
}
};
var insertIntoField = function (field, text) {
if (!field || !text) { return; }
var start = field.selectionStart || 0;
var end = field.selectionEnd || 0;
var value = field.value || "";
field.value = value.substring(0, start) + text + value.substring(end);
field.focus();
var next = start + text.length;
field.setSelectionRange(next, next);
markDirty();
};
var insertToken = function (token) {
if (!token) { return; }
if (templateModal && templateModal.style.display === "block") {
if (templateMode === "code" && templateCode) {
insertIntoField(templateCode, token);
templateCodeEdited = true;
} else if (templateRichEditor) {
templateRichEditor.insertContent(token);
templateRichEditor.save();
templateDirty = true;
} else if (templateHtml) {
insertIntoField(templateHtml, token);
}
templateDirty = true;
return;
}
if (!lastTarget) {
lastTarget = subjectField;
}
insertIntoField(lastTarget, token);
};
var renderTokenControlsInto = function (container) {
if (!container) { return; }
container.innerHTML = "";
if (!currentTokens || currentTokens.length === 0) {
return;
}
if (currentTokens.length > 10) {
var sel = document.createElement("select");
sel.innerHTML = "<option value=\\"\\">Select field token</option>";
currentTokens.forEach(function (t) {
var token = String((t && t.token) || "");
var label = cleanTokenLabel(String((t && (t.field || t.token)) || ""));
var o = document.createElement("option");
o.value = token;
o.textContent = label || token;
sel.appendChild(o);
});
var btn = document.createElement("button");
btn.type = "button";
btn.className = "button button-small";
btn.textContent = "Insert";
btn.style.marginLeft = "6px";
btn.addEventListener("click", function () { insertToken(sel.value || ""); });
container.appendChild(sel);
container.appendChild(btn);
return;
}
currentTokens.forEach(function (t) {
var token = String((t && t.token) || "");
var label = cleanTokenLabel(String((t && (t.field || t.token)) || ""));
var b = document.createElement("button");
b.type = "button";
b.className = "button button-small";
b.style.marginRight = "6px";
b.style.marginBottom = "6px";
b.textContent = label || token;
b.title = token;
b.addEventListener("click", function () { insertToken(token); });
container.appendChild(b);
});
};
var refreshTokenUi = function () {
renderTokenControlsInto(tokenControls);
renderTokenControlsInto(templateTokenControls);
var fields = tokenFieldOptions(currentTokens);
repopulateFieldSelect(pdfNameSelect, fields, "(none)");
repopulateFieldSelect(emailSelect, fields, "Select field");
if (tokenHelp) {
tokenHelp.textContent = fields.length ? "Insert tokens into Subject, Message or PDF Attachment editors." : "Select a data source to view tokens.";
}
};
var fetchTokensForDataSource = function (name) {
if (!name) {
currentTokens = [];
refreshTokenUi();
return;
}
fetch(adminApiBase + "&op=tokens&data_source=" + encodeURIComponent(name), { credentials: "same-origin" })
.then(function (r) { return r.json(); })
.then(function (j) {
currentTokens = (j && j.tokens && Array.isArray(j.tokens)) ? j.tokens : [];
refreshTokenUi();
})
.catch(function () {
currentTokens = [];
refreshTokenUi();
if (tokenHelp) { tokenHelp.textContent = "Unable to load tokens for selected data source."; }
});
};
var updateTemplateMeta = function () {
if (messageField && messageMeta) { messageMeta.textContent = (messageField.value || "").length + " chars"; }
if (pdfField && pdfMeta) { pdfMeta.textContent = (pdfField.value || "").length + " chars"; }
if (messageSnippet && messageField) {
var msg = stripHtml(messageField.value || "");
messageSnippet.textContent = msg ? msg.substring(0, 160) + (msg.length > 160 ? "..." : "") : "No message content yet.";
}
if (pdfSnippet && pdfField) {
var pdf = stripHtml(pdfField.value || "");
pdfSnippet.textContent = pdf ? pdf.substring(0, 160) + (pdf.length > 160 ? "..." : "") : "No PDF template content yet.";
}
};
var ensureTemplateRichEditor = function () {
if (!templateHtml || templateRichEditor) { return; }
if (!(window.wp && wp.editor && typeof wp.editor.initialize === "function")) { return; }
wp.editor.initialize("ms-template-html", {
tinymce: {
wpautop: false,
menubar: false,
toolbar1: "bold italic bullist numlist | link unlink | undo redo",
forced_root_block: false,
verify_html: false,
valid_elements: "*[*]",
extended_valid_elements: "style[type|media]",
entity_encoding: "raw",
convert_urls: false
},
quicktags: true,
mediaButtons: false
});
if (window.tinymce && typeof window.tinymce.get === "function") {
templateRichEditor = window.tinymce.get("ms-template-html");
}
if (templateRichEditor) {
templateRichEditor.on("change input keyup setcontent", function () {
templateRichEditor.save();
templateDirty = true;
});
templateRichEditor.on("focus", function () { lastTarget = templateHtml; });
}
};
var normalizeAssetName = function (name) {
var n = String(name || "").toLowerCase();
n = n.replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "");
return n;
};
var loadPdfAssets = function () {
if (!pdfAssetPick) { return; }
pdfAssetPick.innerHTML = "<option value=\\"\\">Loading...</option>";
fetch(pdfApiBase + "&op=list", { credentials: "same-origin" })
.then(function (r) { return r.json(); })
.then(function (j) {
var items = (j && j.items && Array.isArray(j.items)) ? j.items : [];
pdfAssetPick.innerHTML = "<option value=\\"\\">Select PDF asset</option>";
items.forEach(function (row) {
var name = String((row && row.name) || "").trim();
if (!name) { return; }
var o = document.createElement("option");
o.value = name;
o.textContent = name;
pdfAssetPick.appendChild(o);
});
})
.catch(function () {
pdfAssetPick.innerHTML = "<option value=\\"\\">Unable to load assets</option>";
});
};
var openTemplateEditor = function (field, title, isPdf) {
if (!field || !templateModal) { return; }
ensureTemplateRichEditor();
activeTemplateField = field;
templateTitle.textContent = title;
setTemplateHtmlValue(field.value || "");
templateCodeCanonicalRaw = scrubEditorArtifacts(String(field.value || ""));
templateCodeEdited = false;
templateDirty = false;
setTemplateMode("visual");
templatePdfAssets.style.display = isPdf ? "block" : "none";
if (isPdf) { loadPdfAssets(); }
templateModal.style.display = "block";
};
var closeTemplateEditor = function () {
if (!templateModal) { return; }
if (templateDirty && !window.confirm("Discard changes in template editor?")) { return; }
templateModal.style.display = "none";
};
editor.querySelectorAll("input,select,textarea").forEach(function (el) {
el.addEventListener("input", markDirty);
el.addEventListener("change", markDirty);
});
editor.addEventListener("submit", function () { isDirty = false; });
if (subjectField) {
subjectField.addEventListener("focus", function () { lastTarget = subjectField; });
}
if (dsSelect) {
dsSelect.addEventListener("change", function () {
fetchTokensForDataSource(dsSelect.value || "");
markDirty();
});
}
if (openMessageEditor && messageField) {
openMessageEditor.addEventListener("click", function () {
openTemplateEditor(messageField, "Edit Message", false);
});
}
if (openPdfEditor && pdfField) {
openPdfEditor.addEventListener("click", function () {
openTemplateEditor(pdfField, "Edit PDF Attachment", true);
});
}
if (templateClose) { templateClose.addEventListener("click", closeTemplateEditor); }
if (templateHtml) {
templateHtml.addEventListener("input", function () {
templateDirty = true;
});
templateHtml.addEventListener("focus", function () { lastTarget = templateHtml; });
}
if (templateCode) {
templateCode.addEventListener("input", function () {
templateDirty = true;
templateCodeEdited = true;
});
templateCode.addEventListener("focus", function () { lastTarget = templateCode; });
}
if (templateModeVisualBtn) { templateModeVisualBtn.addEventListener("click", function () { setTemplateMode("visual"); }); }
if (templateModeCodeBtn) { templateModeCodeBtn.addEventListener("click", function () { setTemplateMode("code"); }); }
if (templateSave) {
templateSave.addEventListener("click", function () {
if (!activeTemplateField) { return; }
var valueToSave = "";
if (templateMode === "code" && templateCode) {
valueToSave = scrubEditorArtifacts(String(templateCode.value || ""));
if (templateHtml) { templateHtml.value = valueToSave; }
if (templateRichEditor) {
templateRichEditor.setContent(valueToSave);
templateRichEditor.save();
}
} else {
syncTemplateTextareaFromEditor();
valueToSave = scrubEditorArtifacts(getTemplateHtmlValue());
if (templateHtml) { templateHtml.value = valueToSave; }
}
activeTemplateField.value = valueToSave;
templateDirty = false;
templateModal.style.display = "none";
updateTemplateMeta();
markDirty();
});
}
if (pdfAssetInsert && pdfAssetPick) {
pdfAssetInsert.addEventListener("click", function () {
var raw = pdfAssetPick.value || "";
if (!raw) { return; }
var token = "{{ pdf_asset(\\"" + normalizeAssetName(raw) + "\\") }}";
insertToken(token);
});
}
if (openNew && editorModal) { if (openNew && editorModal) {
openNew.addEventListener("click", function () { openNew.addEventListener("click", function () {
if (!confirmDiscard()) { return; }
var idInput = document.getElementById("ms-editor-id"); var idInput = document.getElementById("ms-editor-id");
if (idInput) { idInput.value = ""; } if (idInput) { idInput.value = ""; }
["ms_purpose","ms_ds","ms_subject","ms_message","ms_pdfa","ms_cc","ms_bcc","ms_reply","ms_pdfname"].forEach(function (id) { ["ms_purpose","ms_ds","ms_subject","ms_message","ms_pdfa","ms_cc","ms_bcc","ms_reply","ms_pdfname","ms_email_field"].forEach(function (id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (el) { el.value = ""; } if (el) { el.value = ""; }
}); });
var list = document.getElementById("ms_attachment_list"); var list = document.getElementById("ms_attachment_list");
if (list) { list.innerHTML = ""; } if (list) { list.innerHTML = ""; }
var csv = document.getElementById("ms_attachment_csv");
if (csv) { csv.value = ""; }
currentTokens = [];
refreshTokenUi();
updateTemplateMeta();
isDirty = false;
editorModal.style.display = "block"; editorModal.style.display = "block";
}); });
} }
if (closeEditor && editorModal) { if (closeEditor && editorModal) {
closeEditor.addEventListener("click", function () { closeEditor.addEventListener("click", function () {
if (!confirmDiscard()) { return; }
editorModal.style.display = "none"; editorModal.style.display = "none";
}); });
} }
var targets = ["ms_subject", "ms_message", "ms_pdfa"];
targets.forEach(function (id) {
var el = document.getElementById(id);
if (!el) { return; }
el.addEventListener("focus", function () { lastTarget = el; });
});
var tokenButtons = editor.querySelectorAll(".ms-token-btn");
tokenButtons.forEach(function (btn) {
btn.addEventListener("click", function () {
var token = btn.getAttribute("data-token") || "";
if (!lastTarget) {
lastTarget = document.getElementById("ms_message");
}
if (!lastTarget) { return; }
var start = lastTarget.selectionStart || 0;
var end = lastTarget.selectionEnd || 0;
var value = lastTarget.value || "";
lastTarget.value = value.substring(0, start) + token + value.substring(end);
lastTarget.focus();
var next = start + token.length;
lastTarget.setSelectionRange(next, next);
});
});
var list = document.getElementById("ms_attachment_list"); var list = document.getElementById("ms_attachment_list");
var pick = document.getElementById("ms_attachment_pick"); var pick = document.getElementById("ms_attachment_pick");
@ -277,7 +757,7 @@ final class MailshotsAdminPage
list.addEventListener("click", function (ev) { list.addEventListener("click", function (ev) {
if (ev.target && ev.target.classList.contains("ms-att-remove")) { if (ev.target && ev.target.classList.contains("ms-att-remove")) {
var li = ev.target.closest("li"); var li = ev.target.closest("li");
if (li) { li.remove(); sync(); } if (li) { li.remove(); sync(); markDirty(); }
} }
}); });
addBtn.addEventListener("click", function () { addBtn.addEventListener("click", function () {
@ -298,12 +778,20 @@ final class MailshotsAdminPage
li.appendChild(rm); li.appendChild(rm);
list.appendChild(li); list.appendChild(li);
sync(); sync();
markDirty();
}); });
sync(); sync();
} }
refreshTokenUi();
updateTemplateMeta();
var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . '; var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . ';
if (hasEdit && editorModal) { var hasResult = ' . ($result !== null ? 'true' : 'false') . ';
var resultOk = ' . (!empty($result['ok']) ? 'true' : 'false') . ';
if (hasEdit && editorModal && (!hasResult || !resultOk)) {
editorModal.style.display = "block"; editorModal.style.display = "block";
isDirty = false;
} }
})(); })();
</script>'; </script>';
@ -332,6 +820,7 @@ final class MailshotsAdminPage
'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''), 'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''),
'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES), 'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES),
'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''), 'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''),
'RecipientEmailField' => (string) ($this->wp->requestParam('RecipientEmailField', '') ?? ''),
'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''), 'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''),
]; ];
$result = $this->service()->save($id, $payload); $result = $this->service()->save($id, $payload);
@ -426,6 +915,7 @@ final class MailshotsAdminPage
'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''), 'PDFAttachment' => (string) ($this->wp->requestParam('PDFAttachment', '') ?? ''),
'AttachmentNames' => (string) ($this->wp->requestParam('AttachmentNames', '[]') ?? '[]'), 'AttachmentNames' => (string) ($this->wp->requestParam('AttachmentNames', '[]') ?? '[]'),
'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''), 'PDFFilenameDerivedFrom' => (string) ($this->wp->requestParam('PDFFilenameDerivedFrom', '') ?? ''),
'RecipientEmailField' => (string) ($this->wp->requestParam('RecipientEmailField', '') ?? ''),
'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''), 'ReplyTo' => (string) ($this->wp->requestParam('ReplyTo', '') ?? ''),
]; ];
$this->wp->sendJson($this->service()->save($id, $payload)); $this->wp->sendJson($this->service()->save($id, $payload));
@ -450,10 +940,22 @@ final class MailshotsAdminPage
return ($this->serviceFactory)(); return ($this->serviceFactory)();
} }
private function cleanTokenLabel(string $value): string
{
$label = trim($value);
if ($label === '') {
return $label;
}
$label = str_replace(['{{', '}}'], '', $label);
$label = preg_replace('/\bfenedgec_members_/i', '', $label) ?? $label;
return trim($label);
}
/** @return array<string,mixed>|null */ /** @return array<string,mixed>|null */
private function result(): ?array private function result(): ?array
{ {
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null; return is_array($raw) ? $raw : null;
} }
} }

View File

@ -77,7 +77,7 @@ final class PdfAssetsAdminPage
echo '<div id="pdf-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="pdf-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>'; echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="pdf-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>'; echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">'; echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">';
if ($editId > 0) { if ($editId > 0) {
@ -131,7 +131,7 @@ final class PdfAssetsAdminPage
echo '<tr><td colspan="7">No PDF assets found.</td></tr>'; echo '<tr><td colspan="7">No PDF assets found.</td></tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '<script>(function(){var modal=document.getElementById("pdf-editor-modal");var openBtn=document.getElementById("pdf-open-new");var closeBtn=document.getElementById("pdf-close-editor");if(openBtn&&modal){openBtn.addEventListener("click",function(){var id=document.getElementById("pdf-editor-id");if(id){id.value="";}["pdf_name","pdf_file_name","pdf_mime","pdf_width","pdf_height","pdf_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}var j=document.getElementById("pdf_just");if(j){j.value="in-place";}modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';if(hasEdit&&modal){modal.style.display="block";}})();</script>'; echo '<script>(function(){var modal=document.getElementById("pdf-editor-modal");var openBtn=document.getElementById("pdf-open-new");var closeBtn=document.getElementById("pdf-close-editor");var form=document.getElementById("pdf-editor-form");var isDirty=false;function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}if(form){form.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});form.addEventListener("submit",function(){isDirty=false;});}if(openBtn&&modal){openBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}var id=document.getElementById("pdf-editor-id");if(id){id.value="";}["pdf_name","pdf_file_name","pdf_mime","pdf_width","pdf_height","pdf_base64"].forEach(function(x){var el=document.getElementById(x);if(el){el.value="";}}var j=document.getElementById("pdf_just");if(j){j.value="in-place";}isDirty=false;modal.style.display="block";});}if(closeBtn&&modal){closeBtn.addEventListener("click",function(){if(!confirmDiscard()){return;}modal.style.display="none";});}var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';var hasResult=' . ($result !== null ? 'true' : 'false') . ';var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';if(hasEdit&&modal&&(!hasResult||!resultOk)){modal.style.display="block";isDirty=false;}})();</script>';
echo '</div>'; echo '</div>';
} }
@ -250,6 +250,7 @@ final class PdfAssetsAdminPage
private function result(): ?array private function result(): ?array
{ {
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null; return is_array($raw) ? $raw : null;
} }
} }

View File

@ -432,6 +432,7 @@ final class ProfileAdminPage
private function testResult(int $uid): ?array private function testResult(int $uid): ?array
{ {
$raw = $this->wp->getOption($this->testResultOptionKey($uid), null); $raw = $this->wp->getOption($this->testResultOptionKey($uid), null);
$this->wp->deleteOption($this->testResultOptionKey($uid));
if (!is_array($raw) || !isset($raw['kind']) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) { if (!is_array($raw) || !isset($raw['kind']) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) {
return null; return null;
} }

View File

@ -230,6 +230,7 @@ final class RunMailshotAdminPage
private function result(): ?array private function result(): ?array
{ {
$raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::RESULT_OPTION_KEY);
return is_array($raw) ? $raw : null; return is_array($raw) ? $raw : null;
} }

View File

@ -228,6 +228,7 @@ final class SetupAdminPage
private function testResult(): ?array private function testResult(): ?array
{ {
$raw = $this->wp->getOption(self::TEST_RESULT_OPTION_KEY, null); $raw = $this->wp->getOption(self::TEST_RESULT_OPTION_KEY, null);
$this->wp->deleteOption(self::TEST_RESULT_OPTION_KEY);
if (!is_array($raw) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) { if (!is_array($raw) || !isset($raw['ok']) || !isset($raw['messages']) || !is_array($raw['messages'])) {
return null; return null;
} }

View File

@ -170,14 +170,20 @@ final class DataSourceService
$schemas[] = $membersSchema; $schemas[] = $membersSchema;
} }
$sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name'; // Shared-hosting DB users often cannot read information_schema.
$rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); // Keep working with the configured members DB schema in that case.
foreach ($rows as $row) { try {
$name = (string) ($row['schema_name'] ?? ''); $sql = 'SELECT schema_name FROM information_schema.schemata ORDER BY schema_name';
if ($name === '' || in_array($name, ['information_schema', 'performance_schema', 'mysql', 'sys'], true)) { $rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
continue; foreach ($rows as $row) {
$name = (string) ($row['schema_name'] ?? '');
if ($name === '' || in_array($name, ['information_schema', 'performance_schema', 'mysql', 'sys'], true)) {
continue;
}
$schemas[] = $name;
} }
$schemas[] = $name; } catch (\Throwable $e) {
// no-op: fallback is the configured members schema above
} }
$schemas = array_values(array_unique($schemas)); $schemas = array_values(array_unique($schemas));
@ -192,10 +198,18 @@ final class DataSourceService
if ($schema === '') { if ($schema === '') {
return []; return [];
} }
$sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = :schema ORDER BY table_name'; // Shared-hosting users may not have information_schema access.
$stmt = $this->router->membersPdo()->prepare($sql); // Use explicit SHOW TABLES for the selected schema.
$stmt->execute(['schema' => $schema]); $sql = 'SHOW TABLES FROM `' . str_replace('`', '``', $schema) . '`';
return array_map(static fn(array $row): string => (string) ($row['table_name'] ?? ''), $stmt->fetchAll(PDO::FETCH_ASSOC)); $rows = $this->router->membersPdo()->query($sql)->fetchAll(PDO::FETCH_NUM);
$tables = [];
foreach ($rows as $row) {
if (isset($row[0]) && trim((string) $row[0]) !== '') {
$tables[] = (string) $row[0];
}
}
sort($tables, SORT_NATURAL | SORT_FLAG_CASE);
return $tables;
} }
/** @return list<string> */ /** @return list<string> */

View File

@ -50,7 +50,8 @@ final class DslCompiler
$whereSql = $whereParts === [] ? '' : ' WHERE ' . implode(' AND ', $whereParts); $whereSql = $whereParts === [] ? '' : ' WHERE ' . implode(' AND ', $whereParts);
$sql = 'SELECT * FROM ' . $from; $selectSql = count($sources) > 1 ? $this->buildUniqueSelectProjection($sources) : '*';
$sql = 'SELECT ' . $selectSql . ' FROM ' . $from;
if ($joins !== []) { if ($joins !== []) {
$sql .= ' ' . implode(' ', $joins); $sql .= ' ' . implode(' ', $joins);
} }
@ -134,14 +135,18 @@ final class DslCompiler
return $this->alias('renewals') . '.`status` = \'pending\''; return $this->alias('renewals') . '.`status` = \'pending\'';
} }
if ($name === 'fen1-contact') { if ($name === 'fen1-contact') {
return $this->alias('contacts') . '.`FENContact1` = 1'; return $this->alias('contacts') . '.`is_fen_1` = 1';
} }
if ($name === 'fen2-contact') { if ($name === 'primary-contact') {
return $this->alias('contacts') . '.`FENContact2` = 1'; return $this->alias('contacts') . '.`is_contact_1` = 1';
} }
if ($name === 'member-or-affiliate-or-parish-council') { if ($name === 'member-or-affiliate-or-parish-council') {
$acc = $this->alias('accounts'); $acc = $this->alias('accounts');
return sprintf("(%s.`Type` IN ('Member','Affiliate') OR %s.`Name` LIKE '%%Parish Council%%')", $acc, $acc); return sprintf(
"(%s.`account_type_id` IN (SELECT id FROM `picklist_account_type` WHERE value IN ('Member','Affiliate')) OR %s.`name` LIKE '%%Parish Council%%')",
$acc,
$acc
);
} }
throw new AppError('dsl_compile', 'Unknown filter', ['filter' => $name]); throw new AppError('dsl_compile', 'Unknown filter', ['filter' => $name]);
@ -178,4 +183,20 @@ final class DslCompiler
} }
return '`' . $source . '`'; return '`' . $source . '`';
} }
/** @param list<string> $sources */
private function buildUniqueSelectProjection(array $sources): string
{
$parts = [];
foreach ($sources as $source) {
$alias = $this->alias($source);
foreach ($this->metadata->sourceFields($source) as $field) {
$parts[] = $alias . '.`' . $field . '` AS `' . $source . '.' . $field . '`';
}
}
if ($parts === []) {
throw new AppError('dsl_compile', 'No fields available for selected sources');
}
return implode(', ', $parts);
}
} }

View File

@ -14,8 +14,8 @@ final class DslValidator
private array $filterSourceRequirements = [ private array $filterSourceRequirements = [
'selected-renewal' => ['renewals'], 'selected-renewal' => ['renewals'],
'pending-renewal' => ['renewals'], 'pending-renewal' => ['renewals'],
'primary-contact' => ['contacts'],
'fen1-contact' => ['contacts'], 'fen1-contact' => ['contacts'],
'fen2-contact' => ['contacts'],
'member-or-affiliate-or-parish-council' => ['accounts'], 'member-or-affiliate-or-parish-council' => ['accounts'],
]; ];
@ -71,7 +71,7 @@ final class DslValidator
} }
if (!$this->hasAnyEmailField($sources)) { if (!$this->hasAnyEmailField($sources)) {
$warnings[] = 'Data Source does not have an email field, it cannot be used for a Mailshot'; $warnings[] = 'Data Source has no obvious email field; select an explicit Recipient Email Field on each Mailshot.';
} }
foreach ($ast['where'] as $predicate) { foreach ($ast['where'] as $predicate) {
@ -97,6 +97,10 @@ final class DslValidator
if ($predicate['type'] === 'filter') { if ($predicate['type'] === 'filter') {
$name = $predicate['name']; $name = $predicate['name'];
if ($name === 'fen2-contact') {
$errors[] = 'Filter fen2-contact is not supported. Use fen1-contact.';
return;
}
if (!isset($this->filterSourceRequirements[$name])) { if (!isset($this->filterSourceRequirements[$name])) {
$errors[] = 'Unknown filter: ' . $name; $errors[] = 'Unknown filter: ' . $name;
return; return;

View File

@ -58,13 +58,14 @@ final class MailshotRunService
$limit = max(1, min(500, $limit)); $limit = max(1, min(500, $limit));
$rows = array_slice($rows, 0, $limit); $rows = array_slice($rows, 0, $limit);
$out = []; $out = [];
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
foreach (array_values($rows) as $i => $row) { foreach (array_values($rows) as $i => $row) {
[$keyField, $keyValue] = $this->detectRecipientKey($row, $i); [$keyField, $keyValue] = $this->detectRecipientKey($row, $i);
$out[] = [ $out[] = [
'index' => $i, 'index' => $i,
'recipient_key_field' => $keyField, 'recipient_key_field' => $keyField,
'recipient_key' => $keyValue, 'recipient_key' => $keyValue,
'recipient_email' => $this->detectEmail($row), 'recipient_email' => $this->detectEmail($row, $recipientEmailField),
'row' => $row, 'row' => $row,
]; ];
} }
@ -119,6 +120,7 @@ final class MailshotRunService
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? '')); $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? '')); $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
$attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis'); $attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');
@ -268,18 +270,20 @@ final class MailshotRunService
{ {
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? '')); $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? '')); $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
$recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
$counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'skipped' => 0]; $counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'skipped' => 0];
$log = []; $log = [];
foreach (array_values($rows) as $index => $row) { foreach (array_values($rows) as $index => $row) {
$counters['attempted']++; $counters['attempted']++;
$recipientEmail = $this->detectEmail($row); $recipientEmail = $this->detectEmail($row, $recipientEmailField);
[$recipientKeyField, $recipientKey] = $this->detectRecipientKey($row, $index); [$recipientKeyField, $recipientKey] = $this->detectRecipientKey($row, $index);
if ($recipientEmail === null) { if ($recipientEmail === null) {
$counters['failed']++; $counters['failed']++;
$log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => 'No email field found in recipient row.']; $error = $recipientEmailField !== '' ? ('Recipient email field "' . $recipientEmailField . '" is missing or invalid in recipient row.') : 'No email field found in recipient row.';
$log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => $error];
$this->lastRun->create([ $this->lastRun->create([
'mailshot_id' => $mailshotId, 'mailshot_id' => $mailshotId,
'data_source' => (string) ($mailshot['DataSource'] ?? ''), 'data_source' => (string) ($mailshot['DataSource'] ?? ''),
@ -288,7 +292,7 @@ final class MailshotRunService
'recipient_key_field' => $recipientKeyField, 'recipient_key_field' => $recipientKeyField,
'recipient_email_last' => null, 'recipient_email_last' => null,
'status' => 'failed', 'status' => 'failed',
'error_message' => 'No email field found in recipient row.', 'error_message' => $error,
'attempt_count' => $freshRun ? 1 : 2, 'attempt_count' => $freshRun ? 1 : 2,
]); ]);
continue; continue;
@ -404,8 +408,28 @@ final class MailshotRunService
} }
/** @param array<string,mixed> $row */ /** @param array<string,mixed> $row */
private function detectEmail(array $row): ?string private function detectEmail(array $row, string $preferredField = ''): ?string
{ {
if ($preferredField !== '') {
$candidates = [$preferredField];
if (str_contains($preferredField, '.')) {
$parts = explode('.', $preferredField);
$last = (string) end($parts);
if ($last !== '') {
$candidates[] = $last;
}
}
foreach ($candidates as $candidate) {
foreach ($row as $k => $val) {
if (strcasecmp((string) $k, $candidate) === 0) {
$v = trim((string) $val);
if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) {
return $v;
}
}
}
}
}
foreach (['Email', 'email', 'contact_email', 'account_email', 'recipient_email', 'recipient_email_last'] as $key) { foreach (['Email', 'email', 'contact_email', 'account_email', 'recipient_email', 'recipient_email_last'] as $key) {
if (isset($row[$key])) { if (isset($row[$key])) {
$v = trim((string) $row[$key]); $v = trim((string) $row[$key]);

View File

@ -55,6 +55,7 @@ final class MailshotService
{ {
$purpose = trim((string) ($payload['Purpose'] ?? '')); $purpose = trim((string) ($payload['Purpose'] ?? ''));
$dataSource = trim((string) ($payload['DataSource'] ?? '')); $dataSource = trim((string) ($payload['DataSource'] ?? ''));
$recipientEmailField = trim((string) ($payload['RecipientEmailField'] ?? ''));
$subject = trim((string) ($payload['Subject'] ?? '')); $subject = trim((string) ($payload['Subject'] ?? ''));
$message = trim((string) ($payload['Message'] ?? '')); $message = trim((string) ($payload['Message'] ?? ''));
@ -70,6 +71,20 @@ final class MailshotService
if ($message === '') { if ($message === '') {
$errors[] = 'Message is required.'; $errors[] = 'Message is required.';
} }
if ($recipientEmailField === '') {
$errors[] = 'RecipientEmailField is required.';
} elseif ($dataSource !== '') {
$tokens = $this->tokenInsertionData($dataSource);
$fields = [];
foreach (($tokens['tokens'] ?? []) as $token) {
if (is_array($token) && isset($token['field'])) {
$fields[] = (string) $token['field'];
}
}
if ($fields !== [] && !in_array($recipientEmailField, $fields, true)) {
$errors[] = 'RecipientEmailField must be one of the selected DataSource fields.';
}
}
$attachmentNames = $this->normalizeAttachmentNames($payload['AttachmentNames'] ?? null); $attachmentNames = $this->normalizeAttachmentNames($payload['AttachmentNames'] ?? null);
@ -88,6 +103,7 @@ final class MailshotService
'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES), 'AttachmentNames' => json_encode($attachmentNames, JSON_UNESCAPED_SLASHES),
'PDFFilenameDerivedFrom' => trim((string) ($payload['PDFFilenameDerivedFrom'] ?? '')), 'PDFFilenameDerivedFrom' => trim((string) ($payload['PDFFilenameDerivedFrom'] ?? '')),
'ReplyTo' => trim((string) ($payload['ReplyTo'] ?? '')), 'ReplyTo' => trim((string) ($payload['ReplyTo'] ?? '')),
'RecipientEmailField' => $recipientEmailField,
]; ];
if ($id === null) { if ($id === null) {

View File

@ -242,6 +242,7 @@ final class DslParser
$filters = [ $filters = [
'selected-renewal', 'selected-renewal',
'pending-renewal', 'pending-renewal',
'primary-contact',
'fen1-contact', 'fen1-contact',
'fen2-contact', 'fen2-contact',
'member-or-affiliate-or-parish-council', 'member-or-affiliate-or-parish-council',

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace FecaMailshots\Infrastructure; namespace FecaMailshots\Infrastructure;
use FecaMailshots\Application\SourceMetadataProvider; use FecaMailshots\Application\SourceMetadataProvider;
use PDO;
final class DatabaseSourceMetadataProvider implements SourceMetadataProvider final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
{ {
@ -21,18 +20,21 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
{ {
$this->router = $router; $this->router = $router;
$this->builtInFields = [ $this->builtInFields = [
'contacts' => ['ID', 'Accountid', 'First', 'Last', 'Email', 'FENContact1', 'FENContact2'], 'contacts' => ['id', 'account_id', 'first_name', 'last_name', 'contact_email_1', 'contact_email_2', 'is_contact_1', 'is_contact_2', 'is_fen_1', 'is_fen_2', 'is_public_contact'],
'accounts' => ['ID', 'Name', 'Type', 'Email'], 'accounts' => ['id', 'name', 'account_type_id'],
'renewals' => ['id', 'account_id', 'status', 'selected', 'email'], 'renewals' => ['id', 'account_id', 'status', 'selected', 'email'],
'grants' => ['id', 'account_id', 'name', 'amount', 'applied_for', 'total_cost', 'grant_date', 'notes'],
]; ];
$this->joinMap = [ $this->joinMap = [
'contacts|accounts' => ['left' => 'contacts.Accountid', 'right' => 'accounts.ID'], 'contacts|accounts' => ['left' => 'contacts.account_id', 'right' => 'accounts.id'],
'accounts|contacts' => ['left' => 'accounts.ID', 'right' => 'contacts.Accountid'], 'accounts|contacts' => ['left' => 'accounts.id', 'right' => 'contacts.account_id'],
'renewals|accounts' => ['left' => 'renewals.account_id', 'right' => 'accounts.ID'], 'renewals|accounts' => ['left' => 'renewals.account_id', 'right' => 'accounts.id'],
'accounts|renewals' => ['left' => 'accounts.ID', 'right' => 'renewals.account_id'], 'accounts|renewals' => ['left' => 'accounts.id', 'right' => 'renewals.account_id'],
'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.Accountid'], 'renewals|contacts' => ['left' => 'renewals.account_id', 'right' => 'contacts.account_id'],
'contacts|renewals' => ['left' => 'contacts.Accountid', 'right' => 'renewals.account_id'], 'contacts|renewals' => ['left' => 'contacts.account_id', 'right' => 'renewals.account_id'],
'grants|accounts' => ['left' => 'grants.account_id', 'right' => 'accounts.id'],
'accounts|grants' => ['left' => 'accounts.id', 'right' => 'grants.account_id'],
]; ];
} }
@ -47,10 +49,10 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
} }
[$schema, $table] = explode('.', $source, 2); [$schema, $table] = explode('.', $source, 2);
$sql = 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = :schema AND table_name = :table'; $show = 'SHOW TABLES FROM `' . str_replace('`', '``', $schema) . '` LIKE ?';
$stmt = $this->router->membersPdo()->prepare($sql); $stmt = $this->router->membersPdo()->prepare($show);
$stmt->execute(['schema' => $schema, 'table' => $table]); $stmt->execute([$table]);
return ((int) $stmt->fetchColumn()) > 0; return (bool) $stmt->fetchColumn();
} }
public function sourceFields(string $source): array public function sourceFields(string $source): array
@ -64,10 +66,9 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
} }
[$schema, $table] = explode('.', $source, 2); [$schema, $table] = explode('.', $source, 2);
$sql = 'SELECT COLUMN_NAME FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table ORDER BY ORDINAL_POSITION'; $sql = 'SHOW COLUMNS FROM `' . str_replace('`', '``', $schema) . '`.`' . str_replace('`', '``', $table) . '`';
$stmt = $this->router->membersPdo()->prepare($sql); $rows = $this->router->membersPdo()->query($sql)->fetchAll(\PDO::FETCH_ASSOC);
$stmt->execute(['schema' => $schema, 'table' => $table]); return array_values(array_filter(array_map(static fn(array $row): string => (string) ($row['Field'] ?? ''), $rows), static fn(string $v): bool => $v !== ''));
return array_map(static fn(array $row): string => (string) $row['COLUMN_NAME'], $stmt->fetchAll(PDO::FETCH_ASSOC));
} }
public function hasEmailField(string $source): bool public function hasEmailField(string $source): bool
@ -84,6 +85,6 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
public function allKnownSources(): array public function allKnownSources(): array
{ {
return ['contacts', 'accounts', 'renewals']; return ['contacts', 'accounts', 'renewals', 'grants'];
} }
} }

View File

@ -10,6 +10,7 @@ use PDO;
final class MailshotRepository final class MailshotRepository
{ {
private DatabaseRouter $router; private DatabaseRouter $router;
private bool $recipientEmailFieldColumnChecked = false;
public function __construct(DatabaseRouter $router) public function __construct(DatabaseRouter $router)
{ {
@ -19,13 +20,15 @@ final class MailshotRepository
/** @return list<array<string, mixed>> */ /** @return list<array<string, mixed>> */
public function all(): array public function all(): array
{ {
$sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo FROM mailshots ORDER BY Purpose ASC'; $this->ensureRecipientEmailFieldColumn();
$sql = 'SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField FROM mailshots ORDER BY Purpose ASC';
return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC); return $this->router->mailshotsPdo()->query($sql)->fetchAll(PDO::FETCH_ASSOC);
} }
public function find(int $id): ?array public function find(int $id): ?array
{ {
$stmt = $this->router->mailshotsPdo()->prepare('SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo FROM mailshots WHERE id = :id'); $this->ensureRecipientEmailFieldColumn();
$stmt = $this->router->mailshotsPdo()->prepare('SELECT id, Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField FROM mailshots WHERE id = :id');
$stmt->execute(['id' => $id]); $stmt->execute(['id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row === false ? null : $row; return $row === false ? null : $row;
@ -34,7 +37,8 @@ final class MailshotRepository
/** @param array<string, mixed> $row */ /** @param array<string, mixed> $row */
public function create(array $row): int public function create(array $row): int
{ {
$sql = 'INSERT INTO mailshots (Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo) VALUES (:Purpose, :DataSource, :CC, :BCC, :Subject, :Message, :PDFAttachment, :AttachmentNames, :PDFFilenameDerivedFrom, :ReplyTo)'; $this->ensureRecipientEmailFieldColumn();
$sql = 'INSERT INTO mailshots (Purpose, DataSource, CC, BCC, Subject, Message, PDFAttachment, AttachmentNames, PDFFilenameDerivedFrom, ReplyTo, RecipientEmailField) VALUES (:Purpose, :DataSource, :CC, :BCC, :Subject, :Message, :PDFAttachment, :AttachmentNames, :PDFFilenameDerivedFrom, :ReplyTo, :RecipientEmailField)';
$stmt = $this->router->mailshotsPdo()->prepare($sql); $stmt = $this->router->mailshotsPdo()->prepare($sql);
$stmt->execute($this->payload($row)); $stmt->execute($this->payload($row));
return (int) $this->router->mailshotsPdo()->lastInsertId(); return (int) $this->router->mailshotsPdo()->lastInsertId();
@ -43,7 +47,8 @@ final class MailshotRepository
/** @param array<string, mixed> $row */ /** @param array<string, mixed> $row */
public function update(int $id, array $row): void public function update(int $id, array $row): void
{ {
$sql = 'UPDATE mailshots SET Purpose = :Purpose, DataSource = :DataSource, CC = :CC, BCC = :BCC, Subject = :Subject, Message = :Message, PDFAttachment = :PDFAttachment, AttachmentNames = :AttachmentNames, PDFFilenameDerivedFrom = :PDFFilenameDerivedFrom, ReplyTo = :ReplyTo WHERE id = :id'; $this->ensureRecipientEmailFieldColumn();
$sql = 'UPDATE mailshots SET Purpose = :Purpose, DataSource = :DataSource, CC = :CC, BCC = :BCC, Subject = :Subject, Message = :Message, PDFAttachment = :PDFAttachment, AttachmentNames = :AttachmentNames, PDFFilenameDerivedFrom = :PDFFilenameDerivedFrom, ReplyTo = :ReplyTo, RecipientEmailField = :RecipientEmailField WHERE id = :id';
$stmt = $this->router->mailshotsPdo()->prepare($sql); $stmt = $this->router->mailshotsPdo()->prepare($sql);
$payload = $this->payload($row); $payload = $this->payload($row);
$payload['id'] = $id; $payload['id'] = $id;
@ -77,9 +82,39 @@ final class MailshotRepository
'AttachmentNames' => $this->textOrNull($row['AttachmentNames'] ?? null), 'AttachmentNames' => $this->textOrNull($row['AttachmentNames'] ?? null),
'PDFFilenameDerivedFrom' => $this->textOrNull($row['PDFFilenameDerivedFrom'] ?? null), 'PDFFilenameDerivedFrom' => $this->textOrNull($row['PDFFilenameDerivedFrom'] ?? null),
'ReplyTo' => $this->textOrNull($row['ReplyTo'] ?? null), 'ReplyTo' => $this->textOrNull($row['ReplyTo'] ?? null),
'RecipientEmailField' => $this->textOrNull($row['RecipientEmailField'] ?? null),
]; ];
} }
private function ensureRecipientEmailFieldColumn(): void
{
if ($this->recipientEmailFieldColumnChecked) {
return;
}
$this->recipientEmailFieldColumnChecked = true;
$pdo = $this->router->mailshotsPdo();
$exists = false;
try {
$schema = $this->router->mailshotsDbName();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = :schema AND table_name = 'mailshots' AND column_name = 'RecipientEmailField'");
$stmt->execute(['schema' => $schema]);
$exists = (int) $stmt->fetchColumn() > 0;
} catch (\Throwable $e) {
$cols = $pdo->query('SHOW COLUMNS FROM mailshots')->fetchAll(PDO::FETCH_ASSOC);
foreach ($cols as $col) {
if (strcasecmp((string) ($col['Field'] ?? ''), 'RecipientEmailField') === 0) {
$exists = true;
break;
}
}
}
if (!$exists) {
$pdo->exec('ALTER TABLE mailshots ADD COLUMN RecipientEmailField VARCHAR(255) NULL AFTER ReplyTo');
}
}
private function text($value): string private function text($value): string
{ {
return trim((string) $value); return trim((string) $value);

View File

@ -79,6 +79,12 @@ final class FixtureWordPressFacade implements WordPressFacade
return true; return true;
} }
public function deleteOption(string $name): bool
{
unset($this->options[$name]);
return true;
}
public function dispatch(string $hook): void public function dispatch(string $hook): void
{ {
foreach ($this->actions[$hook] ?? [] as $callback) { foreach ($this->actions[$hook] ?? [] as $callback) {

View File

@ -67,4 +67,9 @@ final class ProductionWordPressFacade implements WordPressFacade
{ {
return (bool) update_option($name, $value); return (bool) update_option($name, $value);
} }
public function deleteOption(string $name): bool
{
return (bool) delete_option($name);
}
} }

View File

@ -35,4 +35,6 @@ interface WordPressFacade
* @param mixed $value * @param mixed $value
*/ */
public function updateOption(string $name, $value): bool; public function updateOption(string $name, $value): bool;
public function deleteOption(string $name): bool;
} }

72
package-lock.json generated Normal file
View File

@ -0,0 +1,72 @@
{
"name": "feca-mailshots-plugin-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "feca-mailshots-plugin-e2e",
"devDependencies": {
"@playwright/test": "^1.53.0"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

13
package.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "feca-mailshots-plugin-e2e",
"private": true,
"type": "module",
"scripts": {
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.53.0"
}
}

14
playwright.config.mjs Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e/specs',
workers: 1,
timeout: 180000,
expect: { timeout: 15000 },
retries: 0,
reporter: [['list']],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:8080',
trace: 'on-first-retry'
}
});

View File

@ -15,6 +15,15 @@ This document remains in the working context for all operations.
No fallback behavior may be introduced or enabled unless a requirement explicitly instructs it. No fallback behavior may be introduced or enabled unless a requirement explicitly instructs it.
Use the following rule in prompts:
Hard constraints for this task:
1) No fallbacks unless explicitly listed below.
2) No synthetic placeholders/default labels.
3) Fail fast on missing/invalid data paths.
4) Show explicit errors at point of detection.
5) If a fallback seems necessary, stop and ask first.
## Repository Context ## Repository Context
- Primary workspace: `mailshot-plugin` - Primary workspace: `mailshot-plugin`

View File

@ -1,8 +1,3 @@
{% macro br(text) %}
{% if text %}
{{ text }}<br>
{% endif %}
{% endmacro %}
<style> <style>
body { body {
font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif; font-family: Cambria, Cochin, Georgia, Times, 'Times New Roman', serif;
@ -75,12 +70,12 @@ Cottenham<br>
CB24 8UA</div> CB24 8UA</div>
<div class="to-address"> <div class="to-address">
{{ br(contact_name) }} {{ contact_name }}
<b>{{ br(AdvertiserName) }}</b> <b>{{ AdvertiserName }}</b>
{{ br(address_1) }} {{ address_1 }}
{{ br(address_2) }} {{ address_2 }}
{{ br(town) }} {{ town }}
{{ br(post_code) }} {{ post_code }}
</div> </div>
<div class="invoice-header"> <div class="invoice-header">

34
tests/e2e/README.md Normal file
View File

@ -0,0 +1,34 @@
# Browser E2E (Playwright)
This suite runs browser-based smoke/regression coverage against fixture-hosted admin UI (`tests/fixture/wp-admin/*`).
## Run
```bash
tests/e2e/run.sh
```
Or manually:
```bash
scripts/run_fixture_server.sh start
npm install
npx playwright install chromium
E2E_BASE_URL=http://127.0.0.1:8080 npx playwright test
```
## Specs
- `admin-ui-smoke.spec.mjs`
- page-load smoke for Data Sources, Mailshots, Attachments, PDF Assets.
- `data-sources.spec.mjs`
- modal create flow, preview regression guard (`contacts and accounts`), validation failure path.
- `mailshots-editor.spec.mjs`
- datasource-driven token/field population, message/PDF overlay editor flows, create mailshot.
- `run-test-pages.spec.mjs`
- Run/Test page load checks and API failure-path assertions (`run_mailshot` invalid id, `send_test` blank email).
## Notes
- Tests clean up created Data Source/Mailshot artefacts.
- This suite is intended as a pre-manual gate before release/deploy.

17
tests/e2e/run.sh Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
FIXTURE_PORT="${FIXTURE_PORT:-8092}"
BASE_URL="${E2E_BASE_URL:-http://127.0.0.1:${FIXTURE_PORT}}"
cd "${ROOT_DIR}"
FIXTURE_PORT="${FIXTURE_PORT}" scripts/run_fixture_server.sh start >/dev/null
if [[ ! -d node_modules ]]; then
npm install
fi
npx playwright install chromium
E2E_BASE_URL="${BASE_URL}" npx playwright test

View File

@ -0,0 +1,16 @@
import { test, expect } from '@playwright/test';
import { adminPath } from './helpers.mjs';
test('admin UI smoke: key admin pages load', async ({ page }) => {
await page.goto(adminPath('feca-mailshot-data-sources'));
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.goto(adminPath('feca-mailshots-attachments'));
await expect(page.locator('h1', { hasText: 'Attachments' })).toBeVisible();
await page.goto(adminPath('feca-mailshots-pdf-assets'));
await expect(page.locator('h1', { hasText: 'PDF Assets' })).toBeVisible();
});

View File

@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';
import { adminPath, apiPost, cleanupByNames, ensureDataSource } from './helpers.mjs';
test('data sources: create + preview + validation failure path', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_${uniq}`;
try {
await page.goto(adminPath('feca-mailshot-data-sources'));
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
await page.getByRole('button', { name: 'New Data Source' }).click();
await page.locator('#ds_name').fill(dsName);
await page.locator('#ds_dsl').fill('contacts and accounts');
await page.getByRole('button', { name: /^Create|Save$/ }).first().click();
await expect(page.getByRole('cell', { name: dsName })).toBeVisible();
const preview = await apiPost(request, 'feca_mailshots_data_sources_api', 'preview', {
dsl_text: 'contacts and accounts',
limit: '10'
});
expect(preview._httpStatus).toBe(200);
expect(JSON.stringify(preview)).not.toContain('Duplicate column name');
const invalid = await apiPost(request, 'feca_mailshots_data_sources_api', 'validate', {
dsl_text: 'unknownsource'
});
expect(invalid._httpStatus).toBe(200);
expect(Array.isArray(invalid.errors)).toBeTruthy();
expect(invalid.errors.join(' | ')).toContain('Unknown source');
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName] });
} catch {
// best effort
}
}
});
test('data sources: DSL builder round-trip preserves complex representable DSL', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ds_roundtrip_${uniq}`;
const dsl =
"contacts and accounts where fen1-contact and not (primary-contact) and contacts.account_id = accounts.id and contacts.last_name contains 'smith' and accounts.id in (123) and accounts.name starts-with 'St' and accounts.name ends-with 'Ltd' and contacts.account_id != accounts.id";
try {
await ensureDataSource(request, dsName, dsl);
await page.goto(adminPath('feca-mailshot-data-sources'));
await expect(page.getByRole('heading', { name: 'Mailshot Data Sources' })).toBeVisible();
const row = page.locator('tr', {
has: page.getByRole('cell', { name: dsName })
}).first();
await expect(row).toBeVisible();
await row.getByRole('link', { name: 'Edit' }).click();
await expect(page.locator('#ds-editor-modal')).toBeVisible();
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
await page.locator('#ds-build-dsl-open').click();
await expect(page.locator('#ds-builder-modal')).toBeVisible();
await expect(page.locator('#ds-builder-output')).toHaveValue(dsl);
await page.locator('#ds-builder-apply').click();
await expect(page.locator('#ds_dsl')).toHaveValue(dsl);
await page.locator('#ds-build-dsl-open').click();
await expect(page.locator('#ds-builder-output')).toHaveValue(dsl);
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName] });
} catch {
// best effort
}
}
});

View File

@ -0,0 +1,52 @@
import { expect } from '@playwright/test';
export const adminPath = (page) => `/wp-admin/admin.php?page=${page}`;
export const postPath = (action, op = '') => `/wp-admin/admin-post.php?action=${encodeURIComponent(action)}${op ? `&op=${encodeURIComponent(op)}` : ''}`;
export async function apiPost(request, action, op, form = {}) {
const res = await request.post(postPath(action, op), { form });
const status = res.status();
const raw = await res.text();
let decoded;
try {
decoded = JSON.parse(raw);
} catch {
decoded = { ok: false, error: `Non-JSON response (HTTP ${status})`, raw };
}
return { _httpStatus: status, ...decoded };
}
export async function apiList(request, action) {
return await apiPost(request, action, 'list', {});
}
export async function ensureDataSource(request, name, dslText) {
const out = await apiPost(request, 'feca_mailshots_data_sources_api', 'save', {
name,
dsl_text: dslText
});
expect(out.ok).toBeTruthy();
return out;
}
export async function ensureMailshot(request, payload) {
const out = await apiPost(request, 'feca_mailshots_mailshots_api', 'save', payload);
expect(out.ok).toBeTruthy();
return out;
}
export async function cleanupByNames(request, { dataSourceNames = [], mailshotPurposes = [] } = {}) {
const msList = await apiList(request, 'feca_mailshots_mailshots_api');
for (const row of msList.items || []) {
if (mailshotPurposes.includes(String(row.Purpose || ''))) {
await apiPost(request, 'feca_mailshots_mailshots_api', 'delete', { id: String(row.id || 0) });
}
}
const dsList = await apiList(request, 'feca_mailshots_data_sources_api');
for (const row of dsList.items || []) {
if (dataSourceNames.includes(String(row.name || ''))) {
await apiPost(request, 'feca_mailshots_data_sources_api', 'delete', { id: String(row.ID || row.id || 0) });
}
}
}

View File

@ -0,0 +1,92 @@
import { test, expect } from '@playwright/test';
import { adminPath, ensureDataSource, cleanupByNames } from './helpers.mjs';
test('mailshots editor: datasource-driven options + overlay editors + create', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ms_ds_${uniq}`;
const purpose = `e2e_mailshot_${uniq}`;
try {
await ensureDataSource(request, dsName, 'contacts');
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill(purpose);
await page.locator('#ms_ds').selectOption({ label: dsName });
await expect.poll(async () => await page.locator('#ms-token-controls button, #ms-token-controls select option').count()).toBeGreaterThan(0);
await expect.poll(async () => await page.locator('#ms_pdfname option').count()).toBeGreaterThan(1);
await expect.poll(async () => await page.locator('#ms_email_field option').count()).toBeGreaterThan(1);
await page.locator('#ms_subject').fill('Subject {{ contacts.id }}');
await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await page.evaluate(() => {
const html = '<style>.red-note{color:red;}</style><div class="red-note">Hello <strong>bold</strong> {{ contacts.id }}</div>';
const ed = window.tinymce && window.tinymce.get ? window.tinymce.get('ms-template-html') : null;
const raw = /** @type {HTMLTextAreaElement|null} */ (document.getElementById('ms-template-html'));
if (ed) {
ed.setContent(html);
ed.save();
} else if (raw) {
raw.value = html;
}
});
await page.locator('#ms-template-mode-code').click();
await expect(page.locator('#ms-template-code')).toHaveValue(/<style[^>]*>[\s\S]*\.red-note\s*\{[^}]*color:\s*red;?[^}]*\}[\s\S]*<\/style>/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/class=["']red-note["']/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/<strong>bold<\/strong>/);
await page.locator('#ms-template-mode-visual').click();
await page.locator('#ms-template-mode-code').click();
await expect(page.locator('#ms-template-code')).toHaveValue(/class=["']red-note["']/i);
await expect(page.locator('#ms-template-code')).not.toHaveValue(/data-mce-type=["']bookmark["']/i);
await expect(page.locator('#ms-template-code')).not.toHaveValue(/mce_SELRES_(?:start|end)/i);
await expect(page.locator('#ms-template-code')).not.toHaveValue(/<style[^>]*>[\s\S]*<span[\s\S]*<\/style>/i);
const formattedSource = `<style>
.red-note { color: red; }
</style>
<div class="red-note">
hello
</div>`;
await page.locator('#ms-template-code').fill(formattedSource);
await page.locator('#ms-template-mode-visual').click();
await page.locator('#ms-template-mode-code').click();
await expect(page.locator('#ms-template-code')).toHaveValue(/<style>\s*\n\s*\.red-note\s*\{\s*color:\s*red;\s*\}\s*\n<\/style>/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/\n\s*\n<div class="red-note">/i);
await expect(page.locator('#ms-template-code')).toHaveValue(/\n\s+hello\s*\n<\/div>/i);
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms_message')).toContainText('.red-note{color:red;}');
await expect(page.locator('#ms_message')).toContainText('class="red-note"');
await expect(page.locator('#ms_message')).toContainText('<strong>bold</strong>');
await page.locator('#ms-edit-pdf').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await page.locator('#ms-template-mode-code').click();
await page.locator('#ms-template-code').fill('<p>PDF {{ contacts.id }}</p>');
await page.locator('#ms-template-save').click();
await page.locator('#ms_email_field').evaluate((el) => {
const select = /** @type {HTMLSelectElement} */ (el);
const option = Array.from(select.options).find((o) => o.value && /email/i.test(o.value)) || Array.from(select.options).find((o) => o.value);
if (option) {
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByRole('cell', { name: purpose })).toBeVisible();
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
} catch {
// best effort
}
}
});

View File

@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { adminPath, apiPost, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
test('run/test pages: load and failure-path API assertions', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_run_ds_${uniq}`;
const purpose = `e2e_run_ms_${uniq}`;
try {
await ensureDataSource(request, dsName, 'contacts');
await ensureMailshot(request, {
Purpose: purpose,
DataSource: dsName,
CC: '',
BCC: '',
Subject: 'Subj {{ contacts.id }}',
Message: '<p>Hi {{ contacts.id }}</p>',
PDFAttachment: '<p>PDF</p>',
AttachmentNames: '[]',
PDFFilenameDerivedFrom: '',
RecipientEmailField: 'contacts.contact_email_1',
ReplyTo: ''
});
await page.goto(adminPath('feca-mailshots-run'));
await expect(page.getByRole('heading', { name: 'Run Mailshot' })).toBeVisible();
await page.goto(adminPath('feca-mailshots-test'));
await expect(page.getByRole('heading', { name: 'Mailshot Test' })).toBeVisible();
const runInvalid = await apiPost(request, 'feca_mailshots_run_api', 'run_mailshot', { mailshot_id: '0' });
expect(runInvalid.ok).toBeFalsy();
expect(String((runInvalid.errors || []).join(' | ') || runInvalid.error || '')).toContain('Missing mail credentials');
const sendBlank = await apiPost(request, 'feca_mailshots_test_api', 'send_test', {
mailshot_id: '1',
recipient_index: '0',
test_email: ''
});
expect(sendBlank.ok).toBeFalsy();
expect(String((sendBlank.errors || []).join(' | ') || sendBlank.error || '')).toContain('Test email address is required');
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
} catch {
// best effort
}
}
});

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
$ctx = require dirname(__DIR__) . '/bootstrap.php';
$wp = $ctx['wp'];
assert($wp instanceof FecaMailshots\WordPress\FixtureWordPressFacade);
$action = isset($_REQUEST['action']) ? (string) $_REQUEST['action'] : '';
if ($action === '') {
http_response_code(400);
echo 'Missing action';
exit;
}
$hook = 'admin_post_' . $action;
$wp->dispatch($hook);

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
$ctx = require dirname(__DIR__) . '/bootstrap.php';
$container = $ctx['container'];
$page = isset($_GET['page']) ? (string) $_GET['page'] : 'feca-mailshots-mailshots';
$map = [
'feca-mailshots-mailshots' => FecaMailshots\Admin\MailshotsAdminPage::class,
'feca-mailshot-data-sources' => FecaMailshots\Admin\DataSourcesAdminPage::class,
'feca-mailshots-attachments' => FecaMailshots\Admin\AttachmentsAdminPage::class,
'feca-mailshots-pdf-assets' => FecaMailshots\Admin\PdfAssetsAdminPage::class,
'feca-mailshots-setup' => FecaMailshots\Admin\SetupAdminPage::class,
'feca-mailshots-profile' => FecaMailshots\Admin\ProfileAdminPage::class,
'feca-mailshots-test' => FecaMailshots\Admin\MailshotTestAdminPage::class,
'feca-mailshots-run' => FecaMailshots\Admin\RunMailshotAdminPage::class,
];
if (!isset($map[$page])) {
http_response_code(404);
echo 'Unknown admin page';
exit;
}
$instance = $container->get($map[$page]);
$instance->render();

View File

@ -73,6 +73,26 @@ try {
} }
$queryId = (int) ($saveDs['id'] ?? 0); $queryId = (int) ($saveDs['id'] ?? 0);
$tokenProbe = $mailshotService->tokenInsertionData($queryName);
$recipientEmailField = '';
foreach (($tokenProbe['tokens'] ?? []) as $token) {
if (!is_array($token) || !isset($token['field'])) {
continue;
}
$field = (string) $token['field'];
if (stripos($field, 'email') !== false) {
$recipientEmailField = $field;
break;
}
if ($recipientEmailField === '') {
$recipientEmailField = $field;
}
}
if ($recipientEmailField === '') {
fwrite(STDERR, "Expected at least one token field for datasource\n");
exit(1);
}
$saveAtt = $attachmentService->save(null, [ $saveAtt = $attachmentService->save(null, [
'name' => 'phase3_att_' . $unique, 'name' => 'phase3_att_' . $unique,
'file_name' => 'terms.txt', 'file_name' => 'terms.txt',
@ -103,6 +123,7 @@ try {
$saveMailshot = $mailshotService->save(null, [ $saveMailshot = $mailshotService->save(null, [
'Purpose' => 'Phase3 Mailshot ' . $unique, 'Purpose' => 'Phase3 Mailshot ' . $unique,
'DataSource' => $queryName, 'DataSource' => $queryName,
'RecipientEmailField' => $recipientEmailField,
'Subject' => 'Subject {{ ' . strtolower($firstTable) . '_id }}', 'Subject' => 'Subject {{ ' . strtolower($firstTable) . '_id }}',
'Message' => '<p>Hello</p>', 'Message' => '<p>Hello</p>',
'AttachmentNames' => json_encode(['phase3_att_' . $unique], JSON_UNESCAPED_SLASHES), 'AttachmentNames' => json_encode(['phase3_att_' . $unique], JSON_UNESCAPED_SLASHES),

View File

@ -50,7 +50,7 @@ foreach ($cases as $case) {
if ($isValid) { if ($isValid) {
$compiled = $compiler->compile($ast); $compiled = $compiler->compile($ast);
if (strpos($compiled['sql'], 'SELECT * FROM') !== 0) { if (strpos($compiled['sql'], 'SELECT ') !== 0) {
$failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']]; $failures[] = ['dsl' => $case['dsl'], 'errors' => ['Compilation did not produce SELECT']];
} }
} }