final testing / documentation
This commit is contained in:
parent
4a667a8c35
commit
ff3dff9a9a
|
|
@ -3,7 +3,7 @@
|
|||
* Plugin Name: FECA Mailshots
|
||||
* Plugin URI: https://fenedge.co.uk/
|
||||
* Description: FECA mailshots plugin.
|
||||
* Version: 1.0.4
|
||||
* Version: 1.0.27
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: FECA
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ final class DataSourcesAdminPage
|
|||
echo 'var isDirty=false;';
|
||||
echo 'function confirmDiscard(){if(!isDirty){return true;}return window.confirm("You have unsaved changes. Close without saving?");}';
|
||||
echo 'if(editorForm){editorForm.querySelectorAll("input,select,textarea").forEach(function(el){el.addEventListener("input",function(){isDirty=true;});el.addEventListener("change",function(){isDirty=true;});});editorForm.addEventListener("submit",function(){isDirty=false;});}';
|
||||
echo 'var filters=[["selected-renewal","Renewal is selected","none"],["pending-renewal","Renewal is pending","none"],["primary-contact","Contact is primary","none"],["fen1-contact","Contact is FEN1","none"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council","none"],["selected","Advertiser is selected","none"],["page-in-issue","Page is in issue","issue"],["ad-in-issue","Advertiser has ad in issue","issue"],["pending-invoice","Invoice is pending","none"],["selected-invoice","Invoice is selected","none"],["invoice-ids","Invoice ID is one of","ids"]];';
|
||||
echo 'var filters=[["selected-renewal","Renewal is selected","none"],["pending-renewal","Renewal is pending","none"],["primary-contact","Contact is primary","none"],["fen1-contact","Contact is FEN1","none"],["member-or-affiliate-or-parish-council","Account is member/affiliate/parish council","none"],["account-has-article-in-issue","Account has article in issue","issue"],["selected","Advertiser is selected","none"],["issue","Issue is","issue"],["pending-invoice","Invoice is pending","none"],["selected-invoice","Invoice is selected","none"],["invoice-ids","Invoice ID is one of","ids"]];';
|
||||
echo 'function selectedSources(){var selected={};builtChecks.forEach(function(c){if(c.checked){selected[c.value]=true;}});customSources.forEach(function(v){selected[v]=true;});var s=[];sourceOrder.forEach(function(src){if(selected[src]&&s.indexOf(src)===-1){s.push(src);}});builtChecks.forEach(function(c){if(c.checked&&s.indexOf(c.value)===-1){s.push(c.value);}});customSources.forEach(function(v){if(s.indexOf(v)===-1){s.push(v);}});return s;}';
|
||||
echo 'function noteSourceSelected(src){if(src&&sourceOrder.indexOf(src)===-1){sourceOrder.push(src);}}';
|
||||
echo 'function noteSourceDeselected(src){sourceOrder=sourceOrder.filter(function(v){return v!==src;});}';
|
||||
|
|
@ -331,14 +331,18 @@ final class DataSourcesAdminPage
|
|||
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
|
||||
echo 'function filterArgType(name){for(var i=0;i<filters.length;i++){if(filters[i][0]===name){return filters[i][2]||"none";}}return "none";}';
|
||||
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
|
||||
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.className="feca-builder-row";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.className="feca-builder-filter-select";f.onchange=function(){row.filter=f.value;renderRows();updateDsl();};box.appendChild(f);var argType=filterArgType(row.filter);if(argType!=="none"){var arg=document.createElement("input");arg.type="text";arg.value=row.filterArg||"";arg.placeholder=argType==="ids"?"IDs, comma separated":"Issue ID";arg.oninput=function(){row.filterArg=arg.value;updateDsl();};box.appendChild(arg);}}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],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.className="feca-builder-row";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.className="feca-builder-filter-select";f.onchange=function(){row.filter=f.value;renderRows();updateDsl();};box.appendChild(f);var argType=filterArgType(row.filter);if(argType!=="none"){var arg=document.createElement("input");arg.type="text";arg.value=row.filterArg||"";arg.placeholder=argType==="ids"?"IDs, comma separated":"Issue ID";arg.oninput=function(){row.filterArg=arg.value;updateDsl();};box.appendChild(arg);}}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"],["blank","is blank"]],row.op);op.onchange=function(){row.op=op.value;renderRows();updateDsl();};box.appendChild(op);if(row.op==="blank"){rowsWrap.appendChild(box);return;}var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
|
||||
echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
|
||||
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}var argType=filterArgType(r.filter);if(argType==="none"){t=r.filter;}else{var raw=String(r.filterArg||"").trim();if(!raw){continue;}if(argType==="ids"){var ids=raw.split(",").map(function(v){return v.trim();}).filter(function(v){return /^\\d+$/.test(v);});if(ids.length===0){continue;}t=r.filter+"("+ids.join(", ")+")";}else{if(!/^\\d+$/.test(raw)){continue;}t=r.filter+"("+raw+")";}}}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=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 isBareIdent(v){var s=String(v||"");if(!s){return false;}var first=s.charAt(0);if(!((first>="A"&&first<="Z")||(first>="a"&&first<="z")||first==="_")){return false;}for(var i=1;i<s.length;i++){var ch=s.charAt(i);if(!((ch>="A"&&ch<="Z")||(ch>="a"&&ch<="z")||(ch>="0"&&ch<="9")||ch==="_"||ch==="-")){return false;}}return true;}';
|
||||
echo 'function ident(v){var s=String(v||"");return isBareIdent(s)?s:("`"+s.split("`").join("``")+"`");}';
|
||||
echo 'function unquoteIdent(v){var s=String(v||"");if(s.length>=2&&s.charAt(0)==="`"&&s.charAt(s.length-1)==="`"){return s.substring(1,s.length-1).split("``").join("`");}return s;}';
|
||||
echo 'function fieldRef(source,field){return ident(source)+"."+ident(field);}';
|
||||
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}var argType=filterArgType(r.filter);if(argType==="none"){t=r.filter;}else{var raw=String(r.filterArg||"").trim();if(!raw){continue;}if(argType==="ids"){var ids=raw.split(",").map(function(v){return v.trim();}).filter(function(v){return /^\\d+$/.test(v);});if(ids.length===0){continue;}t=r.filter+"("+ids.join(", ")+")";}else{if(!/^\\d+$/.test(raw)){continue;}t=r.filter+"("+raw+")";}}}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=fieldRef(r.lhsSource,r.lhsField);if(r.op==="blank"){t=lhs+" blank";}else if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=fieldRef(r.rhsSource,r.rhsField);if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}';
|
||||
echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];sourceOrder=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
|
||||
echo 'function splitAndTerms(text){var out=[];var cur="";var depth=0;var inQuote=false;for(var i=0;i<text.length;i++){var ch=text.charAt(i);if(ch==="\'"){inQuote=!inQuote;cur+=ch;continue;}if(!inQuote){if(ch==="("){depth++;cur+=ch;continue;}if(ch===")"){if(depth>0){depth--;}cur+=ch;continue;}if(depth===0&&text.substring(i,i+5)===" and "){if(cur.trim()){out.push(cur.trim());}cur="";i+=4;continue;}}cur+=ch;}if(cur.trim()){out.push(cur.trim());}return out;}';
|
||||
echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:s.substring(0,idx),field:s.substring(idx+1)};}';
|
||||
echo 'function splitFieldRef(text){var s=String(text||"").trim();var idx=s.lastIndexOf(".");if(idx<=0||idx>=s.length-1){return null;}return {source:unquoteIdent(s.substring(0,idx)),field:unquoteIdent(s.substring(idx+1))};}';
|
||||
echo 'function parseLiteralValue(text){var v=String(text||"").trim();if(!v){return null;}if(v.length>=2&&v.charAt(0)==="\'"&&v.charAt(v.length-1)==="\'"){return v.substring(1,v.length-1);}if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return null;}';
|
||||
echo 'function parseCompareTerm(term){var 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 parseCompareTerm(term){var rawTerm=String(term||"").trim();var bm=/^(.+?)\\s+blank$/i.exec(rawTerm);if(bm){var blankRef=splitFieldRef(bm[1]);if(!blankRef){return null;}return {kind:"compare",negate:false,filter:"selected-renewal",lhsSource:blankRef.source,lhsField:blankRef.field,op:"blank",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};}var m=/^(.+?)\\s+(=|!=|contains|starts-with|ends-with|in)\\s+(.+)$/.exec(rawTerm);if(!m){return null;}var lhsRef=splitFieldRef(m[1]);if(!lhsRef){return null;}var op=String(m[2]||"");var rhsRaw=String(m[3]||"").trim();if(!rhsRaw){return null;}var row={kind:"compare",negate:false,filter:"selected-renewal",lhsSource:lhsRef.source,lhsField:lhsRef.field,op:op,rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""};if(op==="in"){if(rhsRaw.charAt(0)!=="("||rhsRaw.charAt(rhsRaw.length-1)!==")"){return null;}rhsRaw=rhsRaw.substring(1,rhsRaw.length-1).trim();if(!rhsRaw){return null;}}var rhsRef=splitFieldRef(rhsRaw);if(rhsRef){row.rhsMode="field";row.rhsSource=rhsRef.source;row.rhsField=rhsRef.field;return row;}var literal=parseLiteralValue(rhsRaw);if(literal===null){return null;}row.rhsLiteral=literal;return row;}';
|
||||
echo 'function prefillFromDsl(){var text=(dslInput&&dslInput.value?dslInput.value:"").trim();if(!text){return;}var lower=text.toLowerCase();var whereIndex=lower.indexOf(" where ");var srcPart=whereIndex>=0?text.substring(0,whereIndex):text;var wherePart=whereIndex>=0?text.substring(whereIndex+7).trim():"";var bits=srcPart.split(" and ");sourceOrder=[];bits.map(function(v){return v.trim();}).forEach(function(src){if(!src){return;}noteSourceSelected(src);var built=false;builtChecks.forEach(function(c){if(c.value===src){c.checked=true;built=true;}});if(!built&&customSources.indexOf(src)===-1){customSources.push(src);}});constraints=[];if(wherePart){var known={};filters.forEach(function(f){known[f[0]]=true;});splitAndTerms(wherePart).forEach(function(term){var t=(term||"").trim();if(!t){return;}var negate=false;var lt=t.toLowerCase();if(lt.indexOf("not (")===0&&t.charAt(t.length-1)===")"){negate=true;t=t.substring(5,t.length-1).trim();}else if(lt.indexOf("not ")===0){negate=true;t=t.substring(4).trim();}if(known[t]){constraints.push({kind:"filter",negate:negate,filter:t,filterArg:"",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var fm=/^([a-z0-9_-]+)\\(([^)]*)\\)$/i.exec(t);if(fm&&known[fm[1]]){constraints.push({kind:"filter",negate:negate,filter:fm[1],filterArg:fm[2],lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});return;}var parsed=parseCompareTerm(t);if(parsed){parsed.negate=negate;constraints.push(parsed);}});}updateCustomList();renderRows();updateDsl();}';
|
||||
echo 'if(schemaSel){schemaSel.onchange=function(){if(schemaSel.value){fetchTables(schemaSel.value);}else{tableSel.innerHTML="<option value=\"\">Select table</option>";}};if(schemaSel.value){fetchTables(schemaSel.value);}}';
|
||||
echo 'if(addCustomBtn){addCustomBtn.onclick=function(){if(!schemaSel.value||!tableSel.value){return;}var src=schemaSel.value+"."+tableSel.value;if(customSources.indexOf(src)===-1){customSources.push(src);}noteSourceSelected(src);updateCustomList();renderRows();updateDsl();};}';
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ final class DownloadPdfAdminPage
|
|||
}
|
||||
|
||||
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
|
||||
$downloadBaseName = $this->downloadBaseNameForMailshot($mailshotId);
|
||||
|
||||
if ($format === 'zip') {
|
||||
try {
|
||||
|
|
@ -165,7 +166,7 @@ final class DownloadPdfAdminPage
|
|||
}
|
||||
|
||||
try {
|
||||
$this->sendFileDownload('mailshot_' . $mailshotId . '_pdfs.zip', 'application/zip', $zipPath);
|
||||
$this->sendFileDownload($downloadBaseName . '_pdfs.zip', 'application/zip', $zipPath);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($zipPath);
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP download failed: ' . $e->getMessage()]]);
|
||||
|
|
@ -194,7 +195,7 @@ final class DownloadPdfAdminPage
|
|||
return;
|
||||
}
|
||||
try {
|
||||
$this->sendBinaryDownload('mailshot_' . $mailshotId . '_merged.pdf', 'application/pdf', $bytes);
|
||||
$this->sendBinaryDownload($downloadBaseName . '_merged.pdf', 'application/pdf', $bytes);
|
||||
} catch (\Throwable $e) {
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF download failed: ' . $e->getMessage()]]);
|
||||
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
|
||||
|
|
@ -257,6 +258,46 @@ final class DownloadPdfAdminPage
|
|||
return ($this->mailshotServiceFactory)();
|
||||
}
|
||||
|
||||
private function downloadBaseNameForMailshot(int $mailshotId): string
|
||||
{
|
||||
try {
|
||||
foreach ($this->mailshotService()->list() as $mailshot) {
|
||||
if ((int) ($mailshot['id'] ?? 0) !== $mailshotId) {
|
||||
continue;
|
||||
}
|
||||
$purpose = trim((string) ($mailshot['Purpose'] ?? ''));
|
||||
if ($purpose !== '') {
|
||||
return $this->safeDownloadBaseName($purpose);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall back to a stable id-based name if the purpose cannot be loaded.
|
||||
}
|
||||
|
||||
return 'mailshot_' . max(0, $mailshotId);
|
||||
}
|
||||
|
||||
private function safeDownloadBaseName(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return 'mailshot';
|
||||
}
|
||||
|
||||
if (function_exists('iconv')) {
|
||||
$ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name);
|
||||
if (is_string($ascii) && trim($ascii) !== '') {
|
||||
$name = $ascii;
|
||||
}
|
||||
}
|
||||
|
||||
$name = preg_replace('/[^A-Za-z0-9._-]+/', '_', $name) ?? '';
|
||||
$name = preg_replace('/_+/', '_', $name) ?? '';
|
||||
$name = trim($name, '._-');
|
||||
|
||||
return $name !== '' ? $name : 'mailshot';
|
||||
}
|
||||
|
||||
private function maybeRaiseMemoryLimit(string $target): void
|
||||
{
|
||||
if (!function_exists('ini_get') || !function_exists('ini_set')) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ final class MailshotTestAdminPage
|
|||
private const RESULT_OPTION_KEY = 'feca_mailshots_test_ui_result';
|
||||
private const CAPABILITY = 'edit_pages';
|
||||
private const NONCE_ACTION = 'feca_mailshots_test';
|
||||
private const DOWNLOAD_MEMORY_LIMIT_ENV = 'FECA_MAILSHOTS_DOWNLOAD_MEMORY_LIMIT';
|
||||
private const DOWNLOAD_MEMORY_LIMIT_OPTION = 'feca_mailshots_download_memory_limit';
|
||||
|
||||
/** @var callable(): MailshotRunService */
|
||||
private $runServiceFactory;
|
||||
|
|
@ -54,8 +56,11 @@ final class MailshotTestAdminPage
|
|||
|
||||
$mailshots = $this->mailshotService()->list();
|
||||
$selectedMailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
if ($selectedMailshotId <= 0 && $mailshots !== []) {
|
||||
$selectedMailshotId = (int) ($mailshots[0]['id'] ?? 0);
|
||||
}
|
||||
|
||||
$preview = ['ok' => false, 'rows' => [], 'errors' => ['Select a mailshot.']];
|
||||
$preview = ['ok' => false, 'rows' => [], 'errors' => ['Create a mailshot before loading recipients.']];
|
||||
if ($selectedMailshotId > 0) {
|
||||
$preview = $this->runService()->previewRecipients($selectedMailshotId, 100);
|
||||
}
|
||||
|
|
@ -68,6 +73,19 @@ final class MailshotTestAdminPage
|
|||
$defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? '';
|
||||
$testEmail = (string) ($this->wp->requestParam('test_email', (string) $defaultEmail) ?? $defaultEmail);
|
||||
$result = $this->result();
|
||||
if ((string) ($this->wp->requestParam('render_test', '0') ?? '0') === '1') {
|
||||
if ($selectedRecipientIndex < 0) {
|
||||
$result = ['ok' => false, 'errors' => ['Choose a specific recipient row for Render Test.']];
|
||||
} else {
|
||||
try {
|
||||
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
|
||||
$result = $this->runService()->renderTest($selectedMailshotId, $selectedRecipientIndex);
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'errors' => ['Render test failed: ' . $e->getMessage()]];
|
||||
}
|
||||
}
|
||||
$result['ui_action'] = 'render';
|
||||
}
|
||||
|
||||
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
|
||||
|
||||
|
|
@ -100,7 +118,7 @@ final class MailshotTestAdminPage
|
|||
|
||||
echo '<div class="feca-panel">';
|
||||
echo '<h2 class="feca-section-title">1. Select Mailshot</h2>';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '">';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" id="mst-mailshot-select-form">';
|
||||
echo '<input type="hidden" name="page" value="feca-mailshots-test">';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
|
|
@ -116,16 +134,17 @@ final class MailshotTestAdminPage
|
|||
echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>';
|
||||
}
|
||||
echo '</select></div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button" type="submit">Load recipients</button></div>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
echo '<script>(function(){var select=document.getElementById("mst_mailshot_id");var form=document.getElementById("mst-mailshot-select-form");if(select&&form){select.addEventListener("change",function(){form.submit();});}})();</script>';
|
||||
echo '</div>';
|
||||
|
||||
if (!empty($preview['errors'])) {
|
||||
echo '<div class="feca-banner feca-banner-error">' . htmlspecialchars(implode('; ', $preview['errors'])) . '</div>';
|
||||
}
|
||||
|
||||
echo '<form method="post" action="' . $action . '" class="feca-form">';
|
||||
echo '<div id="mst-inline-result"></div>';
|
||||
echo '<form method="post" action="' . $action . '" class="feca-form" id="mst-action-form">';
|
||||
echo '<h2 class="feca-section-title">2. Render / Send Test</h2>';
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
|
||||
|
|
@ -153,10 +172,16 @@ final class MailshotTestAdminPage
|
|||
echo '<input id="mst_test_email" class="regular-text" type="email" name="test_email" value="' . htmlspecialchars($testEmail, ENT_QUOTES) . '">';
|
||||
echo '</div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button" type="submit" name="action" value="feca_mailshots_test_render_ui">Render Test (No Send)</button></div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" onclick="return window.fecaConfirmTestSend ? window.fecaConfirmTestSend() : confirm(\'Send one test email to the entered address?\');">Send Test Email</button></div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" id="mst-send-test-button" onclick="return window.fecaConfirmTestSend ? window.fecaConfirmTestSend() : confirm(\'Send one test email to the entered address?\');">Send Test Email</button></div>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
echo '<script>(function(){';
|
||||
echo 'var apiUrl=' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_test_api&op=send_test'), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
|
||||
echo 'var form=document.getElementById("mst-action-form");';
|
||||
echo 'var inlineResult=document.getElementById("mst-inline-result");';
|
||||
echo 'var sendButton=document.getElementById("mst-send-test-button");';
|
||||
echo 'function showResult(ok,title,lines){if(!inlineResult){return;}var wrap=document.createElement("div");wrap.className="feca-banner "+(ok?"feca-banner-success":"feca-banner-error");var strong=document.createElement("strong");strong.textContent=title;wrap.appendChild(strong);(lines||[]).forEach(function(line){var p=document.createElement("p");p.className="feca-banner-note";p.textContent=String(line||"");wrap.appendChild(p);});inlineResult.innerHTML="";inlineResult.appendChild(wrap);wrap.scrollIntoView({block:"nearest"});}';
|
||||
echo 'if(form){form.addEventListener("submit",function(ev){var submitter=ev.submitter;if(!submitter||String(submitter.value||"")!=="feca_mailshots_test_send_ui"){return;}ev.preventDefault();var payload=new URLSearchParams(new FormData(form));payload.delete("action");if(sendButton){sendButton.disabled=true;}showResult(true,"Sending test email...",["Rendering PDF attachment and sending message."]);fetch(apiUrl,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(response){return response.text().then(function(text){var data=null;try{data=JSON.parse(text);}catch(e){return {ok:false,errors:["Send test failed: server returned HTTP "+response.status+" instead of JSON. Check the WordPress/PHP error log for the underlying fatal error."]};}if(!response.ok&&data&&data.ok!==false){data.ok=false;}return data;});}).then(function(data){if(data&&data.ok){var lines=[];if(data.sent_to){lines.push("Sent to: "+data.sent_to);}if(data.sent_at){lines.push("Sent at: "+data.sent_at);}if(data.warnings&&data.warnings.length){lines=lines.concat(data.warnings.map(function(w){return "Warning: "+w;}));}showResult(true,"Test action succeeded.",lines.length?lines:["Sent."]);return;}var errors=(data&&data.errors&&data.errors.length)?data.errors:[(data&&data.error)?data.error:"Unknown send-test failure."];showResult(false,"Test action failed.",errors);}).catch(function(error){showResult(false,"Test action failed.",[error&&error.message?error.message:"Request failed."]);}).finally(function(){if(sendButton){sendButton.disabled=false;}});});}';
|
||||
echo 'window.fecaConfirmTestSend=function(){';
|
||||
echo 'var select=document.getElementById("mst_recipient_index");';
|
||||
echo 'if(!select){return confirm("Send one test email to the entered address?");}';
|
||||
|
|
@ -213,15 +238,21 @@ final class MailshotTestAdminPage
|
|||
echo '<p class="feca-modal-subtitle"><strong>Recipient:</strong> ' . htmlspecialchars($recipientText) . '</p>';
|
||||
echo '<p class="feca-modal-subtitle"><strong>Subject:</strong> ' . htmlspecialchars($subject) . '</p>';
|
||||
echo '<div class="feca-preview-grid">';
|
||||
echo '<div><h3 class="feca-modal-subtitle">Message (HTML)</h3><iframe sandbox="" class="feca-preview-iframe" srcdoc="' . htmlspecialchars($messageHtml, ENT_QUOTES) . '"></iframe></div>';
|
||||
echo '<div><h3 class="feca-modal-subtitle">PDF Attachment (HTML)</h3><iframe sandbox="" class="feca-preview-iframe" srcdoc="' . htmlspecialchars($pdfHtml, ENT_QUOTES) . '"></iframe></div>';
|
||||
echo '<div><h3 class="feca-modal-subtitle">Message (HTML)</h3><iframe sandbox="" class="feca-preview-iframe" id="ms-render-message-frame"></iframe></div>';
|
||||
echo '<div><h3 class="feca-modal-subtitle">PDF Attachment (HTML)</h3><iframe sandbox="" class="feca-preview-iframe" id="ms-render-pdf-frame"></iframe></div>';
|
||||
echo '</div>';
|
||||
echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="ms-close-render-preview">Close</button></p>';
|
||||
echo '</div></div>';
|
||||
echo '<script>(function(){';
|
||||
echo 'var messageHtml=' . json_encode($messageHtml, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
|
||||
echo 'var pdfHtml=' . json_encode($pdfHtml, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
|
||||
echo 'var modal=document.getElementById("ms-render-preview-modal");';
|
||||
echo 'var open=document.getElementById("ms-open-render-preview");';
|
||||
echo 'var close=document.getElementById("ms-close-render-preview");';
|
||||
echo 'var messageFrame=document.getElementById("ms-render-message-frame");';
|
||||
echo 'var pdfFrame=document.getElementById("ms-render-pdf-frame");';
|
||||
echo 'if(messageFrame){messageFrame.srcdoc=messageHtml;}';
|
||||
echo 'if(pdfFrame){pdfFrame.srcdoc=pdfHtml;}';
|
||||
echo 'if(!modal){return;}';
|
||||
echo 'var show=function(){modal.style.display="block";};';
|
||||
echo 'var hide=function(){modal.style.display="none";};';
|
||||
|
|
@ -255,6 +286,7 @@ final class MailshotTestAdminPage
|
|||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
return;
|
||||
}
|
||||
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
|
||||
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
if ($idx < 0) {
|
||||
|
|
@ -269,6 +301,7 @@ final class MailshotTestAdminPage
|
|||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
return;
|
||||
}
|
||||
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
|
||||
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
$to = (string) ($this->wp->requestParam('test_email', '') ?? '');
|
||||
|
|
@ -294,20 +327,11 @@ final class MailshotTestAdminPage
|
|||
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
if ($idx < 0) {
|
||||
$result = ['ok' => false, 'errors' => ['Choose a specific recipient row for Render Test.']];
|
||||
$result['ui_action'] = 'render';
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
|
||||
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''));
|
||||
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''), true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$result = $this->runService()->renderTest($mailshotId, $idx);
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'errors' => ['Render test failed: ' . $e->getMessage()]];
|
||||
}
|
||||
$result['ui_action'] = 'render';
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
|
||||
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''));
|
||||
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
|
||||
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''), true);
|
||||
}
|
||||
|
||||
public function handleSendUi(): void
|
||||
|
|
@ -319,6 +343,7 @@ final class MailshotTestAdminPage
|
|||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
$email = (string) ($this->wp->requestParam('test_email', '') ?? '');
|
||||
try {
|
||||
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
|
||||
if ($idx < 0) {
|
||||
$result = $this->runService()->sendTestAll($mailshotId, $email);
|
||||
} else {
|
||||
|
|
@ -333,9 +358,12 @@ final class MailshotTestAdminPage
|
|||
$this->redirect($mailshotId, $idx, $email);
|
||||
}
|
||||
|
||||
private function redirect(int $mailshotId, int $recipientIndex, string $testEmail): void
|
||||
private function redirect(int $mailshotId, int $recipientIndex, string $testEmail, bool $renderTest = false): void
|
||||
{
|
||||
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-test&mailshot_id=' . $mailshotId . '&recipient_index=' . $recipientIndex . '&test_email=' . rawurlencode($testEmail));
|
||||
if ($renderTest) {
|
||||
$url .= '&render_test=1';
|
||||
}
|
||||
if (!headers_sent()) {
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
|
|
@ -350,6 +378,72 @@ final class MailshotTestAdminPage
|
|||
return is_array($raw) ? $raw : null;
|
||||
}
|
||||
|
||||
private function maybeRaiseMemoryLimit(string $target): void
|
||||
{
|
||||
if ($target === '' || !function_exists('ini_get') || !function_exists('ini_set')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = (string) ini_get('memory_limit');
|
||||
$currentBytes = $this->memoryLimitToBytes($current);
|
||||
$targetBytes = $this->memoryLimitToBytes($target);
|
||||
|
||||
if ($currentBytes < 0 || $targetBytes <= 0 || $currentBytes >= $targetBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ini_set('memory_limit', $target);
|
||||
}
|
||||
|
||||
private function resolveDownloadMemoryLimitTarget(): string
|
||||
{
|
||||
$optionValue = $this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '');
|
||||
if (is_string($optionValue)) {
|
||||
$value = trim($optionValue);
|
||||
if ($this->memoryLimitToBytes($value) > 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
$envValue = getenv(self::DOWNLOAD_MEMORY_LIMIT_ENV);
|
||||
if (is_string($envValue)) {
|
||||
$value = trim($envValue);
|
||||
if ($this->memoryLimitToBytes($value) > 0) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function memoryLimitToBytes(string $limit): int
|
||||
{
|
||||
$value = trim($limit);
|
||||
if ($value === '') {
|
||||
return 0;
|
||||
}
|
||||
if ($value === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$unit = strtolower(substr($value, -1));
|
||||
if (ctype_alpha($unit)) {
|
||||
$number = (float) substr($value, 0, -1);
|
||||
switch ($unit) {
|
||||
case 'g':
|
||||
return (int) ($number * 1024 * 1024 * 1024);
|
||||
case 'm':
|
||||
return (int) ($number * 1024 * 1024);
|
||||
case 'k':
|
||||
return (int) ($number * 1024);
|
||||
default:
|
||||
return (int) $number;
|
||||
}
|
||||
}
|
||||
|
||||
return (int) $value;
|
||||
}
|
||||
|
||||
private function runService(): MailshotRunService
|
||||
{
|
||||
return ($this->runServiceFactory)();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ final class MailshotsAdminPage
|
|||
$this->wp->addAction('admin_post_feca_mailshots_mailshots_api', [$this, 'handleApi']);
|
||||
$this->wp->addAction('admin_post_feca_mailshots_mailshots_ui_save', [$this, 'handleUiSave']);
|
||||
$this->wp->addAction('admin_post_feca_mailshots_mailshots_ui_delete', [$this, 'handleUiDelete']);
|
||||
$this->wp->addAction('admin_post_feca_mailshots_mailshots_ui_duplicate', [$this, 'handleUiDuplicate']);
|
||||
}
|
||||
|
||||
public function registerMenu(): void
|
||||
|
|
@ -47,7 +48,15 @@ final class MailshotsAdminPage
|
|||
return;
|
||||
}
|
||||
|
||||
$items = $this->service()->list();
|
||||
$sortBy = (string) ($this->wp->requestParam('sort_by', 'purpose') ?? 'purpose');
|
||||
if (!in_array($sortBy, ['purpose', 'data_source', 'subject'], true)) {
|
||||
$sortBy = 'purpose';
|
||||
}
|
||||
$sortDirection = strtolower((string) ($this->wp->requestParam('sort_direction', 'asc') ?? 'asc'));
|
||||
if (!in_array($sortDirection, ['asc', 'desc'], true)) {
|
||||
$sortDirection = 'asc';
|
||||
}
|
||||
$items = $this->sortedMailshotItems($this->service()->list(), $sortBy, $sortDirection);
|
||||
$dataSources = $this->service()->dataSourceNames();
|
||||
sort($dataSources, SORT_NATURAL | SORT_FLAG_CASE);
|
||||
$attachmentNames = $this->service()->attachmentNames();
|
||||
|
|
@ -141,8 +150,14 @@ final class MailshotsAdminPage
|
|||
if ($result !== null && !$isSaveError && (!is_array($draft) || !empty($result['ok']))) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
|
||||
$successText = 'Mailshot saved.';
|
||||
if (($result['context'] ?? '') === 'mailshot_duplicate') {
|
||||
$successText = 'Mailshot duplicated.';
|
||||
} elseif (($result['context'] ?? '') === 'mailshot_delete') {
|
||||
$successText = 'Mailshot deleted.';
|
||||
}
|
||||
echo '<div class="feca-banner ' . $bannerClass . '">';
|
||||
echo '<strong>' . ($ok ? 'Mailshot saved.' : 'Mailshot action failed.') . '</strong>';
|
||||
echo '<strong>' . ($ok ? htmlspecialchars($successText) : 'Mailshot action failed.') . '</strong>';
|
||||
if (!empty($result['errors']) && is_array($result['errors'])) {
|
||||
echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
|
||||
}
|
||||
|
|
@ -157,6 +172,8 @@ final class MailshotsAdminPage
|
|||
echo $this->modalErrorHtml($result);
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
echo '<input type="hidden" name="sort_by" value="' . htmlspecialchars($sortBy, ENT_QUOTES) . '">';
|
||||
echo '<input type="hidden" name="sort_direction" value="' . htmlspecialchars($sortDirection, ENT_QUOTES) . '">';
|
||||
if ($editId > 0) {
|
||||
echo '<input type="hidden" name="id" value="' . $editId . '" id="ms-editor-id">';
|
||||
} else {
|
||||
|
|
@ -234,20 +251,57 @@ final class MailshotsAdminPage
|
|||
echo '</div></div>';
|
||||
|
||||
echo '<h2>Existing Mailshots</h2>';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" class="feca-form">';
|
||||
echo '<input type="hidden" name="page" value="feca-mailshots-mailshots">';
|
||||
if ($editId > 0) {
|
||||
echo '<input type="hidden" name="edit_id" value="' . $editId . '">';
|
||||
}
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-260">';
|
||||
echo '<label for="ms_sort_by"><strong>Sort by</strong></label>';
|
||||
echo '<select id="ms_sort_by" name="sort_by">';
|
||||
foreach (['purpose' => 'Purpose', 'data_source' => 'Data Source', 'subject' => 'Subject'] as $value => $label) {
|
||||
$selected = $sortBy === $value ? ' selected' : '';
|
||||
echo '<option value="' . htmlspecialchars($value, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($label) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
echo '</div>';
|
||||
echo '<div class="feca-control feca-control-min-180">';
|
||||
echo '<label for="ms_sort_direction"><strong>Direction</strong></label>';
|
||||
echo '<select id="ms_sort_direction" name="sort_direction">';
|
||||
foreach (['asc' => 'Ascending', 'desc' => 'Descending'] as $value => $label) {
|
||||
$selected = $sortDirection === $value ? ' selected' : '';
|
||||
echo '<option value="' . htmlspecialchars($value, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($label) . '</option>';
|
||||
}
|
||||
echo '</select>';
|
||||
echo '</div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button" type="submit">Apply</button></div>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
|
||||
echo '<table class="widefat striped"><thead><tr><th>Purpose</th><th>Data Source</th><th>Subject</th><th>Actions</th></tr></thead><tbody>';
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $id);
|
||||
$editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $id . '&sort_by=' . rawurlencode($sortBy) . '&sort_direction=' . rawurlencode($sortDirection));
|
||||
echo '<tr>';
|
||||
echo '<td class="feca-truncate-220">' . htmlspecialchars((string) ($row['Purpose'] ?? '')) . '</td>';
|
||||
echo '<td class="feca-truncate-220">' . htmlspecialchars((string) ($row['DataSource'] ?? '')) . '</td>';
|
||||
echo '<td class="feca-truncate-360">' . htmlspecialchars((string) ($row['Subject'] ?? '')) . '</td>';
|
||||
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
|
||||
echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_duplicate">';
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
echo '<input type="hidden" name="id" value="' . $id . '">';
|
||||
echo '<input type="hidden" name="sort_by" value="' . htmlspecialchars($sortBy, ENT_QUOTES) . '">';
|
||||
echo '<input type="hidden" name="sort_direction" value="' . htmlspecialchars($sortDirection, ENT_QUOTES) . '">';
|
||||
echo '<button type="submit" class="button button-small">Duplicate</button>';
|
||||
echo '</form> ';
|
||||
echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_delete">';
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
echo '<input type="hidden" name="id" value="' . $id . '">';
|
||||
echo '<input type="hidden" name="sort_by" value="' . htmlspecialchars($sortBy, ENT_QUOTES) . '">';
|
||||
echo '<input type="hidden" name="sort_direction" value="' . htmlspecialchars($sortDirection, ENT_QUOTES) . '">';
|
||||
echo '<button type="submit" class="button button-small feca-button-danger" onclick="return confirm(\'Delete this mailshot?\');">Delete</button>';
|
||||
echo '</form></td>';
|
||||
echo '</tr>';
|
||||
|
|
@ -347,6 +401,20 @@ final class MailshotsAdminPage
|
|||
text = text.replace(/fenedgec_members_/gi, "");
|
||||
return text.trim();
|
||||
};
|
||||
var tokenFormatOptions = [
|
||||
{ value: "", label: "Raw value" },
|
||||
{ value: "date(\'l j F Y\')", label: "Long Date" },
|
||||
{ value: "date(\'j M Y\')", label: "Short Date" },
|
||||
{ value: "date(\'Y-m-d\')", label: "ISO Date" }
|
||||
];
|
||||
var twigTokenFor = function (tokenData, format) {
|
||||
var name = String((tokenData && tokenData.token_name) || "").trim();
|
||||
if (!name) {
|
||||
return String((tokenData && tokenData.token) || "");
|
||||
}
|
||||
var fmt = String(format || "").trim();
|
||||
return "{{ " + name + (fmt ? " | " + fmt : "") + " }}";
|
||||
};
|
||||
var stripHtml = function (html) {
|
||||
var s = String(html || "");
|
||||
s = s.replace(/<style[\\s\\S]*?<\\/style>/gi, " ");
|
||||
|
|
@ -500,25 +568,43 @@ final class MailshotsAdminPage
|
|||
if (!currentTokens || currentTokens.length === 0) {
|
||||
return;
|
||||
}
|
||||
var chooser = document.createElement("span");
|
||||
chooser.className = "feca-token-format-controls";
|
||||
chooser.style.display = "inline-block";
|
||||
chooser.style.marginRight = "8px";
|
||||
chooser.style.marginBottom = "6px";
|
||||
var sel = document.createElement("select");
|
||||
sel.innerHTML = "<option value=\\"\\">Select field token</option>";
|
||||
currentTokens.forEach(function (t, idx) {
|
||||
var label = cleanTokenLabel(String((t && (t.field || t.token)) || ""));
|
||||
var o = document.createElement("option");
|
||||
o.value = String(idx);
|
||||
o.textContent = label || String((t && t.token) || "");
|
||||
sel.appendChild(o);
|
||||
});
|
||||
var formatSel = document.createElement("select");
|
||||
tokenFormatOptions.forEach(function (fmt) {
|
||||
var o = document.createElement("option");
|
||||
o.value = fmt.value;
|
||||
o.textContent = fmt.label;
|
||||
formatSel.appendChild(o);
|
||||
});
|
||||
formatSel.style.marginLeft = "6px";
|
||||
var btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "button button-small";
|
||||
btn.textContent = "Insert";
|
||||
btn.style.marginLeft = "6px";
|
||||
btn.addEventListener("click", function () {
|
||||
var idx = parseInt(sel.value || "-1", 10);
|
||||
if (idx < 0 || !currentTokens[idx]) { return; }
|
||||
insertToken(twigTokenFor(currentTokens[idx], formatSel.value || ""));
|
||||
});
|
||||
chooser.appendChild(sel);
|
||||
chooser.appendChild(formatSel);
|
||||
chooser.appendChild(btn);
|
||||
container.appendChild(chooser);
|
||||
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) {
|
||||
|
|
@ -845,7 +931,7 @@ final class MailshotsAdminPage
|
|||
if (!empty($result['ok']) && isset($result['id'])) {
|
||||
$editId = (int) $result['id'];
|
||||
}
|
||||
$url = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots' . ($editId !== null && $editId > 0 ? '&edit_id=' . $editId : ''));
|
||||
$url = $this->mailshotsPageUrl($editId !== null ? (int) $editId : 0);
|
||||
if (!headers_sent()) {
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
|
|
@ -866,9 +952,34 @@ final class MailshotsAdminPage
|
|||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'errors' => [$e->getMessage()], 'context' => 'mailshot_delete'];
|
||||
}
|
||||
$result['context'] = 'mailshot_delete';
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
|
||||
if (!headers_sent()) {
|
||||
header('Location: ' . $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots'), true, 302);
|
||||
header('Location: ' . $this->mailshotsPageUrl(), true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function handleUiDuplicate(): void
|
||||
{
|
||||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
return;
|
||||
}
|
||||
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
|
||||
$result = ['ok' => false, 'errors' => ['Mailshot ID is required.'], 'context' => 'mailshot_duplicate'];
|
||||
try {
|
||||
if ($id > 0) {
|
||||
$result = $this->service()->duplicate($id);
|
||||
$result['context'] = 'mailshot_duplicate';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'errors' => [$e->getMessage()], 'context' => 'mailshot_duplicate'];
|
||||
}
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
|
||||
$editId = !empty($result['ok']) && isset($result['id']) ? (int) $result['id'] : 0;
|
||||
$url = $this->mailshotsPageUrl($editId);
|
||||
if (!headers_sent()) {
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
|
@ -953,6 +1064,15 @@ final class MailshotsAdminPage
|
|||
return;
|
||||
}
|
||||
|
||||
if ($op === 'duplicate') {
|
||||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
return;
|
||||
}
|
||||
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
|
||||
$this->wp->sendJson($this->service()->duplicate($id));
|
||||
return;
|
||||
}
|
||||
|
||||
$this->wp->sendJson(['ok' => false, 'error' => 'Unknown operation'], 400);
|
||||
} catch (\Throwable $e) {
|
||||
$this->wp->sendJson(['ok' => false, 'error' => $e->getMessage()], 500);
|
||||
|
|
@ -964,6 +1084,46 @@ final class MailshotsAdminPage
|
|||
return ($this->serviceFactory)();
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items @return list<array<string,mixed>> */
|
||||
private function sortedMailshotItems(array $items, string $sortBy, string $sortDirection): array
|
||||
{
|
||||
$fieldMap = [
|
||||
'purpose' => 'Purpose',
|
||||
'data_source' => 'DataSource',
|
||||
'subject' => 'Subject',
|
||||
];
|
||||
$field = $fieldMap[$sortBy] ?? 'Purpose';
|
||||
$direction = $sortDirection === 'desc' ? -1 : 1;
|
||||
usort($items, static function (array $a, array $b) use ($field, $direction): int {
|
||||
$left = trim((string) ($a[$field] ?? ''));
|
||||
$right = trim((string) ($b[$field] ?? ''));
|
||||
$cmp = strnatcasecmp($left, $right);
|
||||
if ($cmp === 0) {
|
||||
$cmp = ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||
}
|
||||
return $cmp * $direction;
|
||||
});
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function mailshotsPageUrl(int $editId = 0): string
|
||||
{
|
||||
$sortBy = (string) ($this->wp->requestParam('sort_by', 'purpose') ?? 'purpose');
|
||||
if (!in_array($sortBy, ['purpose', 'data_source', 'subject'], true)) {
|
||||
$sortBy = 'purpose';
|
||||
}
|
||||
$sortDirection = strtolower((string) ($this->wp->requestParam('sort_direction', 'asc') ?? 'asc'));
|
||||
if (!in_array($sortDirection, ['asc', 'desc'], true)) {
|
||||
$sortDirection = 'asc';
|
||||
}
|
||||
|
||||
$url = 'admin.php?page=feca-mailshots-mailshots&sort_by=' . rawurlencode($sortBy) . '&sort_direction=' . rawurlencode($sortDirection);
|
||||
if ($editId > 0) {
|
||||
$url .= '&edit_id=' . $editId;
|
||||
}
|
||||
return $this->wp->adminUrl($url);
|
||||
}
|
||||
|
||||
private function cleanTokenLabel(string $value): string
|
||||
{
|
||||
$label = trim($value);
|
||||
|
|
|
|||
|
|
@ -16,10 +16,16 @@ final class SetupAdminPage
|
|||
private const NONCE_ACTION = 'feca_mailshots_setup';
|
||||
|
||||
private WordPressFacade $wp;
|
||||
/** @var list<string> */
|
||||
private array $startupErrors;
|
||||
private bool $registerRootMenu;
|
||||
|
||||
public function __construct(WordPressFacade $wp)
|
||||
/** @param list<string> $startupErrors */
|
||||
public function __construct(WordPressFacade $wp, array $startupErrors = [], bool $registerRootMenu = false)
|
||||
{
|
||||
$this->wp = $wp;
|
||||
$this->startupErrors = array_values(array_map(static fn($line): string => (string) $line, $startupErrors));
|
||||
$this->registerRootMenu = $registerRootMenu;
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
|
|
@ -31,6 +37,9 @@ final class SetupAdminPage
|
|||
|
||||
public function registerMenu(): void
|
||||
{
|
||||
if ($this->registerRootMenu) {
|
||||
$this->wp->addMenuPage('FECA Mailshots', 'FECA Mailshots', 'manage_options', 'feca-mailshot', [$this, 'render']);
|
||||
}
|
||||
$this->wp->addSubmenuPage('feca-mailshot', 'Setup', 'Setup', 'manage_options', 'feca-mailshots-setup', [$this, 'render']);
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +62,16 @@ final class SetupAdminPage
|
|||
if ($status !== '') {
|
||||
echo '<div class="feca-banner feca-banner-success">' . htmlspecialchars($status) . '</div>';
|
||||
}
|
||||
if ($this->startupErrors !== []) {
|
||||
echo '<div class="feca-banner feca-banner-error">';
|
||||
echo '<strong>' . htmlspecialchars('Setup is incomplete.') . '</strong>';
|
||||
echo '<ul>';
|
||||
foreach ($this->startupErrors as $line) {
|
||||
echo '<li>' . htmlspecialchars($line) . '</li>';
|
||||
}
|
||||
echo '</ul>';
|
||||
echo '</div>';
|
||||
}
|
||||
if ($test !== null) {
|
||||
$ok = !empty($test['ok']);
|
||||
$title = $ok ? 'Connection test passed.' : 'Connection test failed.';
|
||||
|
|
|
|||
|
|
@ -28,33 +28,34 @@ final class DslCompiler
|
|||
|
||||
$from = $this->quoteSource($sources[0]) . ' AS ' . $this->alias($sources[0]);
|
||||
$joins = [];
|
||||
$hasIssueContextFilter = $this->hasConstrainingIssueFilter($ast['where']);
|
||||
|
||||
for ($i = 1; $i < count($sources); $i++) {
|
||||
$left = $sources[$i - 1];
|
||||
$right = $sources[$i];
|
||||
$path = $this->metadata->joinPath($left, $right);
|
||||
$join = $this->findPriorJoinPath(array_slice($sources, 0, $i), $right);
|
||||
$path = $join['path'];
|
||||
if ($path === null) {
|
||||
throw new AppError('dsl_compile', sprintf('No join path between %s and %s', $left, $right));
|
||||
if ($this->canAppendIssueContext($right, $hasIssueContextFilter)) {
|
||||
$joins[] = 'CROSS JOIN ' . $this->quoteSource($right) . ' AS ' . $this->alias($right);
|
||||
continue;
|
||||
}
|
||||
throw new AppError('dsl_compile', sprintf('No join path to %s', $right));
|
||||
}
|
||||
|
||||
[$leftRef, $rightRef] = $this->rewriteJoinRef($path['left'], $path['right']);
|
||||
if ($this->isNormalizedJoin($left, $right)) {
|
||||
$leftRef = 'LOWER(TRIM(COALESCE(' . $leftRef . ', \'\')))';
|
||||
$rightRef = 'LOWER(TRIM(COALESCE(' . $rightRef . ', \'\')))';
|
||||
if ($this->isNormalizedJoin($join['left'], $right)) {
|
||||
$leftRef = $this->normalizedTextSql($leftRef);
|
||||
$rightRef = $this->normalizedTextSql($rightRef);
|
||||
}
|
||||
$joins[] = 'INNER JOIN ' . $this->quoteSource($right) . ' AS ' . $this->alias($right)
|
||||
. ' ON ' . $leftRef . ' = ' . $rightRef;
|
||||
}
|
||||
|
||||
if (in_array('accounts', $sources, true)) {
|
||||
$acc = $this->alias('accounts');
|
||||
$joins[] = 'LEFT JOIN `picklist_account_type` AS `p_account_type` ON `p_account_type`.`id` = ' . $acc . '.`account_type_id`';
|
||||
$joins[] = 'LEFT JOIN `picklist_public_location` AS `p_public_location` ON `p_public_location`.`id` = ' . $acc . '.`public_location_id`';
|
||||
$joins[] = 'LEFT JOIN `picklist_sector` AS `p_sector` ON `p_sector`.`id` = ' . $acc . '.`sector_id`';
|
||||
}
|
||||
|
||||
$params = [];
|
||||
$whereParts = [];
|
||||
foreach ($this->compileAutomaticSourcePredicates($sources) as $predicateSql) {
|
||||
$whereParts[] = $predicateSql;
|
||||
}
|
||||
foreach ($ast['where'] as $predicate) {
|
||||
$whereParts[] = $this->compilePredicate($predicate, $params, $sources);
|
||||
}
|
||||
|
|
@ -94,6 +95,13 @@ final class DslCompiler
|
|||
$lhs = $this->fieldRefSql($predicate['lhs']);
|
||||
$op = $predicate['op'];
|
||||
$value = $predicate['rhs'];
|
||||
if (is_array($value) && ($value['type'] ?? '') === 'blank_literal') {
|
||||
if (!in_array($op, ['=', '!='], true)) {
|
||||
throw new AppError('dsl_compile', 'Blank comparison supports only = and !=');
|
||||
}
|
||||
$sql = $this->compileBlankSql($lhs, $op === '!=');
|
||||
return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql;
|
||||
}
|
||||
if ($op === 'contains') {
|
||||
$op = 'LIKE';
|
||||
$value = '%' . $value . '%';
|
||||
|
|
@ -109,6 +117,12 @@ final class DslCompiler
|
|||
return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql;
|
||||
}
|
||||
|
||||
if ($predicate['type'] === 'blank') {
|
||||
$lhs = $this->fieldRefSql($predicate['lhs']);
|
||||
$sql = $this->compileBlankSql($lhs, false);
|
||||
return $predicate['not'] ? 'NOT (' . $sql . ')' : $sql;
|
||||
}
|
||||
|
||||
if ($predicate['type'] === 'comparison_field') {
|
||||
$lhs = $this->fieldRefSql($predicate['lhs']);
|
||||
$rhs = $this->fieldRefSql($predicate['rhs']);
|
||||
|
|
@ -139,6 +153,14 @@ final class DslCompiler
|
|||
throw new AppError('dsl_compile', 'Unknown predicate type', ['type' => $predicate['type']]);
|
||||
}
|
||||
|
||||
private function compileBlankSql(string $fieldSql, bool $notBlank): string
|
||||
{
|
||||
if ($notBlank) {
|
||||
return '(' . $fieldSql . ' IS NOT NULL AND ' . $fieldSql . " <> '')";
|
||||
}
|
||||
return '(' . $fieldSql . ' IS NULL OR ' . $fieldSql . " = '')";
|
||||
}
|
||||
|
||||
/** @param list<mixed> $args @param list<mixed> &$params @param list<string> $sources */
|
||||
private function compileFilter(string $name, array $args, array &$params, array $sources): string
|
||||
{
|
||||
|
|
@ -170,17 +192,8 @@ final class DslCompiler
|
|||
}
|
||||
return $this->alias('invoices') . '.`id` IN (' . implode(',', array_fill(0, count($ids), '?')) . ')';
|
||||
}
|
||||
if ($name === 'page-in-issue') {
|
||||
$issueId = $this->singleNumericFilterArg($name, $args);
|
||||
$params[] = $issueId;
|
||||
if (in_array('pages', $sources, true)) {
|
||||
return $this->alias('pages') . '.`Issue` = ?';
|
||||
}
|
||||
$pagesTable = $this->quoteSource('pages');
|
||||
return 'EXISTS (SELECT 1 FROM ' . $pagesTable . ' AS p_filter WHERE p_filter.`ID` = ' . $this->alias('articles') . '.`PageID` AND p_filter.`Issue` = ?)';
|
||||
}
|
||||
if ($name === 'ad-in-issue') {
|
||||
return $this->compileAdInIssueFilter($this->singleNumericFilterArg($name, $args), $params, $sources);
|
||||
if ($name === 'issue') {
|
||||
return $this->compileIssueFilter($this->singleNumericFilterArg($name, $args), $params, $sources);
|
||||
}
|
||||
if ($name === 'member-or-affiliate-or-parish-council') {
|
||||
$acc = $this->alias('accounts');
|
||||
|
|
@ -190,6 +203,9 @@ final class DslCompiler
|
|||
$acc
|
||||
);
|
||||
}
|
||||
if ($name === 'account-has-article-in-issue') {
|
||||
return $this->compileAccountHasArticleInIssueFilter($this->singleNumericFilterArg($name, $args), $params);
|
||||
}
|
||||
|
||||
throw new AppError('dsl_compile', 'Unknown filter', ['filter' => $name]);
|
||||
}
|
||||
|
|
@ -209,12 +225,6 @@ final class DslCompiler
|
|||
/** @param array{source:string,field:string} $fieldRef */
|
||||
private function fieldRefSql(array $fieldRef): string
|
||||
{
|
||||
if ($fieldRef['source'] === 'accounts') {
|
||||
$virtual = $this->accountsVirtualFieldSql($fieldRef['field']);
|
||||
if ($virtual !== null) {
|
||||
return $virtual;
|
||||
}
|
||||
}
|
||||
$sql = $this->metadata->fieldSql($fieldRef['source'], $fieldRef['field'], $this->alias($fieldRef['source']));
|
||||
if ($sql === null) {
|
||||
throw new AppError('dsl_compile', 'Unknown field', ['source' => $fieldRef['source'], 'field' => $fieldRef['field']]);
|
||||
|
|
@ -244,13 +254,6 @@ final class DslCompiler
|
|||
foreach ($sources as $source) {
|
||||
$alias = $this->alias($source);
|
||||
foreach ($this->metadata->sourceFields($source) as $field) {
|
||||
if ($source === 'accounts') {
|
||||
$virtual = $this->accountsVirtualFieldSql($field);
|
||||
if ($virtual !== null) {
|
||||
$parts[] = $virtual . ' AS `' . $source . '.' . $field . '`';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$fieldSql = $this->metadata->fieldSql($source, $field, $alias);
|
||||
if ($fieldSql === null) {
|
||||
throw new AppError('dsl_compile', 'Unknown field', ['source' => $source, 'field' => $field]);
|
||||
|
|
@ -264,11 +267,80 @@ final class DslCompiler
|
|||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
/** @param list<string> $priorSources @return array{left:string,path:?array{left:string,right:string}} */
|
||||
private function findPriorJoinPath(array $priorSources, string $right): array
|
||||
{
|
||||
for ($i = count($priorSources) - 1; $i >= 0; $i--) {
|
||||
$left = $priorSources[$i];
|
||||
$path = $this->metadata->joinPath($left, $right);
|
||||
if ($path !== null) {
|
||||
return ['left' => $left, 'path' => $path];
|
||||
}
|
||||
}
|
||||
return ['left' => '', 'path' => null];
|
||||
}
|
||||
|
||||
private function isNormalizedJoin(string $left, string $right): bool
|
||||
{
|
||||
return ($left === 'ads' && $right === 'advertisers') || ($left === 'advertisers' && $right === 'ads');
|
||||
}
|
||||
|
||||
/** @param list<string> $sources @return list<string> */
|
||||
private function compileAutomaticSourcePredicates(array $sources): array
|
||||
{
|
||||
$predicates = [];
|
||||
foreach ($sources as $source) {
|
||||
if (!in_array($source, ['accounts', 'contacts'], true)) {
|
||||
continue;
|
||||
}
|
||||
$deletedField = $this->findSourceField($source, 'is_deleted');
|
||||
if ($deletedField === null) {
|
||||
continue;
|
||||
}
|
||||
$deletedSql = $this->metadata->fieldSql($source, $deletedField, $this->alias($source));
|
||||
if ($deletedSql === null) {
|
||||
continue;
|
||||
}
|
||||
$predicates[] = '(' . $deletedSql . ' IS NULL OR ' . $deletedSql . ' + 0 = 0)';
|
||||
}
|
||||
return $predicates;
|
||||
}
|
||||
|
||||
private function findSourceField(string $source, string $needle): ?string
|
||||
{
|
||||
foreach ($this->metadata->sourceFields($source) as $field) {
|
||||
if (strcasecmp($field, $needle) === 0) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function canAppendIssueContext(string $rightSource, bool $hasIssueContextFilter): bool
|
||||
{
|
||||
return $rightSource === 'issues' && $hasIssueContextFilter;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $predicates */
|
||||
private function hasConstrainingIssueFilter(array $predicates, bool $negated = false): bool
|
||||
{
|
||||
foreach ($predicates as $predicate) {
|
||||
$predicateNegated = $negated || !empty($predicate['not']);
|
||||
if (($predicate['type'] ?? '') === 'filter'
|
||||
&& ($predicate['name'] ?? '') === 'issue'
|
||||
&& !$predicateNegated
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (($predicate['type'] ?? '') === 'group'
|
||||
&& $this->hasConstrainingIssueFilter(is_array($predicate['items'] ?? null) ? $predicate['items'] : [], $predicateNegated)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param list<mixed> $args */
|
||||
private function singleNumericFilterArg(string $name, array $args): int
|
||||
{
|
||||
|
|
@ -296,38 +368,47 @@ final class DslCompiler
|
|||
}
|
||||
|
||||
/** @param list<mixed> &$params @param list<string> $sources */
|
||||
private function compileAdInIssueFilter(int $issueId, array &$params, array $sources): string
|
||||
private function compileIssueFilter(int $issueId, array &$params, array $sources): string
|
||||
{
|
||||
$fenAds = $this->quoteSource('ads');
|
||||
$fenPages = $this->quoteSource('pages');
|
||||
$params[] = $issueId;
|
||||
if (in_array('pages', $sources, true)) {
|
||||
return $this->alias('pages') . '.`Issue` = ?';
|
||||
}
|
||||
if (in_array('issues', $sources, true)) {
|
||||
return $this->alias('issues') . '.`ID` = ?';
|
||||
}
|
||||
if (in_array('articles', $sources, true)) {
|
||||
return 'EXISTS (SELECT 1 FROM ' . $fenPages . ' AS p_article_issue WHERE p_article_issue.`ID` = ' . $this->alias('articles') . '.`PageID` AND p_article_issue.`Issue` = ?)';
|
||||
}
|
||||
if (in_array('ads', $sources, true)) {
|
||||
return 'EXISTS (SELECT 1 FROM ' . $fenPages . ' AS p_ad_issue WHERE p_ad_issue.`ID` = ' . $this->alias('ads') . '.`PageID` AND p_ad_issue.`Issue` = ?)';
|
||||
}
|
||||
if (in_array('pages', $sources, true)) {
|
||||
return 'EXISTS (SELECT 1 FROM ' . $fenAds . ' AS ad_page_issue WHERE ad_page_issue.`PageID` = ' . $this->alias('pages') . '.`ID`) AND ' . $this->alias('pages') . '.`Issue` = ?';
|
||||
}
|
||||
if (in_array('advertisers', $sources, true)) {
|
||||
return 'EXISTS (SELECT 1 FROM ' . $fenAds . ' AS ad_adv_issue INNER JOIN ' . $fenPages . ' AS p_adv_issue ON p_adv_issue.`ID` = ad_adv_issue.`PageID` WHERE LOWER(TRIM(COALESCE(ad_adv_issue.`Advertiser`, \'\'))) = LOWER(TRIM(COALESCE(' . $this->alias('advertisers') . '.`AdvertiserName`, \'\'))) AND p_adv_issue.`Issue` = ?)';
|
||||
}
|
||||
if (in_array('issues', $sources, true)) {
|
||||
return $this->alias('issues') . '.`ID` = ? AND EXISTS (SELECT 1 FROM ' . $fenPages . ' AS p_issue_ad INNER JOIN ' . $fenAds . ' AS ad_issue ON ad_issue.`PageID` = p_issue_ad.`ID` WHERE p_issue_ad.`Issue` = ' . $this->alias('issues') . '.`ID`)';
|
||||
return 'EXISTS (SELECT 1 FROM ' . $fenAds . ' AS ad_adv_issue INNER JOIN ' . $fenPages . ' AS p_adv_issue ON p_adv_issue.`ID` = ad_adv_issue.`PageID` WHERE ' . $this->normalizedTextSql('ad_adv_issue.`Advertiser`') . ' = ' . $this->normalizedTextSql($this->alias('advertisers') . '.`AdvertiserName`') . ' AND p_adv_issue.`Issue` = ?)';
|
||||
}
|
||||
return $this->alias('invoices') . '.`issue_id` = ?';
|
||||
}
|
||||
|
||||
private function accountsVirtualFieldSql(string $field): ?string
|
||||
/** @param list<mixed> &$params */
|
||||
private function compileAccountHasArticleInIssueFilter(int $issueId, array &$params): string
|
||||
{
|
||||
$name = strtolower(trim($field));
|
||||
if ($name === 'type') {
|
||||
return '`p_account_type`.`value`';
|
||||
$accountNameField = $this->findSourceField('accounts', 'name');
|
||||
$accountNameSql = $accountNameField === null ? null : $this->metadata->fieldSql('accounts', $accountNameField, $this->alias('accounts'));
|
||||
if ($accountNameSql === null) {
|
||||
throw new AppError('dsl_compile', 'Unknown field', ['source' => 'accounts', 'field' => 'name']);
|
||||
}
|
||||
if ($name === 'public_location') {
|
||||
return '`p_public_location`.`value`';
|
||||
}
|
||||
if ($name === 'account_sector') {
|
||||
return '`p_sector`.`value`';
|
||||
}
|
||||
return null;
|
||||
|
||||
$params[] = $issueId;
|
||||
return 'EXISTS (SELECT 1 FROM ' . $this->quoteSource('articles') . ' AS article_account_issue'
|
||||
. ' INNER JOIN ' . $this->quoteSource('pages') . ' AS page_account_issue ON page_account_issue.`ID` = article_account_issue.`PageID`'
|
||||
. ' WHERE ' . $this->normalizedTextSql('article_account_issue.`MemberName`') . ' = ' . $this->normalizedTextSql($accountNameSql)
|
||||
. ' AND page_account_issue.`Issue` = ?)';
|
||||
}
|
||||
|
||||
private function normalizedTextSql(string $sql): string
|
||||
{
|
||||
return 'LOWER(TRIM(CONVERT(COALESCE(' . $sql . ', \'\') USING utf8mb4) COLLATE utf8mb4_unicode_ci))';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ final class DslValidator
|
|||
'primary-contact' => ['contacts'],
|
||||
'fen1-contact' => ['contacts'],
|
||||
'selected' => ['advertisers'],
|
||||
'page-in-issue' => ['pages|articles'],
|
||||
'ad-in-issue' => ['advertisers|ads|pages|issues|invoices'],
|
||||
'issue' => ['advertisers|ads|pages|articles|issues|invoices'],
|
||||
'pending-invoice' => ['invoices'],
|
||||
'selected-invoice' => ['invoices'],
|
||||
'invoice-ids' => ['invoices'],
|
||||
'member-or-affiliate-or-parish-council' => ['accounts'],
|
||||
'account-has-article-in-issue' => ['accounts'],
|
||||
];
|
||||
|
||||
public function __construct(SourceMetadataProvider $metadata)
|
||||
|
|
@ -63,9 +63,12 @@ final class DslValidator
|
|||
$errors[] = 'Mixing built-in and custom sources is not supported in v1.5.';
|
||||
}
|
||||
|
||||
$hasIssueContextFilter = $this->hasConstrainingIssueFilter($ast['where']);
|
||||
for ($i = 1; $i < count($sources); $i++) {
|
||||
if ($this->metadata->joinPath($sources[$i - 1], $sources[$i]) === null) {
|
||||
$errors[] = sprintf('No approved join path between %s and %s', $sources[$i - 1], $sources[$i]);
|
||||
if (!$this->hasPriorJoinPath(array_slice($sources, 0, $i), $sources[$i])
|
||||
&& !$this->canAppendIssueContext($sources[$i], $hasIssueContextFilter)
|
||||
) {
|
||||
$errors[] = sprintf('No approved join path to %s', $sources[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,6 +141,14 @@ final class DslValidator
|
|||
if ($predicate['type'] === 'comparison_field' && in_array($predicate['op'], ['contains', 'starts-with', 'ends-with'], true)) {
|
||||
$errors[] = $predicate['op'] . ' requires a literal right-hand value';
|
||||
}
|
||||
|
||||
if ($predicate['type'] === 'comparison_value'
|
||||
&& is_array($predicate['rhs'] ?? null)
|
||||
&& ($predicate['rhs']['type'] ?? '') === 'blank_literal'
|
||||
&& !in_array($predicate['op'], ['=', '!='], true)
|
||||
) {
|
||||
$errors[] = 'Blank comparison supports only = and !=';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> &$errors */
|
||||
|
|
@ -151,7 +162,7 @@ final class DslValidator
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (in_array($name, ['page-in-issue', 'ad-in-issue'], true)) {
|
||||
if (in_array($name, ['issue', 'account-has-article-in-issue'], true)) {
|
||||
if (count($args) !== 1 || !is_int($args[0])) {
|
||||
$errors[] = sprintf('Filter %s requires exactly one numeric issue ID', $name);
|
||||
}
|
||||
|
|
@ -175,7 +186,7 @@ final class DslValidator
|
|||
private function validateFieldRef(array $fieldRef, array $sources, array &$errors): void
|
||||
{
|
||||
$source = $fieldRef['source'];
|
||||
if (!in_array($source, $sources, true) && !str_contains($source, '.')) {
|
||||
if (!in_array($source, $sources, true)) {
|
||||
$errors[] = 'Field source is not in selected sources: ' . $source;
|
||||
return;
|
||||
}
|
||||
|
|
@ -201,4 +212,40 @@ final class DslValidator
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function canAppendIssueContext(string $rightSource, bool $hasIssueContextFilter): bool
|
||||
{
|
||||
return $rightSource === 'issues' && $hasIssueContextFilter;
|
||||
}
|
||||
|
||||
/** @param list<string> $priorSources */
|
||||
private function hasPriorJoinPath(array $priorSources, string $rightSource): bool
|
||||
{
|
||||
for ($i = count($priorSources) - 1; $i >= 0; $i--) {
|
||||
if ($this->metadata->joinPath($priorSources[$i], $rightSource) !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $predicates */
|
||||
private function hasConstrainingIssueFilter(array $predicates, bool $negated = false): bool
|
||||
{
|
||||
foreach ($predicates as $predicate) {
|
||||
$predicateNegated = $negated || !empty($predicate['not']);
|
||||
if (($predicate['type'] ?? '') === 'filter'
|
||||
&& ($predicate['name'] ?? '') === 'issue'
|
||||
&& !$predicateNegated
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (($predicate['type'] ?? '') === 'group'
|
||||
&& $this->hasConstrainingIssueFilter(is_array($predicate['items'] ?? null) ? $predicate['items'] : [], $predicateNegated)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -942,8 +942,8 @@ final class MailshotRunService
|
|||
throw new \RuntimeException('Dompdf is not available. Ensure vendor dependencies are installed.');
|
||||
}
|
||||
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($pdfHtml, 'UTF-8');
|
||||
$dompdf = $this->createDompdf();
|
||||
$dompdf->loadHtml($this->normalizePdfHtmlForDompdf($pdfHtml), 'UTF-8');
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
$pdfBytes = $dompdf->output();
|
||||
|
|
@ -966,8 +966,8 @@ final class MailshotRunService
|
|||
throw new \RuntimeException('Merged HTML file is empty or unreadable.');
|
||||
}
|
||||
|
||||
$dompdf = new \Dompdf\Dompdf();
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf = $this->createDompdf();
|
||||
$dompdf->loadHtml($this->normalizePdfHtmlForDompdf($html), 'UTF-8');
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
$pdfBytes = $dompdf->output();
|
||||
|
|
@ -977,6 +977,29 @@ final class MailshotRunService
|
|||
return $pdfBytes;
|
||||
}
|
||||
|
||||
private function createDompdf(): \Dompdf\Dompdf
|
||||
{
|
||||
$options = new \Dompdf\Options();
|
||||
$options->set('defaultFont', 'DejaVu Sans');
|
||||
$options->set('isFontSubsettingEnabled', true);
|
||||
return new \Dompdf\Dompdf($options);
|
||||
}
|
||||
|
||||
private function normalizePdfHtmlForDompdf(string $html): string
|
||||
{
|
||||
return strtr($html, [
|
||||
"\u{2709}" => $this->dompdfSymbolSpan("\u{2709}"),
|
||||
"\u{1F4E7}" => $this->dompdfSymbolSpan("\u{2709}"),
|
||||
"\u{260E}" => $this->dompdfSymbolSpan("\u{260E}"),
|
||||
"\u{1F4DE}" => $this->dompdfSymbolSpan("\u{260E}"),
|
||||
]);
|
||||
}
|
||||
|
||||
private function dompdfSymbolSpan(string $symbol): string
|
||||
{
|
||||
return '<span style="font-family:DejaVu Sans,sans-serif;">' . $symbol . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $pdfPaths
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -115,6 +115,32 @@ final class MailshotService
|
|||
$this->mailshots->delete($id);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function duplicate(int $id): array
|
||||
{
|
||||
$source = $this->mailshots->find($id);
|
||||
if ($source === null) {
|
||||
return ['ok' => false, 'errors' => ['Mailshot not found.']];
|
||||
}
|
||||
|
||||
$row = [
|
||||
'Purpose' => $this->duplicatePurpose((string) ($source['Purpose'] ?? ''), $id),
|
||||
'DataSource' => (string) ($source['DataSource'] ?? ''),
|
||||
'CC' => (string) ($source['CC'] ?? ''),
|
||||
'BCC' => (string) ($source['BCC'] ?? ''),
|
||||
'Subject' => (string) ($source['Subject'] ?? ''),
|
||||
'Message' => (string) ($source['Message'] ?? ''),
|
||||
'PDFAttachment' => (string) ($source['PDFAttachment'] ?? ''),
|
||||
'AttachmentNames' => (string) ($source['AttachmentNames'] ?? '[]'),
|
||||
'PDFFilenameDerivedFrom' => (string) ($source['PDFFilenameDerivedFrom'] ?? ''),
|
||||
'ReplyTo' => (string) ($source['ReplyTo'] ?? ''),
|
||||
'RecipientEmailField' => (string) ($source['RecipientEmailField'] ?? ''),
|
||||
];
|
||||
|
||||
$newId = $this->mailshots->create($row);
|
||||
return ['ok' => true, 'id' => $newId];
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
public function lastRun(int $mailshotId): array
|
||||
{
|
||||
|
|
@ -126,6 +152,44 @@ final class MailshotService
|
|||
$this->lastRun->clearForMailshot($mailshotId);
|
||||
}
|
||||
|
||||
private function duplicatePurpose(string $purpose, int $sourceId): string
|
||||
{
|
||||
$base = trim($purpose);
|
||||
if ($base === '') {
|
||||
$base = 'Mailshot #' . $sourceId;
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
foreach ($this->mailshots->all() as $row) {
|
||||
$existing[strtolower(trim((string) ($row['Purpose'] ?? '')))] = true;
|
||||
}
|
||||
|
||||
$prefix = 'Copy of ';
|
||||
$candidate = $this->truncatePurpose($prefix . $base);
|
||||
if (!isset($existing[strtolower($candidate)])) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
for ($i = 2; $i < 1000; $i++) {
|
||||
$suffix = ' (' . $i . ')';
|
||||
$candidate = $this->truncatePurpose($prefix . $base, $suffix);
|
||||
if (!isset($existing[strtolower($candidate)])) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->truncatePurpose($prefix . $base, ' (' . time() . ')');
|
||||
}
|
||||
|
||||
private function truncatePurpose(string $value, string $suffix = ''): string
|
||||
{
|
||||
$max = 100;
|
||||
$suffixLength = strlen($suffix);
|
||||
$baseMax = max(1, $max - $suffixLength);
|
||||
$base = substr($value, 0, $baseMax);
|
||||
return rtrim($base) . $suffix;
|
||||
}
|
||||
|
||||
/** @return array{tokens:list<array<string, string>>, errors:list<string>} */
|
||||
public function tokenInsertionData(string $dataSourceName): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ final class TemplateRenderer
|
|||
|
||||
$dataUri = 'data:' . $mimeType . ';base64,' . base64_encode($bytes);
|
||||
$imgStyle = 'width:' . $this->mm($widthMm) . ';height:' . $this->mm($heightMm) . ';';
|
||||
if ($justification === 'right') {
|
||||
$imgStyle .= 'float:right;';
|
||||
}
|
||||
$alt = htmlspecialchars($fileName !== '' ? $fileName : $name, ENT_QUOTES);
|
||||
$src = htmlspecialchars($dataUri, ENT_QUOTES);
|
||||
$img = '<img src="' . $src . '" alt="' . $alt . '" style="' . $imgStyle . '">';
|
||||
|
|
@ -99,6 +102,9 @@ final class TemplateRenderer
|
|||
if ($justification === 'in-place') {
|
||||
return $img;
|
||||
}
|
||||
if ($justification === 'right') {
|
||||
return $img;
|
||||
}
|
||||
return '<div style="display:block;text-align:' . $justification . ';">' . $img . '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +113,8 @@ final class TemplateRenderer
|
|||
{
|
||||
$out = $row;
|
||||
foreach ($row as $key => $value) {
|
||||
$value = $this->formatValueForTemplate((string) $key, $value);
|
||||
$out[$key] = $value;
|
||||
$lower = strtolower((string) $key);
|
||||
$canon = preg_replace('/[^a-z0-9_]+/', '_', $lower);
|
||||
if (!is_string($canon)) {
|
||||
|
|
@ -124,6 +132,71 @@ final class TemplateRenderer
|
|||
return $out;
|
||||
}
|
||||
|
||||
/** @param mixed $value @return mixed */
|
||||
private function formatValueForTemplate(string $key, $value)
|
||||
{
|
||||
if (!$this->looksLikeBooleanField($key)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$bool = $this->coerceBooleanishValue($value);
|
||||
if ($bool === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $bool ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
private function looksLikeBooleanField(string $key): bool
|
||||
{
|
||||
$field = strtolower((string) preg_replace('/[^a-z0-9]+/', '', strtolower(basename(str_replace('.', '/', $key)))));
|
||||
if ($field === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_starts_with($field, 'is')
|
||||
|| str_starts_with($field, 'has')
|
||||
|| str_starts_with($field, 'can')
|
||||
|| in_array($field, ['selected', 'active', 'enabled', 'disabled', 'lapsed'], true);
|
||||
}
|
||||
|
||||
/** @param mixed $value */
|
||||
private function coerceBooleanishValue($value): ?bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
if ((float) $value === 0.0) {
|
||||
return false;
|
||||
}
|
||||
if (in_array((int) $value, [1, -1], true)) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value === "\0") {
|
||||
return false;
|
||||
}
|
||||
if ($value === "\1" || $value === "\xff") {
|
||||
return true;
|
||||
}
|
||||
|
||||
$normal = strtolower(trim($value));
|
||||
if (in_array($normal, ['0', 'false', 'no', 'n', 'off'], true)) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($normal, ['1', '-1', 'true', 'yes', 'y', 'on'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function mm(float $value): string
|
||||
{
|
||||
$formatted = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ final class DslLexer
|
|||
$lower = strtolower($value);
|
||||
$keywords = [
|
||||
'and', 'where', 'not', 'in', 'contains', 'starts-with', 'ends-with',
|
||||
'true', 'false',
|
||||
'true', 'false', 'blank',
|
||||
];
|
||||
$tokens[] = in_array($lower, $keywords, true)
|
||||
? new DslToken('KW', $lower, $start)
|
||||
|
|
|
|||
|
|
@ -125,6 +125,10 @@ final class DslParser
|
|||
return ['type' => 'set_values', 'not' => $negated, 'lhs' => $lhs, 'values' => $rhsValues, 'op' => 'not in'];
|
||||
}
|
||||
|
||||
if ($this->matchKw('blank')) {
|
||||
return ['type' => 'blank', 'not' => $negated, 'lhs' => $lhs];
|
||||
}
|
||||
|
||||
$opToken = $this->peek();
|
||||
$op = '';
|
||||
if ($opToken->type === 'OP') {
|
||||
|
|
@ -149,6 +153,10 @@ final class DslParser
|
|||
{
|
||||
$this->expect('.');
|
||||
$field = $this->expect('IDENT')->value;
|
||||
if ($this->match('.')) {
|
||||
$ident .= '.' . $field;
|
||||
$field = $this->expect('IDENT')->value;
|
||||
}
|
||||
return ['source' => strtolower($ident), 'field' => $field];
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +166,10 @@ final class DslParser
|
|||
$src = strtolower($this->expect('IDENT')->value);
|
||||
$this->expect('.');
|
||||
$field = $this->expect('IDENT')->value;
|
||||
if ($this->match('.')) {
|
||||
$src .= '.' . strtolower($field);
|
||||
$field = $this->expect('IDENT')->value;
|
||||
}
|
||||
return ['source' => $src, 'field' => $field];
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +196,9 @@ final class DslParser
|
|||
if ($token->type === 'KW' && ($token->value === 'true' || $token->value === 'false')) {
|
||||
return $token->value === 'true';
|
||||
}
|
||||
if ($token->type === 'KW' && $token->value === 'blank') {
|
||||
return ['type' => 'blank_literal'];
|
||||
}
|
||||
|
||||
throw new AppError('dsl_parse', 'Expected literal value', ['offset' => $token->offset]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,10 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
{
|
||||
private DatabaseRouter $router;
|
||||
|
||||
/** @var array<string, list<string>> */
|
||||
private array $builtInFields;
|
||||
/** @var list<string> */
|
||||
private array $builtInSources;
|
||||
/** @var array<string, string> */
|
||||
private array $builtInTables;
|
||||
/** @var array<string, array<string, string>> */
|
||||
private array $fieldMap;
|
||||
/** @var array<string, list<string>> */
|
||||
private array $sourceFieldsCache = [];
|
||||
|
||||
|
|
@ -26,18 +24,7 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
{
|
||||
$this->router = $router;
|
||||
$fen = $router->fenDbName();
|
||||
$this->builtInFields = [
|
||||
'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', 'account_type_id'],
|
||||
'renewals' => ['id', 'account_id', 'status', 'selected', 'email'],
|
||||
'grants' => ['id', 'account_id', 'name', 'amount', 'applied_for', 'total_cost', 'grant_date', 'notes'],
|
||||
'advertisers' => ['name', 'advertisername'],
|
||||
'ads' => ['id', 'advertiser', 'adsize', 'size', 'price', 'issue', 'pageid', 'state', 'notes'],
|
||||
'pages' => ['id', 'issue', 'page'],
|
||||
'articles' => ['id', 'pageid', 'articlenumber', 'article', 'content', 'membername', 'author', 'dcn', 'articlewords', 'otherwords', 'othercontent', 'other'],
|
||||
'issues' => ['id', 'issue', 'issuemonths', 'description'],
|
||||
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'invoice_date', 'due_date', 'invoice_page', 'invoice_size', 'invoice_price', 'status', 'payment_date', 'amount_paid', 'payment_method', 'payment_reference', 'notes', 'created_at', 'updated_at'],
|
||||
];
|
||||
$this->builtInSources = ['contacts', 'accounts', 'renewals', 'grants', 'advertisers', 'ads', 'pages', 'articles', 'issues', 'invoices'];
|
||||
|
||||
$this->builtInTables = [
|
||||
'contacts' => 'contacts',
|
||||
|
|
@ -52,68 +39,6 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
'invoices' => $fen . '.invoices',
|
||||
];
|
||||
|
||||
$this->fieldMap = [
|
||||
'advertisers' => [
|
||||
'name' => 'AdvertiserName',
|
||||
'advertisername' => 'AdvertiserName',
|
||||
],
|
||||
'ads' => [
|
||||
'id' => 'ID',
|
||||
'advertiser' => 'Advertiser',
|
||||
'adsize' => 'AdSize',
|
||||
'size' => 'AdSize',
|
||||
'price' => 'Price',
|
||||
'pageid' => 'PageID',
|
||||
'state' => 'State',
|
||||
'notes' => 'Notes',
|
||||
],
|
||||
'pages' => [
|
||||
'id' => 'ID',
|
||||
'issue' => 'Issue',
|
||||
'page' => 'Page',
|
||||
],
|
||||
'articles' => [
|
||||
'id' => 'ID',
|
||||
'pageid' => 'PageID',
|
||||
'articlenumber' => 'ArticleNumber',
|
||||
'article' => 'ArticleNumber',
|
||||
'content' => 'Content',
|
||||
'membername' => 'MemberName',
|
||||
'author' => 'Author',
|
||||
'dcn' => 'DCN',
|
||||
'articlewords' => 'ArticleWords',
|
||||
'otherwords' => 'OtherWords',
|
||||
'othercontent' => 'OtherContent',
|
||||
'other' => 'OtherContent',
|
||||
],
|
||||
'issues' => [
|
||||
'id' => 'ID',
|
||||
'issue' => 'ID',
|
||||
'issuemonths' => 'IssueMonths',
|
||||
'description' => 'Description',
|
||||
],
|
||||
'invoices' => [
|
||||
'id' => 'id',
|
||||
'issue' => 'issue_id',
|
||||
'issue_id' => 'issue_id',
|
||||
'ad_id' => 'ad_id',
|
||||
'invoice_number' => 'invoice_number',
|
||||
'invoice_date' => 'invoice_date',
|
||||
'due_date' => 'due_date',
|
||||
'invoice_page' => 'invoice_page',
|
||||
'invoice_size' => 'invoice_size',
|
||||
'invoice_price' => 'invoice_price',
|
||||
'status' => 'status',
|
||||
'payment_date' => 'payment_date',
|
||||
'amount_paid' => 'amount_paid',
|
||||
'payment_method' => 'payment_method',
|
||||
'payment_reference' => 'payment_reference',
|
||||
'notes' => 'notes',
|
||||
'created_at' => 'created_at',
|
||||
'updated_at' => 'updated_at',
|
||||
],
|
||||
];
|
||||
|
||||
$this->joinMap = [
|
||||
'contacts|accounts' => ['left' => 'contacts.account_id', 'right' => 'accounts.id'],
|
||||
'accounts|contacts' => ['left' => 'accounts.id', 'right' => 'contacts.account_id'],
|
||||
|
|
@ -142,7 +67,7 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
|
||||
public function sourceExists(string $source): bool
|
||||
{
|
||||
if (isset($this->builtInFields[$source])) {
|
||||
if (in_array($source, $this->builtInSources, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -151,27 +76,20 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
}
|
||||
|
||||
[$schema, $table] = explode('.', $source, 2);
|
||||
$show = 'SHOW TABLES FROM `' . str_replace('`', '``', $schema) . '` LIKE ?';
|
||||
$stmt = $this->router->membersPdo()->prepare($show);
|
||||
$stmt->execute([$table]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
$sql = 'SHOW FULL TABLES FROM `' . str_replace('`', '``', $schema) . '`';
|
||||
$rows = $this->router->membersPdo()->query($sql)->fetchAll(\PDO::FETCH_NUM);
|
||||
foreach ($rows as $row) {
|
||||
if (isset($row[0]) && strcasecmp((string) $row[0], $table) === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function sourceFields(string $source): array
|
||||
{
|
||||
if (isset($this->builtInFields[$source])) {
|
||||
if (isset($this->fieldMap[$source])) {
|
||||
return $this->builtInFields[$source];
|
||||
}
|
||||
$fields = $this->loadFieldsFromTable($this->builtInTables[$source] ?? $source);
|
||||
if ($source === 'accounts') {
|
||||
foreach (['type', 'public_location', 'account_sector'] as $virtualField) {
|
||||
if (!in_array($virtualField, $fields, true)) {
|
||||
$fields[] = $virtualField;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $fields;
|
||||
if (in_array($source, $this->builtInSources, true)) {
|
||||
return $this->loadFieldsFromTable($this->builtInTables[$source] ?? $source);
|
||||
}
|
||||
|
||||
if (!str_contains($source, '.')) {
|
||||
|
|
@ -198,7 +116,7 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
|
||||
public function allKnownSources(): array
|
||||
{
|
||||
return array_keys($this->builtInFields);
|
||||
return $this->builtInSources;
|
||||
}
|
||||
|
||||
public function sourceTable(string $source): ?string
|
||||
|
|
@ -209,18 +127,30 @@ final class DatabaseSourceMetadataProvider implements SourceMetadataProvider
|
|||
public function fieldSql(string $source, string $field, string $alias): ?string
|
||||
{
|
||||
$source = trim($source);
|
||||
$fieldKey = strtolower(trim($field));
|
||||
if ($source === 'ads' && $fieldKey === 'issue') {
|
||||
$pagesTable = $this->quoteSource($this->builtInTables['pages']);
|
||||
return '(SELECT p_issue.`Issue` FROM ' . $pagesTable . ' AS p_issue WHERE p_issue.`ID` = ' . $alias . '.`PageID`)';
|
||||
}
|
||||
if (isset($this->fieldMap[$source])) {
|
||||
$column = $this->fieldMap[$source][$fieldKey] ?? null;
|
||||
return $column === null ? null : $alias . '.`' . str_replace('`', '``', $column) . '`';
|
||||
if (in_array($source, $this->builtInSources, true)) {
|
||||
$column = $this->physicalColumnName($source, $field);
|
||||
if ($column === null) {
|
||||
return null;
|
||||
}
|
||||
return $alias . '.`' . str_replace('`', '``', $column) . '`';
|
||||
}
|
||||
return $alias . '.`' . str_replace('`', '``', $field) . '`';
|
||||
}
|
||||
|
||||
private function physicalColumnName(string $source, string $field): ?string
|
||||
{
|
||||
$table = $this->builtInTables[$source] ?? null;
|
||||
if ($table === null) {
|
||||
return null;
|
||||
}
|
||||
foreach ($this->loadFieldsFromTable($table) as $physicalField) {
|
||||
if (strcasecmp($physicalField, $field) === 0) {
|
||||
return $physicalField;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function loadFieldsFromTable(string $tableSql): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FecaMailshots\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class MailshotSchemaInstaller
|
||||
{
|
||||
private DatabaseRouter $router;
|
||||
|
||||
public function __construct(DatabaseRouter $router)
|
||||
{
|
||||
$this->router = $router;
|
||||
}
|
||||
|
||||
public function install(): void
|
||||
{
|
||||
$pdo = $this->router->mailshotsPdo();
|
||||
foreach ($this->createTableSql() as $sql) {
|
||||
$pdo->exec($sql);
|
||||
}
|
||||
|
||||
$this->ensureMailshotsColumns($pdo);
|
||||
$this->ensureMailshotQueriesColumns($pdo);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function createTableSql(): array
|
||||
{
|
||||
return [
|
||||
"CREATE TABLE IF NOT EXISTS mailshots (
|
||||
id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
Purpose VARCHAR(100) DEFAULT NULL,
|
||||
DataSource VARCHAR(100) NOT NULL,
|
||||
CC VARCHAR(100) DEFAULT NULL,
|
||||
BCC VARCHAR(100) DEFAULT NULL,
|
||||
Subject LONGTEXT NOT NULL,
|
||||
Message LONGTEXT NOT NULL,
|
||||
PDFAttachment LONGTEXT DEFAULT NULL,
|
||||
AttachmentNames LONGTEXT DEFAULT NULL,
|
||||
PDFFilenameDerivedFrom VARCHAR(255) DEFAULT NULL,
|
||||
ReplyTo VARCHAR(100) DEFAULT NULL,
|
||||
RecipientEmailField VARCHAR(255) DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci",
|
||||
"CREATE TABLE IF NOT EXISTS mailshot_queries (
|
||||
ID INT(11) NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
dsl_text LONGTEXT DEFAULT NULL,
|
||||
dsl_version VARCHAR(16) NOT NULL DEFAULT 'v1',
|
||||
`sql` LONGTEXT NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (ID),
|
||||
UNIQUE KEY mailshot_queries_name_uk (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci",
|
||||
"CREATE TABLE IF NOT EXISTS mailshot_attachments (
|
||||
id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(255) NOT NULL,
|
||||
file_bytes LONGBLOB NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY mailshot_attachments_name_uk (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci",
|
||||
"CREATE TABLE IF NOT EXISTS mailshot_pdf_assets (
|
||||
id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
file_bytes LONGBLOB NOT NULL,
|
||||
width_mm DECIMAL(8,2) NOT NULL,
|
||||
height_mm DECIMAL(8,2) NOT NULL,
|
||||
justification ENUM('left','right','in-place') NOT NULL DEFAULT 'in-place',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY mailshot_pdf_assets_name_uk (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci",
|
||||
"CREATE TABLE IF NOT EXISTS mailshot_last_run (
|
||||
id INT(11) NOT NULL AUTO_INCREMENT,
|
||||
mailshot_id INT(11) NOT NULL,
|
||||
data_source VARCHAR(100) DEFAULT NULL,
|
||||
row_index INT(11) NOT NULL DEFAULT 0,
|
||||
recipient_key VARCHAR(255) DEFAULT NULL,
|
||||
recipient_key_field VARCHAR(64) DEFAULT NULL,
|
||||
recipient_email_last VARCHAR(255) DEFAULT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
error_message LONGTEXT DEFAULT NULL,
|
||||
warning_message LONGTEXT DEFAULT NULL,
|
||||
attempt_count INT(11) NOT NULL DEFAULT 1,
|
||||
run_started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_attempt_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY mailshot_last_run_mailshot_idx (mailshot_id),
|
||||
KEY mailshot_last_run_status_idx (status),
|
||||
KEY mailshot_last_run_recipient_idx (recipient_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci",
|
||||
"CREATE TABLE IF NOT EXISTS mailshot_credentials (
|
||||
wp_user_id BIGINT UNSIGNED NOT NULL,
|
||||
smtp_host VARCHAR(255) NOT NULL,
|
||||
smtp_port INT NOT NULL,
|
||||
smtp_user VARCHAR(255) NOT NULL,
|
||||
smtp_password_enc LONGTEXT NOT NULL,
|
||||
smtp_from_email VARCHAR(255) NOT NULL,
|
||||
smtp_from_name VARCHAR(255) DEFAULT NULL,
|
||||
smtp_require_tls TINYINT(1) NOT NULL DEFAULT 0,
|
||||
imap_host VARCHAR(255) DEFAULT NULL,
|
||||
imap_port INT DEFAULT 993,
|
||||
imap_user VARCHAR(255) DEFAULT NULL,
|
||||
imap_password_enc LONGTEXT DEFAULT NULL,
|
||||
imap_sent_folder VARCHAR(255) DEFAULT 'Sent',
|
||||
imap_mailbox_flags VARCHAR(64) DEFAULT '/imap/ssl',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (wp_user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci",
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureMailshotsColumns(PDO $pdo): void
|
||||
{
|
||||
$this->ensureColumn($pdo, 'mailshots', 'AttachmentNames', 'ALTER TABLE mailshots ADD COLUMN AttachmentNames LONGTEXT DEFAULT NULL AFTER PDFAttachment');
|
||||
$this->ensureColumn($pdo, 'mailshots', 'PDFFilenameDerivedFrom', 'ALTER TABLE mailshots ADD COLUMN PDFFilenameDerivedFrom VARCHAR(255) DEFAULT NULL AFTER AttachmentNames');
|
||||
$this->ensureColumn($pdo, 'mailshots', 'ReplyTo', 'ALTER TABLE mailshots ADD COLUMN ReplyTo VARCHAR(100) DEFAULT NULL AFTER PDFFilenameDerivedFrom');
|
||||
$this->ensureColumn($pdo, 'mailshots', 'RecipientEmailField', 'ALTER TABLE mailshots ADD COLUMN RecipientEmailField VARCHAR(255) DEFAULT NULL AFTER ReplyTo');
|
||||
}
|
||||
|
||||
private function ensureMailshotQueriesColumns(PDO $pdo): void
|
||||
{
|
||||
$this->ensureColumn($pdo, 'mailshot_queries', 'dsl_version', "ALTER TABLE mailshot_queries ADD COLUMN dsl_version VARCHAR(16) NOT NULL DEFAULT 'v1' AFTER dsl_text");
|
||||
$this->ensureColumn($pdo, 'mailshot_queries', 'updated_at', 'ALTER TABLE mailshot_queries ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER `sql`');
|
||||
}
|
||||
|
||||
private function ensureColumn(PDO $pdo, string $table, string $column, string $alterSql): void
|
||||
{
|
||||
if ($this->columnExists($pdo, $table, $column)) {
|
||||
return;
|
||||
}
|
||||
$pdo->exec($alterSql);
|
||||
}
|
||||
|
||||
private function columnExists(PDO $pdo, string $table, string $column): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table AND column_name = :column');
|
||||
$stmt->execute([
|
||||
'schema' => $this->router->mailshotsDbName(),
|
||||
'table' => $table,
|
||||
'column' => $column,
|
||||
]);
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,9 @@ use PDO;
|
|||
|
||||
final class PdoDatabaseRouter implements DatabaseRouter
|
||||
{
|
||||
private PDO $mailshotsPdo;
|
||||
private ?PDO $mailshotsPdo = null;
|
||||
|
||||
private PDO $membersPdo;
|
||||
private ?PDO $membersPdo = null;
|
||||
|
||||
private string $mailshotsDbName;
|
||||
|
||||
|
|
@ -18,46 +18,46 @@ final class PdoDatabaseRouter implements DatabaseRouter
|
|||
|
||||
private string $fenDbName;
|
||||
|
||||
private string $host;
|
||||
private string $port;
|
||||
private string $user;
|
||||
private string $pass;
|
||||
/** @var array<int, mixed> */
|
||||
private array $pdoOptions;
|
||||
|
||||
/** @param array<string, string> $config */
|
||||
public function __construct(array $config)
|
||||
{
|
||||
$host = self::required($config, 'MYSQL_HOST');
|
||||
$port = self::required($config, 'MYSQL_PORT');
|
||||
$user = self::required($config, 'MYSQL_USER');
|
||||
$pass = self::required($config, 'MYSQL_PASSWORD');
|
||||
$this->host = self::required($config, 'MYSQL_HOST');
|
||||
$this->port = self::required($config, 'MYSQL_PORT');
|
||||
$this->user = self::required($config, 'MYSQL_USER');
|
||||
$this->pass = self::required($config, 'MYSQL_PASSWORD');
|
||||
|
||||
$this->mailshotsDbName = self::required($config, 'MAILSHOTS_REMOTE_MYSQL_DB');
|
||||
$this->membersDbName = self::required($config, 'MEMBERS_REMOTE_MYSQL_DB');
|
||||
$this->fenDbName = self::required($config, 'FEN_REMOTE_MYSQL_DB');
|
||||
|
||||
$common = [
|
||||
$this->pdoOptions = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4',
|
||||
];
|
||||
|
||||
$this->mailshotsPdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $this->mailshotsDbName),
|
||||
$user,
|
||||
$pass,
|
||||
$common
|
||||
);
|
||||
|
||||
$this->membersPdo = new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $host, $port, $this->membersDbName),
|
||||
$user,
|
||||
$pass,
|
||||
$common
|
||||
);
|
||||
}
|
||||
|
||||
public function mailshotsPdo(): PDO
|
||||
{
|
||||
if ($this->mailshotsPdo === null) {
|
||||
$this->mailshotsPdo = $this->connect($this->mailshotsDbName);
|
||||
}
|
||||
return $this->mailshotsPdo;
|
||||
}
|
||||
|
||||
public function membersPdo(): PDO
|
||||
{
|
||||
if ($this->membersPdo === null) {
|
||||
$this->membersPdo = $this->connect($this->membersDbName);
|
||||
}
|
||||
return $this->membersPdo;
|
||||
}
|
||||
|
||||
|
|
@ -84,4 +84,14 @@ final class PdoDatabaseRouter implements DatabaseRouter
|
|||
}
|
||||
return $config[$key];
|
||||
}
|
||||
|
||||
private function connect(string $dbName): PDO
|
||||
{
|
||||
return new PDO(
|
||||
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $this->host, $this->port, $dbName),
|
||||
$this->user,
|
||||
$this->pass,
|
||||
$this->pdoOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace FecaMailshots\Infrastructure;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class SchemaEnsuringDatabaseRouter implements DatabaseRouter
|
||||
{
|
||||
private DatabaseRouter $inner;
|
||||
private bool $installed = false;
|
||||
|
||||
public function __construct(DatabaseRouter $inner)
|
||||
{
|
||||
$this->inner = $inner;
|
||||
}
|
||||
|
||||
public function mailshotsPdo(): PDO
|
||||
{
|
||||
$this->installOnce();
|
||||
return $this->inner->mailshotsPdo();
|
||||
}
|
||||
|
||||
public function membersPdo(): PDO
|
||||
{
|
||||
return $this->inner->membersPdo();
|
||||
}
|
||||
|
||||
public function mailshotsDbName(): string
|
||||
{
|
||||
return $this->inner->mailshotsDbName();
|
||||
}
|
||||
|
||||
public function membersDbName(): string
|
||||
{
|
||||
return $this->inner->membersDbName();
|
||||
}
|
||||
|
||||
public function fenDbName(): string
|
||||
{
|
||||
return $this->inner->fenDbName();
|
||||
}
|
||||
|
||||
private function installOnce(): void
|
||||
{
|
||||
if ($this->installed) {
|
||||
return;
|
||||
}
|
||||
(new MailshotSchemaInstaller($this->inner))->install();
|
||||
$this->installed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -28,9 +28,11 @@ use FecaMailshots\Application\TemplateRenderer;
|
|||
use FecaMailshots\Domain\DslParser;
|
||||
use FecaMailshots\Infrastructure\BasicSmtpSender;
|
||||
use FecaMailshots\Infrastructure\DatabaseRouter;
|
||||
use FecaMailshots\Infrastructure\MailshotSchemaInstaller;
|
||||
use FecaMailshots\Infrastructure\DatabaseSourceMetadataProvider;
|
||||
use FecaMailshots\Infrastructure\PhpImapAppender;
|
||||
use FecaMailshots\Infrastructure\PdoDatabaseRouter;
|
||||
use FecaMailshots\Infrastructure\SchemaEnsuringDatabaseRouter;
|
||||
use FecaMailshots\Infrastructure\WordPressSaltSecretKeyProvider;
|
||||
use FecaMailshots\Repository\AttachmentRepository;
|
||||
use FecaMailshots\Repository\LastRunRepository;
|
||||
|
|
@ -46,13 +48,14 @@ use FecaMailshots\Application\ImapAppender;
|
|||
|
||||
final class Plugin
|
||||
{
|
||||
public static function buildContainer(array $dbConfig, WordPressFacade $wp): Container
|
||||
public static function buildContainer(array $dbConfig, WordPressFacade $wp, ?DatabaseRouter $router = null): Container
|
||||
{
|
||||
$c = new Container();
|
||||
|
||||
$c->set('logger', static fn() => new ErrorLogLogger());
|
||||
|
||||
$c->set(DatabaseRouter::class, static fn() => new PdoDatabaseRouter($dbConfig));
|
||||
$c->set(DatabaseRouter::class, static fn() => $router ?? new SchemaEnsuringDatabaseRouter(new PdoDatabaseRouter($dbConfig)));
|
||||
$c->set(MailshotSchemaInstaller::class, static fn(Container $c) => new MailshotSchemaInstaller($c->get(DatabaseRouter::class)));
|
||||
|
||||
$c->set(DatabaseSourceMetadataProvider::class, static fn(Container $c) => new DatabaseSourceMetadataProvider($c->get(DatabaseRouter::class)));
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ final class ProductionWordPressFacade implements WordPressFacade
|
|||
{
|
||||
public function addAction(string $hook, callable $callback): void
|
||||
{
|
||||
add_action($hook, $callback);
|
||||
add_action($hook, $this->guardCallback($callback, $hook));
|
||||
}
|
||||
|
||||
public function addMenuPage(string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void
|
||||
{
|
||||
add_menu_page($pageTitle, $menuTitle, $capability, $slug, $callback, 'dashicons-email-alt', 58);
|
||||
add_menu_page($pageTitle, $menuTitle, $capability, $slug, $this->guardCallback($callback, 'admin_page_' . $slug), 'dashicons-email-alt', 58);
|
||||
}
|
||||
|
||||
public function addSubmenuPage(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $slug, callable $callback): void
|
||||
{
|
||||
add_submenu_page($parentSlug, $pageTitle, $menuTitle, $capability, $slug, $callback);
|
||||
add_submenu_page($parentSlug, $pageTitle, $menuTitle, $capability, $slug, $this->guardCallback($callback, 'admin_page_' . $slug));
|
||||
}
|
||||
|
||||
public function currentUserCan(string $capability): bool
|
||||
|
|
@ -86,4 +86,24 @@ final class ProductionWordPressFacade implements WordPressFacade
|
|||
{
|
||||
return (bool) delete_option($name);
|
||||
}
|
||||
|
||||
private function guardCallback(callable $callback, string $context): callable
|
||||
{
|
||||
return function (...$args) use ($callback, $context) {
|
||||
try {
|
||||
return $callback(...$args);
|
||||
} catch (\Throwable $e) {
|
||||
$message = 'Mailshots plugin error: ' . $e->getMessage();
|
||||
error_log($message);
|
||||
if (strpos($context, 'admin_post_') === 0 || strpos($context, 'rest_api_init') === 0) {
|
||||
$this->sendJson(['ok' => false, 'errors' => [$message]], 500);
|
||||
return null;
|
||||
}
|
||||
echo '<div class="wrap feca-mailshots-admin">';
|
||||
echo '<div class="notice notice-error"><p>' . htmlspecialchars($message, ENT_QUOTES) . '</p></div>';
|
||||
echo '</div>';
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,20 +7,23 @@ use FecaMailshots\WordPress\ProductionWordPressFacade;
|
|||
|
||||
require_once __DIR__ . '/autoload.php';
|
||||
|
||||
$getOption = static function (string $key): string {
|
||||
$setupErrors = [];
|
||||
$getOption = static function (string $key) use (&$setupErrors): string {
|
||||
if (!function_exists('get_option')) {
|
||||
throw new \RuntimeException('WordPress get_option() is unavailable while bootstrapping mailshots plugin.');
|
||||
}
|
||||
$raw = get_option(\FecaMailshots\Admin\SetupAdminPage::OPTION_KEY, []);
|
||||
if (!is_array($raw)) {
|
||||
throw new \RuntimeException('Mailshots setup option is missing or invalid.');
|
||||
$setupErrors[] = 'Mailshots setup option is missing or invalid.';
|
||||
return '';
|
||||
}
|
||||
if (!array_key_exists($key, $raw)) {
|
||||
throw new \RuntimeException('Missing setup configuration key: ' . $key);
|
||||
$setupErrors[] = 'Missing setup configuration key: ' . $key;
|
||||
return '';
|
||||
}
|
||||
$value = trim((string) $raw[$key]);
|
||||
if ($value === '') {
|
||||
throw new \RuntimeException('Empty setup configuration value: ' . $key);
|
||||
$setupErrors[] = 'Empty setup configuration value: ' . $key;
|
||||
}
|
||||
return $value;
|
||||
};
|
||||
|
|
@ -36,6 +39,12 @@ $dbConfig = [
|
|||
];
|
||||
|
||||
$wp = new ProductionWordPressFacade();
|
||||
if ($setupErrors !== []) {
|
||||
$setupPage = new \FecaMailshots\Admin\SetupAdminPage($wp, array_values(array_unique($setupErrors)), true);
|
||||
$setupPage->register();
|
||||
return;
|
||||
}
|
||||
|
||||
$container = Plugin::buildContainer($dbConfig, $wp);
|
||||
$container->get(FecaMailshots\Admin\DataSourcesAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\AttachmentsAdminPage::class)->register();
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ Determine whether `dompdf` is suitable for generating production-quality mailsho
|
|||
- logo placement and title/header integrity
|
||||
- `invoicing.html` checks:
|
||||
- line-break behavior from macro expansion
|
||||
- currency/symbol rendering (`£`, `✉`, `📞`)
|
||||
- currency/symbol rendering (`£`, `✉`, `☎`)
|
||||
- emoji contact symbols such as `📞`/`📧` must be normalized to PDF-safe symbols (`☎`/`✉`) and pinned to `DejaVu Sans` before Dompdf rendering.
|
||||
- spacing, wrapping, and pagination
|
||||
|
||||
4. Baseline comparison
|
||||
|
|
|
|||
|
|
@ -78,7 +78,9 @@ Hard constraints for this task:
|
|||
- `ssh` and `rsync` (required for remote deployment)
|
||||
- PHP CLI
|
||||
- Node.js / npm
|
||||
- Optional at runtime for PDF merge/zip fallback paths: `gs` (Ghostscript), `zip` CLI
|
||||
- `gs` (Ghostscript), required at runtime for merged PDF downloads
|
||||
- PHP `ZipArchive`, preferred at runtime for ZIP PDF downloads
|
||||
- `zip` CLI, required at runtime only when PHP `ZipArchive` is unavailable for ZIP PDF downloads
|
||||
|
||||
## Dependency Installation Scripts
|
||||
|
||||
|
|
|
|||
|
|
@ -43,15 +43,17 @@ Provide a Mailshot feature to:
|
|||
| `AttachmentNames` | `longtext` | YES | | JSON array of named entries from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_attachments` |
|
||||
| `PDFFilenameDerivedFrom` | `varchar(128)` | YES | | Optional data-source field used as base filename for generated PDFs |
|
||||
| `ReplyTo` | `varchar(100)` | YES | | Optional reply-to address |
|
||||
| `RecipientEmailField` | `varchar(255)` | YES | | Optional selected data-source field used as recipient email destination |
|
||||
|
||||
### 1.2.2 Schema Reference: `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries`
|
||||
|
||||
| Column | Type | Null | Key | Notes |
|
||||
| ------------ | ---------------- | ---- | ------ | -------------------------------------------------------- |
|
||||
| `ID` | `int(11)` | NO | PK | Auto-increment |
|
||||
| `name` | `varchar(100)` | NO | UNIQUE | Data-source name used by `mailshots.DataSource` |
|
||||
| `dsl_text` | `longtext` | YES | | DSL sentence (source of truth) |
|
||||
| `sql` | `longtext` | NO | | Legacy column retained; compile target not user-authored |
|
||||
| Column | Type | Null | Key | Notes |
|
||||
| ------------- | -------------- | ---- | ------ | -------------------------------------------------------- |
|
||||
| `ID` | `int(11)` | NO | PK | Auto-increment |
|
||||
| `name` | `varchar(100)` | NO | UNIQUE | Data-source name used by `mailshots.DataSource` |
|
||||
| `dsl_text` | `longtext` | YES | | DSL sentence (source of truth) |
|
||||
| `dsl_version` | `varchar(16)` | NO | | DSL version marker; defaults to `v1` |
|
||||
| `sql` | `longtext` | NO | | Legacy column retained; compile target not user-authored |
|
||||
|
||||
## 1.3 Navigation and Pages
|
||||
|
||||
|
|
@ -90,14 +92,20 @@ Persist selected named attachments on each mailshot in `MAILSHOTS_REMOTE_MYSQL_D
|
|||
|
||||
* Display rows from `MAILSHOTS_REMOTE_MYSQL_DB.mailshots`.
|
||||
* In the list/table view, hide internal `id` and display user-facing columns (`Purpose`, `DataSource`, `Subject`).
|
||||
* Sort the mailshot list by `Purpose` (ascending).
|
||||
* Provide `Sort by` and direction controls for the list/table view.
|
||||
* Sortable list columns are `Purpose`, `DataSource`, and `Subject`; the `Actions` column is not sortable.
|
||||
* Default list sort is `Purpose` ascending.
|
||||
* Preserve selected sort when editing, saving, duplicating, or deleting from the mailshot list.
|
||||
* Provide editing for all mutable fields.
|
||||
* Highlight unsaved changed fields.
|
||||
* Track whether the edit form has unsaved changes and warn before closing/quitting an edit with unsaved changes.
|
||||
* Use a separate pane for editing the fields. Note that the Message field is multiline html-formatted and should be edited using an html edit control. The design of the page should maximise room for this field
|
||||
* Provide a separate html edit control for `PDFAttachment` (optional template) using the same html editing features as `Message`.
|
||||
* Message and PDF Attachment html editors should open in overlay panes to maximise editing room.
|
||||
* Provide `PDF Filename Derived From` dropdown populated from available data-source fields for the selected `DataSource`.
|
||||
* Persist `PDF Filename Derived From` as `MAILSHOTS_REMOTE_MYSQL_DB.mailshots.PDFFilenameDerivedFrom`.
|
||||
* Provide `Recipient Email Field` dropdown populated from available data-source fields for the selected `DataSource`.
|
||||
* Persist `Recipient Email Field` as `MAILSHOTS_REMOTE_MYSQL_DB.mailshots.RecipientEmailField`.
|
||||
* If `RecipientEmailField` is blank, show an inline warning that the mailshot cannot be used to send a mailshot; PDF-only workflows may leave it blank.
|
||||
* The Data Pane also include a table of mailshots, width limited to the page width, with truncation of fields.
|
||||
* Keep editing responsive; do not trigger per-keystroke save/reload.
|
||||
* Data-source pulldowns must be sorted by data-source `name` (ascending).
|
||||
|
|
@ -111,16 +119,25 @@ Persist selected named attachments on each mailshot in `MAILSHOTS_REMOTE_MYSQL_D
|
|||
|
||||
### 1.4.2 Action Pane
|
||||
|
||||
* Provide `Save` button. Enable only when unsaved changes exist.
|
||||
* Provide `Discard Changes` button. Enable only when unsaved changes exist.
|
||||
* Provide `Save` button.
|
||||
* Provide `Quit`/close action for leaving the edit pane; if unsaved changes exist, require confirmation before closing without saving.
|
||||
* Provide `New` button to add a new `MAILSHOTS_REMOTE_MYSQL_DB.mailshots` row.
|
||||
* Provide `Delete` button. Enable only when one row is selected.
|
||||
* On `Delete`, require confirmation before delete is applied.
|
||||
* Provide per-row `Delete` button.
|
||||
* On per-row `Delete`, require confirmation before delete is applied.
|
||||
* Provide per-row `Duplicate` button in the list/table.
|
||||
* `Duplicate` must copy all mailshot definition fields into a new row and assign a unique purpose:
|
||||
* first choice: `Copy of <original purpose>`
|
||||
* subsequent collisions: `Copy of <original purpose> (2)`, `Copy of <original purpose> (3)`, and so on
|
||||
* blank original purpose fallback: `Copy of Mailshot #<source id>`
|
||||
* resulting purpose must fit the `Purpose varchar(100)` column.
|
||||
* After successful duplicate, redirect to the edit modal for the newly-created copy.
|
||||
|
||||
### 1.4.3 Validation
|
||||
|
||||
* Require `DataSource` to match an existing `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries.name`.
|
||||
* Prevent save when `Subject` or `Message` are blank.
|
||||
* Prevent save when `Subject` is blank.
|
||||
* Allow `Message` to be blank for PDF-only workflows.
|
||||
* If `RecipientEmailField` is non-blank, it must be one of the available fields for the selected `DataSource`.
|
||||
* Display validation errors in the information pane.
|
||||
|
||||
## 1.5 Template Engine (Twig)
|
||||
|
|
@ -128,12 +145,14 @@ Persist selected named attachments on each mailshot in `MAILSHOTS_REMOTE_MYSQL_D
|
|||
* Use Twig as the template engine for mailshot rendering.
|
||||
* All template tokens and expressions use Twig syntax.
|
||||
* Template context is the selected recipient row (plus approved helper functions/filters).
|
||||
* Default undefined-variable behavior is non-fatal and renders as empty string while recording a warning.
|
||||
* Twig uses strict variables. Undefined variables or invalid expressions must fail rendering with a clear error.
|
||||
* Default escaping policy is:
|
||||
* `Subject`: unescaped text output.
|
||||
* `Message`: unescaped only when explicitly requested in template (`| raw`), otherwise escaped.
|
||||
* `PDFAttachment`: unescaped only when explicitly requested in template (`| raw`), otherwise escaped.
|
||||
* Control-flow tags (`{% if %}`, `{% for %}`, `{% set %}`) are allowed in `Message` and `PDFAttachment`, but disallowed in `Subject` by default.
|
||||
* Twig autoescape is `html` for `Subject`, `Message`, and `PDFAttachment`.
|
||||
* Rendered `Subject` is HTML-entity-decoded before use as an email subject.
|
||||
* Template authors may use Twig filters such as `raw` where unescaped HTML output is required.
|
||||
* Control-flow tags (`{% if %}`, `{% for %}`, `{% set %}`) are supported by Twig in all template fields.
|
||||
* Template context must include both original query field keys (for example `pages.Page`) and canonical safe token keys (for example `pages_page`).
|
||||
* Boolean-like fields whose field name starts with `is`, `has`, or `can`, or whose field name is `selected`, `active`, `enabled`, `disabled`, or `lapsed`, should render as `Yes`/`No` when their values are recognizably boolean-ish.
|
||||
|
||||
## Mailshot Data Sources Page
|
||||
|
||||
|
|
@ -177,15 +196,16 @@ This is defined in `requirements/mailshot_data_source.md`.
|
|||
* Recipient-row selector must also provide an `All recipients` option.
|
||||
* Provide `Test email address` input for explicit destination override, defaulting to configured `.env` value `MAILSHOT_TEST_TO_DEFAULT` when set.
|
||||
* If `MAILSHOT_TEST_TO_DEFAULT` is unset/blank, initialize `Test email address` as blank.
|
||||
* Rendered Message control must display the html message according to the html formatting.
|
||||
* Provide a rendered `PDFAttachment` html preview pane below the rendered message preview pane.
|
||||
* The rendered Message and rendered PDF Attachment preview panes should default to equal heights and support drag-resize via a divider.
|
||||
* After successful render-only test, provide an `Open Render Preview` action.
|
||||
* Render preview opens in an overlay/modal and displays rendered `Message` and rendered `PDFAttachment` side by side in sandboxed iframe previews.
|
||||
* Render preview includes rendered `Subject` as modal context.
|
||||
|
||||
### 1.7.2 Test Mode: Render Only (No Send)
|
||||
|
||||
* Provide action `Render Test (No Send)`.
|
||||
* Perform full Twig template rendering for selected row.
|
||||
* Display rendered `Subject`, `Message`, and `PDFAttachment`.
|
||||
* Display rendered `Subject`, `Message`, and `PDFAttachment` through the render preview modal.
|
||||
* Render-only UI must not store the full rendered html payload in WordPress options; large rendered payloads should be recomputed after redirect or passed by another size-safe mechanism.
|
||||
* Do not send SMTP mail.
|
||||
* Do not write IMAP `Sent` copy.
|
||||
|
||||
|
|
@ -203,7 +223,8 @@ This is defined in `requirements/mailshot_data_source.md`.
|
|||
* `Render Test (No Send)` must require a specific recipient row (not `All recipients`).
|
||||
* Copy sent test email to IMAP `Sent`.
|
||||
* Display success/failure status and timestamp.
|
||||
* Keep rendered `Subject`, `Message`, and `PDFAttachment` visible after send.
|
||||
* Send-test UI must keep the user on the Mailshot Test page and display same-pane feedback. If the server returns non-JSON or an HTTP error, show an inline error rather than navigating to a WordPress critical-error page.
|
||||
* Apply the configured Download PDF memory limit, when valid, before render/send test actions that may render PDF attachments.
|
||||
|
||||
## 1.8 Run Mailshot Page
|
||||
|
||||
|
|
@ -250,8 +271,8 @@ This is defined in `requirements/mailshot_data_source.md`.
|
|||
* `error_message` and/or `warning_message`
|
||||
* `attempt_count`
|
||||
* `last_attempt_at`
|
||||
* Mailshots page must display the latest rows for the selected mailshot from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run`.
|
||||
* Mailshots page must provide:
|
||||
* Run Mailshot page must display the latest rows for the selected mailshot from `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_last_run`.
|
||||
* Run Mailshot page must provide:
|
||||
* `Retry Failed Sends` button to retry all failed rows from the selected mailshot.
|
||||
* per-row `Retry` button to retry one failed row by recipient key.
|
||||
* Retry must resolve recipient data from the current data source and current template values at retry time (not snapshot payload from original run).
|
||||
|
|
@ -266,12 +287,22 @@ This is defined in `requirements/mailshot_data_source.md`.
|
|||
* Require resolved recipient query.
|
||||
* Block generation when recipient query returns zero recipients.
|
||||
* Block generation when selected mailshot has blank `PDFAttachment` template.
|
||||
* Merged PDF generation requires Ghostscript (`gs`) and PHP `exec()` availability.
|
||||
* ZIP generation uses PHP `ZipArchive` when available; if unavailable, it requires a `zip` CLI binary and PHP `exec()` availability.
|
||||
|
||||
### 1.9.2 Output Options
|
||||
|
||||
* Provide action to generate and download individual recipient PDFs as a single `.zip`.
|
||||
* Provide action to generate and download a single merged PDF containing all recipient PDFs.
|
||||
* Use html-to-pdf conversion of rendered `PDFAttachment` content per recipient row.
|
||||
* Downloaded merged PDF filename must be based on the selected mailshot `Purpose`, sanitized to safe filename characters, with suffix `_merged.pdf`.
|
||||
* Downloaded ZIP filename must be based on the selected mailshot `Purpose`, sanitized to safe filename characters, with suffix `_pdfs.zip`.
|
||||
* If `Purpose` is blank or cannot be loaded, use fallback base name `mailshot_<id>`.
|
||||
* PDF rendering must use Dompdf with default font `DejaVu Sans` and font subsetting enabled.
|
||||
* Before Dompdf rendering, normalize contact-symbol characters that commonly fail in PDF fonts:
|
||||
* `📞` and `☎` render as a `☎` symbol pinned to `DejaVu Sans`
|
||||
* `📧` and `✉` render as a `✉` symbol pinned to `DejaVu Sans`
|
||||
* Template authors should still prefer PDF-safe symbols (`☎`, `✉`) over color emoji symbols.
|
||||
|
||||
### 1.9.3 Results and Logging
|
||||
|
||||
|
|
@ -311,7 +342,7 @@ DSL filters include:
|
|||
## 2.0 No-Effect and Error Handling
|
||||
|
||||
* If `Save` is clicked with no changes, no effect.
|
||||
* If `Discard Changes` is clicked with no changes, no effect.
|
||||
* If `Quit`/close is clicked with no changes, close without confirmation.
|
||||
* If a selected mailshot references a missing `MAILSHOTS_REMOTE_MYSQL_DB.mailshot_queries` row, block preview/test/run and display error.
|
||||
* If SMTP send fails for one recipient, record error and continue with next recipient.
|
||||
* If IMAP copy fails after successful SMTP send, mark warning for that recipient.
|
||||
|
|
@ -358,24 +389,25 @@ Provide CRUD storage for PDF assets with at least:
|
|||
|
||||
* List assets in a table.
|
||||
* Support `Create`, `Read`, `Update`, `Delete`.
|
||||
* Upload graphic file during create/update.
|
||||
* Create/update requires image bytes, provided either by file upload or by direct base64 entry.
|
||||
* Edit dimensions in millimetres.
|
||||
* Edit justification (`left`, `right`, `in-place`).
|
||||
* Validate file type and size constraints.
|
||||
* Validate name, filename-derived or explicit MIME type, positive dimensions, allowed justification, and valid base64 image bytes.
|
||||
|
||||
### 2.1.5 Mailshots PDF Editor Integration
|
||||
|
||||
* In the `PDF Attachment` editor overlay, add an insert-asset action with dropdown of available assets.
|
||||
* On insert, place Twig token syntax at cursor position.
|
||||
* Default insert format is `{{ pdf_asset('asset_name') }}`.
|
||||
* Asset names used in inserted tokens must be normalized to safe token characters.
|
||||
* Asset names used in inserted tokens must be escaped safely as Twig string literals; the saved asset name itself is not renamed during insertion.
|
||||
|
||||
### 2.1.6 Rendering Behavior
|
||||
|
||||
* On rendering `PDFAttachment` html (Mailshot Test preview/send, Run Mailshot, Download PDF), resolve each inserted PDF asset Twig token to rendered image html for that asset.
|
||||
* Render image using configured dimensions (`width_mm`, `height_mm`) and configured justification.
|
||||
* If template references missing asset, do not fail entire run; record warning and render explicit missing-asset placeholder html.
|
||||
* Missing asset placeholder default is `[missing pdf asset: <asset_name>]`.
|
||||
* If template references a missing asset, rendering must fail with a clear error naming the missing asset.
|
||||
* If `pdf_asset()` is unavailable, the asset has no readable bytes, dimensions are invalid, or justification is invalid, rendering must fail with a clear error.
|
||||
* PNG assets require either GD PNG support or ImageMagick `convert` for runtimes where Dompdf cannot consume the PNG bytes directly; otherwise rendering must fail with a clear error.
|
||||
|
||||
### 2.1.7 PDF Filename Derivation
|
||||
|
||||
|
|
@ -387,16 +419,15 @@ Provide CRUD storage for PDF assets with at least:
|
|||
## 2.2 PDF Assets Decisions
|
||||
|
||||
* Asset binaries are stored in DB table (`LONGBLOB`) for portability and backup simplicity.
|
||||
* Allowed upload formats: `png`, `jpg/jpeg`, `svg`.
|
||||
* Supported filename-derived MIME types are `jpg/jpeg`, `png`, `gif`, `webp`, `svg`, `bmp`, `tif`, and `tiff`.
|
||||
* Scope/ownership: global asset library, editor-capable users (WordPress `edit_pages`) may CRUD.
|
||||
* Asset naming: unique case-insensitive `name`; renaming does not auto-migrate existing asset insertions in templates and should warn user.
|
||||
* Marker syntax is removed. PDF asset insertion uses Twig syntax.
|
||||
* Helper name is `pdf_asset(name)` as a global Twig helper, not a filter.
|
||||
* Dimension/aspect behavior: fixed width+height in mm with `object-fit: contain` (no crop/stretch distortion).
|
||||
* Justification semantics: `in-place` renders inline at marker location; `left` and `right` render block-aligned elements on their own line (no text-wrap behavior).
|
||||
* Upload limits: max file size `2MB`, max image dimensions `4000x4000`.
|
||||
* Missing asset token behavior: continue processing, emit warning, render explicit missing-asset placeholder.
|
||||
* Delete behavior for in-use assets: block delete when asset helper usage is referenced by any `PDFAttachment` template and show referencing mailshot count.
|
||||
* Dimension/aspect behavior: fixed width and height in millimetres are emitted as inline image style.
|
||||
* Justification semantics: `in-place` renders inline at marker location; `left` renders a block-aligned element on its own line; `right` floats the image right so surrounding text can fill the space to its left.
|
||||
* Missing asset token behavior: fail rendering with a clear error.
|
||||
* Delete behavior: delete the asset row by id; current implementation does not block deletion for in-use assets.
|
||||
|
||||
## 2.3 Mailshot Attachments
|
||||
|
||||
|
|
@ -411,9 +442,9 @@ Under `Mailshot > Assets`, provide page `Attachments`.
|
|||
### 2.3.2 CRUD and Validation
|
||||
|
||||
* `name` is required and unique.
|
||||
* Create requires uploaded file bytes.
|
||||
* Update allows metadata-only edits without replacing file.
|
||||
* Delete must be blocked while referenced by any `MAILSHOTS_REMOTE_MYSQL_DB.mailshots.AttachmentNames`.
|
||||
* Create/update requires file bytes, provided either by file upload or by direct base64 entry.
|
||||
* Supported filename-derived MIME types are `txt`, `csv`, `pdf`, `doc`, `docx`, `odt`, `rtf`, `html`/`htm`, `json`, `xml`, `jpg`/`jpeg`, `png`, `gif`, `webp`, `svg`, and `zip`.
|
||||
* Delete behavior: delete the attachment row by id; current implementation does not block deletion for in-use attachments.
|
||||
|
||||
### 2.3.3 Send/Run Behavior
|
||||
|
||||
|
|
|
|||
|
|
@ -89,8 +89,7 @@ filter_expr = filter_name
|
|||
filter_name = "selected-renewal"
|
||||
| "pending-renewal"
|
||||
| "selected"
|
||||
| "page-in-issue"
|
||||
| "ad-in-issue"
|
||||
| "issue"
|
||||
| "pending-invoice"
|
||||
| "selected-invoice"
|
||||
| "invoice-ids"
|
||||
|
|
@ -131,10 +130,11 @@ digit = "0"…"9" ;
|
|||
* `Data Source does not have an email field, it cannot be used for a Mailshot`
|
||||
* The sentence is still valid and can be saved and previewed.
|
||||
* `and` between sources means relational intersection using configured join paths (not email-only matching).
|
||||
* Built-in `accounts` and `contacts` queries must automatically exclude soft-deleted rows when the source exposes `is_deleted`; values other than `0` are treated as deleted. The generated predicate must avoid collated string comparison so text-backed flags with different collations do not fail at runtime.
|
||||
* For custom sources, an explicit field equality predicate can provide join semantics.
|
||||
* `where` applies after source composition.
|
||||
* `not` negates only the next predicate/group.
|
||||
* Predefined predicates are `selected-renewal`, `pending-renewal`, `selected`, `page-in-issue`, `ad-in-issue`, `pending-invoice`, `selected-invoice`, `invoice-ids`, `fen1-contact`, `primary-contact`, and `member-or-affiliate-or-parish-council`.
|
||||
* Predefined predicates are `selected-renewal`, `pending-renewal`, `selected`, `issue`, `pending-invoice`, `selected-invoice`, `invoice-ids`, `fen1-contact`, `primary-contact`, `member-or-affiliate-or-parish-council`, and `account-has-article-in-issue`.
|
||||
* `renewals` is a built-in source mapped to membership renewal rows.
|
||||
* `pending-renewal` applies only when source set includes `renewals` and means `renewals.status = 'pending'`.
|
||||
* `selected-renewal` applies only when source set includes `renewals` and means `renewals.selected = true`.
|
||||
|
|
@ -163,14 +163,16 @@ digit = "0"…"9" ;
|
|||
* `fen.advertisers`: `Entry ID`, `AdvertiserName`, `title`, `contact_name`, `address_1`, `address_2`, `town`, `post_code`, `Description`, `IsLapsed?`, `Home Phone`, `Phone`, `Email`, `Selected`.
|
||||
* `fen.invoices`: `id`, `issue_id`, `ad_id`, `invoice_number`, `invoice_date`, `due_date`, `invoice_page`, `invoice_size`, `invoice_price`, `status`, `payment_date`, `amount_paid`, `payment_method`, `payment_reference`, `notes`, `created_at`, `updated_at`.
|
||||
* `selected` applies only when source set includes `advertisers` and means `advertisers.Selected` is truthy.
|
||||
* `page-in-issue(<issue>)` accepts exactly one numeric issue ID. It applies only when the source set includes `pages` or `articles`, and means the page row, or the page joined from the article row, belongs to that issue.
|
||||
* `ad-in-issue(<issue>)` accepts exactly one numeric issue ID. It applies only when the source set includes one of `advertisers`, `ads`, `pages`, `issues`, or `invoices`, and means the row has an ad in that issue.
|
||||
* `issue(<issue>)` accepts exactly one numeric issue ID. It applies when the source set includes one of `advertisers`, `ads`, `pages`, `articles`, `issues`, or `invoices`, and means the row is associated with that issue. The compiler may traverse hidden approved paths to apply the filter, but only explicitly cited sources contribute fields to the result/template context.
|
||||
* When `issues` is explicitly appended to an otherwise joined source set and a non-negated `issue(<issue>)` filter is present, `issues` may be attached as a one-row issue context source even when there is no direct approved join path from the preceding source. Without that constraining issue filter, the join must remain invalid.
|
||||
* `pending-invoice` applies only when source set includes `invoices` and means `invoices.status = 'pending'`.
|
||||
* `selected-invoice` applies only when source set includes `invoices` and means the invoice ID is in the runtime selected invoice ID list.
|
||||
* `invoice-ids(...)` applies only when source set includes `invoices`; it accepts one or more numeric invoice IDs.
|
||||
* `member-or-affiliate-or-parish-council` applies to account data and means:
|
||||
* `accounts.type` is `Member` or `Affiliate`, or
|
||||
* `accounts.name` contains `Parish Council`.
|
||||
* `account-has-article-in-issue(<issue>)` applies only when source set includes `accounts`; it accepts exactly one numeric issue ID and means an article in that issue has `Articles.MemberName` matching `accounts.name`. Use `not account-has-article-in-issue(<issue>)` to exclude accounts that already have an article in the issue without adding article fields to the result.
|
||||
* Text comparisons that bridge FEN and member data, including article/member and advertiser/ad name matching, must normalize both sides to the same UTF-8 collation to avoid runtime collation errors.
|
||||
* `field_ref` is restricted by whitelist per built-in source.
|
||||
* `field_ref` is also supported for selected custom sources when table metadata is available.
|
||||
* Field-to-field comparisons are supported with `=` and `!=` only.
|
||||
|
|
@ -210,7 +212,9 @@ The compiler must use an explicit join graph per source pair. Example v1 join pa
|
|||
* `invoices` -> `pages`: `invoices.issue_id = pages.Issue`
|
||||
* `pages` -> `invoices`: `pages.Issue = invoices.issue_id`
|
||||
|
||||
If no approved join path exists between two sources for `and`, parsing/validation must fail with a clear error.
|
||||
For built-in source lists, each newly-mentioned source may join to any earlier source in the same sentence, preferring the nearest earlier source that has an approved path. It is not restricted to the immediately preceding source. For example, `invoices and ads and pages and advertisers where issue(125)` is valid because `advertisers` can join back to the already-present `ads` source even though `pages` immediately precedes it.
|
||||
|
||||
If no approved join path exists from a newly-mentioned source to any earlier selected source for `and`, parsing/validation must fail with a clear error.
|
||||
|
||||
No implicit join behavior is allowed:
|
||||
|
||||
|
|
@ -230,8 +234,11 @@ No implicit join behavior is allowed:
|
|||
* `renewals where selected-renewal`
|
||||
* `renewals and accounts and contacts where pending-renewal`
|
||||
* `advertisers where selected`
|
||||
* `ads and pages where ad-in-issue(202605)`
|
||||
* `articles and pages where page-in-issue(202605)`
|
||||
* `ads and pages where issue(202605)`
|
||||
* `articles and pages and issues where issue(202605)`
|
||||
* `invoices and ads and pages and advertisers where issue(125)`
|
||||
* `contacts and accounts and issues where fen1-contact and issue(202605)`
|
||||
* `contacts and accounts and issues where fen1-contact and issue(202605) and not account-has-article-in-issue(202605)`
|
||||
* `invoices where pending-invoice`
|
||||
* `invoices and ads and advertisers where invoice-ids(101, 102)`
|
||||
* `accounts where accounts.Name not in (members.ExcludedAccounts.ExcludedAccount)`
|
||||
|
|
@ -248,8 +255,8 @@ No implicit join behavior is allowed:
|
|||
* `accounts where primary-contact` (invalid: filter requires `contacts` source)
|
||||
* `ads where selected` (invalid: filter requires `advertisers` source)
|
||||
* `advertisers where pending-invoice` (invalid: filter requires `invoices` source)
|
||||
* `pages where ad-in-issue` (invalid: `ad-in-issue` requires exactly one numeric issue ID)
|
||||
* `ads where page-in-issue(202605)` (invalid: `page-in-issue` requires `pages` or `articles` source)
|
||||
* `pages where issue` (invalid: `issue` requires exactly one numeric issue ID)
|
||||
* `accounts where issue(202605)` (invalid: `issue` requires an issue-capable source)
|
||||
* `invoices where invoice-ids('abc')` (invalid: `invoice-ids` accepts numeric invoice IDs only)
|
||||
* `renewals where member-or-affiliate-or-parish-council` (invalid: filter requires `accounts` source)
|
||||
* `accounts and contacts where contacts.Accountid contains accounts.ID` (invalid: field-to-field supports only `=`/`!=`)
|
||||
|
|
@ -418,8 +425,7 @@ Predefined filter control:
|
|||
* `Renewal is selected` -> `selected-renewal`
|
||||
* `Renewal is pending` -> `pending-renewal`
|
||||
* `Advertiser is selected` -> `selected`
|
||||
* `Page is in issue` -> `page-in-issue(...)`
|
||||
* `Advertiser has ad in issue` -> `ad-in-issue(...)`
|
||||
* `Issue is` -> `issue(...)`
|
||||
* `Invoice is pending` -> `pending-invoice`
|
||||
* `Invoice is selected` -> `selected-invoice`
|
||||
* `Invoice ID is one of` -> `invoice-ids(...)`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
# Manual testing #1
|
||||
|
||||
## General
|
||||
|
||||
* [X] Changes to enable fen database access 2026-05-09 cause a critical failure on the wordpress website. Plugin disabled and wait for codex limits to reset to address.
|
||||
* [X] Plugin did not know fen database name
|
||||
* [X] Be robust against missing credentials
|
||||
* [X] Be robust against (partly) missing schema
|
||||
* [X] Do not take a long time executing on plugin register
|
||||
* [X] ads and advertisers where ad-in-issue(125) only shows advertiser name field (twice) - it should include all ads and all advertisers fields in the result. The same goes for all built-in joins.
|
||||
* [X] Mailshot test "Select a mailshot" error is misleading because a mailshot was selected, it should say "load recipients". Better still - make "load recipients" obsolete. Load on page entry and change of data source. Remove button.
|
||||
* [X] Mailshot test to above DSL with template "{{ ads_adsize }}
|
||||
{{ ads_state }}
|
||||
{{ advertisers_advertisername }}
|
||||
{{ advertisers_islapsed }}
|
||||
{{ advertisers_home_phone }}" returned: "qp Confirmed A R Aspinall & Sons Ltd <20>"
|
||||
* [X] DSL should allow field test to "blank" - which should be true for null and empty string.
|
||||
* [X] fenedgec_members.renewal_accounts_with_contacts generate error "SQLSTATE[42000]: Syntax error or access violation: 1064 You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '?' at line 1"
|
||||
* [X] "contacts and accounts and issues where fen1-contact and issue(125)" gives error "No approved join path between accounts and issues" - should allow the join to "tack on" the issue data
|
||||
* [X] "contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council" gives too many (103) - need is_deleted filter generates error: "SQLSTATE[HY000]: General error: 1267 Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT) for operation '='"
|
||||
* [X] "contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council and account-has-article-in-issue(125)" validates OK, but preview gives: General error: 1267 Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and (utf8mb4_unicode_ci,IMPLICIT) for operation '='
|
||||
* [X] issues.CopyDate and issue.PublicationDate render as e.g. "2026-02-23 00:00:00", would prefer to render dates as e.g. "Friday 23 February 2026"
|
||||
* [ ] I made an edit to a mailshot (), with a 9kb message. Save did not close the dialog - but did not show any errors. There was a long pause (long enough to start typing this report) before it appeared to have had an effect (mailshot saved message). But Edit button on Mailshot doesn't appear to do anything now browser shows page still loading. https://fenedge.co.uk is not responding.
|
||||
* [ ] Edit Data Source save DSL="ads and advertisers where issue(125)" takes a very long long time to repond.
|
||||
* [X] Downloaded merged pdf or .zip should be named after maiilshot purpose
|
||||
|
|
@ -42,7 +42,7 @@ test('data sources: DSL builder round-trip preserves complex representable DSL',
|
|||
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";
|
||||
"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.phone_1 blank and contacts.account_id != accounts.id";
|
||||
|
||||
try {
|
||||
await ensureDataSource(request, dsName, dsl);
|
||||
|
|
@ -81,7 +81,7 @@ test('data sources: DSL builder round-trip preserves FEN built-in filters', asyn
|
|||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const dsName = `e2e_ds_fen_roundtrip_${uniq}`;
|
||||
const dsl =
|
||||
'invoices and ads and advertisers where invoice-ids(10, 11) and ad-in-issue(202605) and selected';
|
||||
'invoices and ads and advertisers where invoice-ids(10, 11) and issue(202605) and selected';
|
||||
|
||||
try {
|
||||
await ensureDataSource(request, dsName, dsl);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ const fallbackPdfTemplate = '<div>Download PDF test</div>';
|
|||
test.describe('renewal dataset: download pdf e2e', () => {
|
||||
test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1');
|
||||
|
||||
const clickAndExpectDownload = async (page, buttonName, expectedFilenamePart) => {
|
||||
const safeDownloadBaseName = (name) => String(name || '')
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^[._-]+|[._-]+$/g, '') || 'mailshot';
|
||||
|
||||
const clickAndExpectDownload = async (page, buttonName, expectedFilename) => {
|
||||
page.once('dialog', async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
|
|
@ -47,7 +53,7 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
throw new Error(`Download action "${buttonName}" timed out waiting for file download.`);
|
||||
}
|
||||
|
||||
expect(downloaded.suggestedFilename()).toContain(expectedFilenamePart);
|
||||
expect(downloaded.suggestedFilename()).toBe(expectedFilename);
|
||||
const outPath = await downloaded.path();
|
||||
expect(outPath).toBeTruthy();
|
||||
const outSize = fs.statSync(outPath).size;
|
||||
|
|
@ -70,7 +76,7 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
}
|
||||
const existingId = String(existing.id || '');
|
||||
expect(existingId).not.toBe('');
|
||||
return { mailshotId: existingId, cleanup: async () => {} };
|
||||
return { mailshotId: existingId, purpose: String(existing.Purpose || ''), cleanup: async () => {} };
|
||||
}
|
||||
|
||||
await ensureDataSource(request, dsCreatedName, 'contacts');
|
||||
|
|
@ -92,6 +98,7 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
|
||||
return {
|
||||
mailshotId,
|
||||
purpose,
|
||||
cleanup: async () => {
|
||||
await cleanupByNames(request, {
|
||||
dataSourceNames: [dsCreatedName],
|
||||
|
|
@ -108,7 +115,7 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
await expect(page.getByRole('heading', { name: 'Download PDF' })).toBeVisible();
|
||||
await page.locator('#dp_mailshot_id').selectOption(resolved.mailshotId);
|
||||
|
||||
await clickAndExpectDownload(page, 'Download Merged PDF', '_merged.pdf');
|
||||
await clickAndExpectDownload(page, 'Download Merged PDF', `${safeDownloadBaseName(resolved.purpose)}_merged.pdf`);
|
||||
} finally {
|
||||
try {
|
||||
await resolved.cleanup();
|
||||
|
|
@ -124,7 +131,7 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
await page.goto(`${adminPath('feca-mailshots-download-pdf')}&mailshot_id=${encodeURIComponent(resolved.mailshotId)}`);
|
||||
await expect(page.getByRole('heading', { name: 'Download PDF' })).toBeVisible();
|
||||
await page.locator('#dp_mailshot_id').selectOption(resolved.mailshotId);
|
||||
await clickAndExpectDownload(page, 'Download ZIP of PDFs', '_pdfs.zip');
|
||||
await clickAndExpectDownload(page, 'Download ZIP of PDFs', `${safeDownloadBaseName(resolved.purpose)}_pdfs.zip`);
|
||||
} finally {
|
||||
try {
|
||||
await resolved.cleanup();
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ test('mailshots editor: datasource-driven options + overlay editors + create', a
|
|||
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);
|
||||
|
||||
const tokenChooser = page.locator('#ms-token-controls .feca-token-format-controls');
|
||||
await expect(tokenChooser).toBeVisible();
|
||||
await tokenChooser.locator('select').first().selectOption({ index: 1 });
|
||||
await tokenChooser.locator('select').nth(1).selectOption({ label: 'Long Date' });
|
||||
await tokenChooser.getByRole('button', { name: 'Insert' }).click();
|
||||
await expect(page.locator('#ms_subject')).toHaveValue(/\{\{ [^}]+ \| date\('l j F Y'\) \}\}/);
|
||||
|
||||
await page.locator('#ms_subject').fill('Subject {{ contacts.id }}');
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
|
|
|
|||
|
|
@ -58,3 +58,45 @@ test('run/test pages: load and failure-path API assertions', async ({ page, requ
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('mailshot test page renders complex PDF preview through UI postback', async ({ page, request }) => {
|
||||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
const dsName = `e2e_render_ui_ds_${uniq}`;
|
||||
const purpose = `e2e_render_ui_ms_${uniq}`;
|
||||
const repeatedRows = Array.from({ length: 180 }, (_, i) => (
|
||||
`<tr><td>${i + 1}</td><td>{{ contacts_id }}</td><td>PDF_RENDER_MARKER_${i}</td></tr>`
|
||||
)).join('');
|
||||
const pdfHtml = `<html><body><h1>PDF_RENDER_MARKER</h1><table>${repeatedRows}</table></body></html>`;
|
||||
|
||||
try {
|
||||
await ensureDataSource(request, dsName, 'contacts');
|
||||
await ensureMailshot(request, {
|
||||
Purpose: purpose,
|
||||
DataSource: dsName,
|
||||
CC: '',
|
||||
BCC: '',
|
||||
Subject: 'UI render {{ contacts_id }}',
|
||||
Message: '<p>Message {{ contacts_id }}</p>',
|
||||
PDFAttachment: pdfHtml,
|
||||
AttachmentNames: '[]',
|
||||
PDFFilenameDerivedFrom: '',
|
||||
RecipientEmailField: 'contacts.contact_email_1',
|
||||
ReplyTo: ''
|
||||
});
|
||||
|
||||
await page.goto(adminPath('feca-mailshots-test'));
|
||||
await page.locator('#mst_mailshot_id').selectOption({ label: purpose });
|
||||
await page.waitForURL(/mailshot_id=/);
|
||||
|
||||
await page.getByRole('button', { name: 'Render Test (No Send)' }).click();
|
||||
await expect(page.locator('.feca-banner-success')).toContainText('Test action succeeded.');
|
||||
await expect(page.locator('#ms-render-preview-modal')).toBeVisible();
|
||||
await expect(page.frameLocator('#ms-render-pdf-frame').locator('body')).toContainText('PDF_RENDER_MARKER');
|
||||
} finally {
|
||||
try {
|
||||
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ $mailshotsHtml = $capture(static function () use ($container): void {
|
|||
$assertContains('New Mailshot', $mailshotsHtml, 'Mailshots');
|
||||
$assertContains('feca_mailshots_mailshots_ui_save', $mailshotsHtml, 'Mailshots');
|
||||
$assertContains('Existing Mailshots', $mailshotsHtml, 'Mailshots');
|
||||
$assertContains('Sort by', $mailshotsHtml, 'Mailshots');
|
||||
$assertContains('ms_sort_direction', $mailshotsHtml, 'Mailshots');
|
||||
$assertNotContains('Use API endpoint', $mailshotsHtml, 'Mailshots');
|
||||
|
||||
$dataSourcesHtml = $capture(static function () use ($container): void {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ final class FakeMetadataProvider implements SourceMetadataProvider
|
|||
'ads|invoices' => ['left' => 'ads.ID', 'right' => 'invoices.ad_id'],
|
||||
'invoices|issues' => ['left' => 'invoices.issue_id', 'right' => 'issues.ID'],
|
||||
'issues|invoices' => ['left' => 'issues.ID', 'right' => 'invoices.issue_id'],
|
||||
'invoices|pages' => ['left' => 'invoices.issue_id', 'right' => 'pages.Issue'],
|
||||
'pages|invoices' => ['left' => 'pages.Issue', 'right' => 'invoices.issue_id'],
|
||||
];
|
||||
return $pairs[$left . '|' . $right] ?? null;
|
||||
}
|
||||
|
|
@ -72,18 +74,6 @@ final class FakeMetadataProvider implements SourceMetadataProvider
|
|||
if (!isset($this->fields[$source]) || !in_array($field, $this->fields[$source], true)) {
|
||||
return null;
|
||||
}
|
||||
if ($source === 'ads' && $field === 'issue') {
|
||||
return '(SELECT p_issue.`Issue` FROM `pages` AS p_issue WHERE p_issue.`ID` = ' . $alias . '.`PageID`)';
|
||||
}
|
||||
$map = [
|
||||
'advertisers' => ['name' => 'AdvertiserName', 'advertisername' => 'AdvertiserName'],
|
||||
'ads' => ['id' => 'ID', 'advertiser' => 'Advertiser', 'adsize' => 'AdSize', 'size' => 'AdSize', 'price' => 'Price', 'pageid' => 'PageID', 'state' => 'State', 'notes' => 'Notes'],
|
||||
'pages' => ['id' => 'ID', 'issue' => 'Issue', 'page' => 'Page'],
|
||||
'articles' => ['id' => 'ID', 'pageid' => 'PageID', 'articlenumber' => 'ArticleNumber', 'article' => 'ArticleNumber', 'content' => 'Content', 'membername' => 'MemberName', 'author' => 'Author', 'dcn' => 'DCN', 'articlewords' => 'ArticleWords', 'otherwords' => 'OtherWords', 'othercontent' => 'OtherContent', 'other' => 'OtherContent'],
|
||||
'issues' => ['id' => 'ID', 'issue' => 'ID', 'issuemonths' => 'IssueMonths', 'description' => 'Description'],
|
||||
'invoices' => ['id' => 'id', 'issue' => 'issue_id', 'issue_id' => 'issue_id', 'ad_id' => 'ad_id', 'invoice_number' => 'invoice_number', 'status' => 'status'],
|
||||
];
|
||||
$column = $map[$source][$field] ?? $field;
|
||||
return $alias . '.`' . $column . '`';
|
||||
return $alias . '.`' . str_replace('`', '``', $field) . '`';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
define('ABSPATH', dirname(__DIR__, 2) . '/');
|
||||
}
|
||||
|
||||
$GLOBALS['feca_bootstrap_actions'] = [];
|
||||
|
||||
function get_option($key, $default = null)
|
||||
{
|
||||
if ($key !== \FecaMailshots\Admin\SetupAdminPage::OPTION_KEY) {
|
||||
return $default;
|
||||
}
|
||||
return [
|
||||
'db_host' => 'invalid-host.local.test',
|
||||
'db_port' => '3306',
|
||||
'db_user' => 'mailshots',
|
||||
'db_password' => 'secret',
|
||||
'mailshots_db_name' => 'mailshots',
|
||||
'members_db_name' => 'members',
|
||||
'fen_db_name' => 'fen',
|
||||
];
|
||||
}
|
||||
|
||||
function add_action(string $hook, callable $callback): void
|
||||
{
|
||||
if (!isset($GLOBALS['feca_bootstrap_actions'][$hook])) {
|
||||
$GLOBALS['feca_bootstrap_actions'][$hook] = [];
|
||||
}
|
||||
$GLOBALS['feca_bootstrap_actions'][$hook][] = $callback;
|
||||
}
|
||||
|
||||
function add_menu_page(...$args): void
|
||||
{
|
||||
}
|
||||
|
||||
function add_submenu_page(...$args): void
|
||||
{
|
||||
}
|
||||
|
||||
require dirname(__DIR__, 2) . '/feca_mailshots_plugin/feca_mailshots_plugin.php';
|
||||
|
||||
$actions = $GLOBALS['feca_bootstrap_actions'];
|
||||
if (!isset($actions['admin_post_feca_mailshots_data_sources_api'], $actions['admin_post_feca_mailshots_setup_save'])) {
|
||||
fwrite(STDERR, "Expected normal handlers to register without touching the database\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Bootstrap full setup no DB touch regression test passed\n";
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
define('ABSPATH', dirname(__DIR__, 2) . '/');
|
||||
}
|
||||
|
||||
$GLOBALS['feca_bootstrap_actions'] = [];
|
||||
|
||||
function get_option($key, $default = null)
|
||||
{
|
||||
if ($key !== \FecaMailshots\Admin\SetupAdminPage::OPTION_KEY) {
|
||||
return $default;
|
||||
}
|
||||
return [
|
||||
'db_host' => 'db.example.test',
|
||||
'db_port' => '3306',
|
||||
'db_user' => 'mailshots',
|
||||
'db_password' => 'secret',
|
||||
'mailshots_db_name' => 'mailshots',
|
||||
'members_db_name' => 'members',
|
||||
];
|
||||
}
|
||||
|
||||
function add_action(string $hook, callable $callback): void
|
||||
{
|
||||
if (!isset($GLOBALS['feca_bootstrap_actions'][$hook])) {
|
||||
$GLOBALS['feca_bootstrap_actions'][$hook] = [];
|
||||
}
|
||||
$GLOBALS['feca_bootstrap_actions'][$hook][] = $callback;
|
||||
}
|
||||
|
||||
function add_menu_page(...$args): void
|
||||
{
|
||||
}
|
||||
|
||||
function add_submenu_page(...$args): void
|
||||
{
|
||||
}
|
||||
|
||||
require dirname(__DIR__, 2) . '/feca_mailshots_plugin/feca_mailshots_plugin.php';
|
||||
|
||||
$actions = $GLOBALS['feca_bootstrap_actions'];
|
||||
if (!isset($actions['admin_post_feca_mailshots_setup_save'], $actions['admin_post_feca_mailshots_setup_test'])) {
|
||||
fwrite(STDERR, "Expected setup handlers to be registered when FEN DB config is missing\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach (array_keys($actions) as $hook) {
|
||||
if (substr($hook, 0, strlen('admin_post_feca_mailshots_data_sources')) === 'admin_post_feca_mailshots_data_sources') {
|
||||
fwrite(STDERR, "DB-backed data source handlers should not register while setup is incomplete\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Bootstrap missing FEN setup regression test passed\n";
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
|
||||
|
||||
use FecaMailshots\Application\MailshotRunService;
|
||||
|
||||
if (!class_exists('Dompdf\\Dompdf')) {
|
||||
fwrite(STDERR, "Dompdf is not available\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!function_exists('shell_exec') || trim((string) shell_exec('command -v pdftotext 2>/dev/null')) === '') {
|
||||
echo "Dompdf unicode symbol test skipped: pdftotext is not available\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$service = (new ReflectionClass(MailshotRunService::class))->newInstanceWithoutConstructor();
|
||||
$method = new ReflectionMethod(MailshotRunService::class, 'renderPdfBytesFromHtml');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$html = <<<'HTML'
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: Helvetica, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>✉ advertising@fenedge.co.uk</p>
|
||||
<p>📞 01954 250082</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$pdfBytes = $method->invoke($service, $html);
|
||||
if (!is_string($pdfBytes) || strpos($pdfBytes, '%PDF') !== 0) {
|
||||
fwrite(STDERR, "Expected rendered PDF bytes\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdfPath = tempnam(sys_get_temp_dir(), 'feca_dompdf_unicode_');
|
||||
if (!is_string($pdfPath) || $pdfPath === '') {
|
||||
fwrite(STDERR, "Failed to create temporary PDF path\n");
|
||||
exit(1);
|
||||
}
|
||||
$pdfFile = $pdfPath . '.pdf';
|
||||
@rename($pdfPath, $pdfFile);
|
||||
|
||||
try {
|
||||
if (@file_put_contents($pdfFile, $pdfBytes) === false) {
|
||||
fwrite(STDERR, "Failed to write temporary PDF\n");
|
||||
exit(1);
|
||||
}
|
||||
$text = (string) shell_exec('pdftotext ' . escapeshellarg($pdfFile) . ' - 2>/dev/null');
|
||||
if (strpos($text, '✉ advertising@fenedge.co.uk') === false) {
|
||||
fwrite(STDERR, "Expected email symbol in PDF text, got: " . $text . "\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($text, '☎ 01954 250082') === false) {
|
||||
fwrite(STDERR, "Expected normalized phone symbol in PDF text, got: " . $text . "\n");
|
||||
exit(1);
|
||||
}
|
||||
} finally {
|
||||
@unlink($pdfFile);
|
||||
}
|
||||
|
||||
echo "Dompdf unicode symbol regression test passed\n";
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
|
||||
|
||||
use FecaMailshots\Admin\DownloadPdfAdminPage;
|
||||
|
||||
$page = (new ReflectionClass(DownloadPdfAdminPage::class))->newInstanceWithoutConstructor();
|
||||
$method = new ReflectionMethod(DownloadPdfAdminPage::class, 'safeDownloadBaseName');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$cases = [
|
||||
'Renewals (pending accounts and contacts)' => 'Renewals_pending_accounts_and_contacts',
|
||||
'Invoice run: May/June 2026' => 'Invoice_run_May_June_2026',
|
||||
' ... ' => 'mailshot',
|
||||
'Quotes " and spaces' => 'Quotes_and_spaces',
|
||||
];
|
||||
|
||||
foreach ($cases as $input => $expected) {
|
||||
$actual = $method->invoke($page, $input);
|
||||
if ($actual !== $expected) {
|
||||
fwrite(STDERR, sprintf("Expected %s => %s, got %s\n", $input, $expected, (string) $actual));
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
echo "Download PDF filename regression test passed\n";
|
||||
|
|
@ -10,45 +10,27 @@ use FecaMailshots\Domain\DslParser;
|
|||
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
|
||||
|
||||
$metadata = new FakeMetadataProvider([
|
||||
'accounts' => ['id', 'name', 'account_type_id', 'sector_id', 'public_location_id', 'type', 'public_location', 'account_sector'],
|
||||
'accounts' => ['id', 'name', 'account_type_id', 'sector_id', 'public_location_id'],
|
||||
'contacts' => ['id', 'account_id', 'contact_email_1'],
|
||||
]);
|
||||
|
||||
$parser = new DslParser();
|
||||
$compiler = new DslCompiler($metadata);
|
||||
|
||||
$ast = $parser->parse("accounts and contacts where accounts.type = 'Member'");
|
||||
$ast = $parser->parse("accounts and contacts where accounts.account_type_id = 1");
|
||||
$compiled = $compiler->compile($ast);
|
||||
$sql = (string) ($compiled['sql'] ?? '');
|
||||
|
||||
if (strpos($sql, 'LEFT JOIN `picklist_account_type`') === false) {
|
||||
fwrite(STDERR, "Expected implicit join to picklist_account_type\n");
|
||||
if (strpos($sql, 'picklist_') !== false) {
|
||||
fwrite(STDERR, "Did not expect implicit picklist joins in physical-schema projection\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($sql, 'LEFT JOIN `picklist_public_location`') === false) {
|
||||
fwrite(STDERR, "Expected implicit join to picklist_public_location\n");
|
||||
if (strpos($sql, 's_accounts.`account_type_id` AS `accounts.account_type_id`') === false) {
|
||||
fwrite(STDERR, "Expected physical accounts.account_type_id projection\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($sql, 'LEFT JOIN `picklist_sector`') === false) {
|
||||
fwrite(STDERR, "Expected implicit join to picklist_sector\n");
|
||||
if (strpos($sql, 's_accounts.`account_type_id` = ?') === false) {
|
||||
fwrite(STDERR, "Expected predicate accounts.account_type_id to compile against physical column\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($sql, '`p_account_type`.`value` AS `accounts.type`') === false) {
|
||||
fwrite(STDERR, "Expected accounts.type projection from picklist_account_type.value\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($sql, '`p_public_location`.`value` AS `accounts.public_location`') === false) {
|
||||
fwrite(STDERR, "Expected accounts.public_location projection from picklist_public_location.value\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($sql, '`p_sector`.`value` AS `accounts.account_sector`') === false) {
|
||||
fwrite(STDERR, "Expected accounts.account_sector projection from picklist_sector.value\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($sql, '`p_account_type`.`value` = ?') === false) {
|
||||
fwrite(STDERR, "Expected predicate accounts.type to compile against picklist value\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "DSL accounts picklist join regression test passed\n";
|
||||
|
||||
echo "DSL accounts physical-schema regression test passed\n";
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ use FecaMailshots\Domain\DslParser;
|
|||
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
|
||||
|
||||
$metadata = new FakeMetadataProvider([
|
||||
'advertisers' => ['name', 'advertisername'],
|
||||
'ads' => ['id', 'advertiser', 'adsize', 'size', 'price', 'issue', 'pageid', 'state', 'notes'],
|
||||
'pages' => ['id', 'issue', 'page'],
|
||||
'articles' => ['id', 'pageid', 'articlenumber', 'article', 'content', 'membername', 'author', 'dcn', 'articlewords', 'otherwords', 'othercontent', 'other'],
|
||||
'issues' => ['id', 'issue', 'issuemonths', 'description'],
|
||||
'advertisers' => ['AdvertiserName', 'Selected', 'IsLapsed?'],
|
||||
'ads' => ['ID', 'Advertiser', 'AdSize', 'Price', 'PageID', 'State', 'Notes'],
|
||||
'pages' => ['ID', 'Issue', 'Page'],
|
||||
'articles' => ['ID', 'PageID', 'ArticleNumber', 'Content', 'MemberName', 'Author', 'DCN', 'ArticleWords', 'OtherWords', 'OtherContent'],
|
||||
'issues' => ['ID', 'IssueMonths', 'Description'],
|
||||
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'status'],
|
||||
'contacts' => ['id', 'account_id', 'last_name', 'contact_email_1', 'is_fen_1', 'is_deleted'],
|
||||
'accounts' => ['id', 'name', 'is_deleted'],
|
||||
]);
|
||||
|
||||
$parser = new DslParser();
|
||||
|
|
@ -24,11 +26,11 @@ $validator = new DslValidator($metadata);
|
|||
$compiler = new DslCompiler($metadata);
|
||||
|
||||
$cases = [
|
||||
'ads and pages where ad-in-issue(202605)' => [
|
||||
'needle' => 'p_ad_issue.`Issue` = ?',
|
||||
'ads and pages where issue(202605)' => [
|
||||
'needle' => 's_pages.`Issue` = ?',
|
||||
'params' => [202605],
|
||||
],
|
||||
'articles and pages where page-in-issue(202605)' => [
|
||||
'articles and pages where issue(202605)' => [
|
||||
'needle' => 's_pages.`Issue` = ?',
|
||||
'params' => [202605],
|
||||
],
|
||||
|
|
@ -61,4 +63,135 @@ foreach ($cases as $dsl => $expect) {
|
|||
}
|
||||
}
|
||||
|
||||
$projectionDsl = 'advertisers and ads where issue(125)';
|
||||
$projectionAst = $parser->parse($projectionDsl);
|
||||
$projectionValidation = $validator->validate($projectionAst);
|
||||
if (($projectionValidation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$projectionDsl}: " . json_encode($projectionValidation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$projection = $compiler->compile($projectionAst);
|
||||
$projectionSql = (string) ($projection['sql'] ?? '');
|
||||
foreach ([
|
||||
'advertisers.AdvertiserName',
|
||||
'advertisers.Selected',
|
||||
'advertisers.IsLapsed?',
|
||||
'ads.ID',
|
||||
'ads.Advertiser',
|
||||
'ads.AdSize',
|
||||
'ads.Price',
|
||||
'ads.PageID',
|
||||
'ads.State',
|
||||
'ads.Notes',
|
||||
] as $qualifiedField) {
|
||||
if (strpos($projectionSql, ' AS `' . $qualifiedField . '`') === false) {
|
||||
fwrite(STDERR, "Expected projection for {$projectionDsl} to include {$qualifiedField}\n{$projectionSql}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$quotedFieldDsl = 'advertisers where advertisers.`IsLapsed?` = true';
|
||||
$quotedFieldAst = $parser->parse($quotedFieldDsl);
|
||||
$quotedFieldValidation = $validator->validate($quotedFieldAst);
|
||||
if (($quotedFieldValidation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$quotedFieldDsl}: " . json_encode($quotedFieldValidation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$quotedFieldSql = (string) ($compiler->compile($quotedFieldAst)['sql'] ?? '');
|
||||
if (strpos($quotedFieldSql, 's_advertisers.`IsLapsed?` = ?') === false) {
|
||||
fwrite(STDERR, "Expected quoted physical field to compile directly\n{$quotedFieldSql}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$issueCases = [
|
||||
'issues where issue(202605)' => 's_issues.`ID` = ?',
|
||||
'articles where issue(202605)' => 'p_article_issue.`Issue` = ?',
|
||||
'ads where issue(202605)' => 'p_ad_issue.`Issue` = ?',
|
||||
'advertisers where issue(202605)' => 'p_adv_issue.`Issue` = ?',
|
||||
'invoices where issue(202605)' => 's_invoices.`issue_id` = ?',
|
||||
'contacts and accounts and issues where fen1-contact and issue(202605)' => 'CROSS JOIN `issues` AS s_issues',
|
||||
];
|
||||
foreach ($issueCases as $dsl => $needle) {
|
||||
$ast = $parser->parse($dsl);
|
||||
$validation = $validator->validate($ast);
|
||||
if (($validation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$dsl}: " . json_encode($validation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$sql = (string) ($compiler->compile($ast)['sql'] ?? '');
|
||||
if (strpos($sql, $needle) === false) {
|
||||
fwrite(STDERR, "Expected issue filter SQL for {$dsl} to contain {$needle}\n{$sql}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$softDeleteDsl = 'contacts and accounts where fen1-contact';
|
||||
$softDeleteAst = $parser->parse($softDeleteDsl);
|
||||
$softDeleteValidation = $validator->validate($softDeleteAst);
|
||||
if (($softDeleteValidation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$softDeleteDsl}: " . json_encode($softDeleteValidation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$softDeleteSql = (string) ($compiler->compile($softDeleteAst)['sql'] ?? '');
|
||||
foreach ([
|
||||
'(s_contacts.`is_deleted` IS NULL OR s_contacts.`is_deleted` + 0 = 0)',
|
||||
'(s_accounts.`is_deleted` IS NULL OR s_accounts.`is_deleted` + 0 = 0)',
|
||||
] as $needle) {
|
||||
if (strpos($softDeleteSql, $needle) === false) {
|
||||
fwrite(STDERR, "Expected soft-delete predicate {$needle}\n{$softDeleteSql}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
$articleExclusionDsl = 'contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council and not account-has-article-in-issue(125)';
|
||||
$articleExclusionAst = $parser->parse($articleExclusionDsl);
|
||||
$articleExclusionValidation = $validator->validate($articleExclusionAst);
|
||||
if (($articleExclusionValidation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$articleExclusionDsl}: " . json_encode($articleExclusionValidation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$articleExclusionCompiled = $compiler->compile($articleExclusionAst);
|
||||
$articleExclusionSql = (string) ($articleExclusionCompiled['sql'] ?? '');
|
||||
foreach ([
|
||||
'NOT (EXISTS (SELECT 1 FROM `articles` AS article_account_issue',
|
||||
'article_account_issue.`MemberName`',
|
||||
'COLLATE utf8mb4_unicode_ci',
|
||||
'page_account_issue.`Issue` = ?',
|
||||
] as $needle) {
|
||||
if (strpos($articleExclusionSql, $needle) === false) {
|
||||
fwrite(STDERR, "Expected article exclusion SQL to contain {$needle}\n{$articleExclusionSql}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
if (($articleExclusionCompiled['params'] ?? []) !== [125, 125]) {
|
||||
fwrite(STDERR, "Unexpected article exclusion params: " . json_encode($articleExclusionCompiled['params'] ?? []) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$blankDsl = 'advertisers where advertisers.AdvertiserName blank';
|
||||
$blankAst = $parser->parse($blankDsl);
|
||||
$blankValidation = $validator->validate($blankAst);
|
||||
if (($blankValidation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$blankDsl}: " . json_encode($blankValidation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$blankSql = (string) ($compiler->compile($blankAst)['sql'] ?? '');
|
||||
if (strpos($blankSql, "(s_advertisers.`AdvertiserName` IS NULL OR s_advertisers.`AdvertiserName` = '')") === false) {
|
||||
fwrite(STDERR, "Expected blank field test to compile as null-or-empty check\n{$blankSql}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$notBlankDsl = 'advertisers where advertisers.AdvertiserName != blank';
|
||||
$notBlankAst = $parser->parse($notBlankDsl);
|
||||
$notBlankValidation = $validator->validate($notBlankAst);
|
||||
if (($notBlankValidation['errors'] ?? []) !== []) {
|
||||
fwrite(STDERR, "Expected valid DSL {$notBlankDsl}: " . json_encode($notBlankValidation['errors']) . "\n");
|
||||
exit(1);
|
||||
}
|
||||
$notBlankSql = (string) ($compiler->compile($notBlankAst)['sql'] ?? '');
|
||||
if (strpos($notBlankSql, "(s_advertisers.`AdvertiserName` IS NOT NULL AND s_advertisers.`AdvertiserName` <> '')") === false) {
|
||||
fwrite(STDERR, "Expected != blank field test to compile as not-null-and-not-empty check\n{$notBlankSql}\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "DSL FEN built-in tests passed\n";
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use FecaMailshots\Domain\DslParser;
|
|||
use FecaMailshots\Tests\Unit\FakeMetadataProvider;
|
||||
|
||||
$metadata = new FakeMetadataProvider([
|
||||
'contacts' => ['ID', 'Accountid', 'Last', 'Email', 'FENContact1'],
|
||||
'accounts' => ['ID', 'Name', 'Type'],
|
||||
'contacts' => ['ID', 'Accountid', 'Last', 'Email', 'FENContact1', 'is_deleted'],
|
||||
'accounts' => ['ID', 'Name', 'Type', 'is_deleted'],
|
||||
'renewals' => ['id', 'account_id', 'status', 'selected'],
|
||||
'advertisers' => ['name', 'advertisername'],
|
||||
'ads' => ['id', 'advertiser', 'adsize', 'size', 'price', 'issue', 'pageid', 'state', 'notes'],
|
||||
|
|
@ -21,6 +21,7 @@ $metadata = new FakeMetadataProvider([
|
|||
'issues' => ['id', 'issue', 'issuemonths', 'description'],
|
||||
'invoices' => ['id', 'issue', 'issue_id', 'ad_id', 'invoice_number', 'status'],
|
||||
'members.ExcludedAccounts' => ['ExcludedAccount'],
|
||||
'fenedgec_members.renewal_accounts_with_contacts' => ['renewal_id', 'account_id', 'contact_1_phone_1'],
|
||||
]);
|
||||
|
||||
$parser = new DslParser();
|
||||
|
|
@ -41,11 +42,11 @@ $cases = [
|
|||
'expectValid' => false,
|
||||
],
|
||||
[
|
||||
'dsl' => 'ads and pages where ad-in-issue(202605)',
|
||||
'dsl' => 'ads and pages where issue(202605)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'articles and pages where page-in-issue(202605)',
|
||||
'dsl' => 'articles and pages where issue(202605)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
|
|
@ -53,13 +54,57 @@ $cases = [
|
|||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'ads where page-in-issue(202605)',
|
||||
'expectValid' => false,
|
||||
'dsl' => 'invoices and ads and pages and advertisers where issue(125)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'ads where issue(202605)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => "invoices where invoice-ids('abc')",
|
||||
'expectValid' => false,
|
||||
],
|
||||
[
|
||||
'dsl' => 'fenedgec_members.renewal_accounts_with_contacts',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'fenedgec_members.renewal_accounts_with_contacts where fenedgec_members.renewal_accounts_with_contacts.contact_1_phone_1 blank',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'issues where issue(202605)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'articles where issue(202605)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'advertisers where issue(202605)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'pages where ad-in-issue(202605)',
|
||||
'expectValid' => false,
|
||||
],
|
||||
[
|
||||
'dsl' => 'contacts and accounts and issues where fen1-contact and issue(125)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'contacts and accounts and issues where fen1-contact',
|
||||
'expectValid' => false,
|
||||
],
|
||||
[
|
||||
'dsl' => 'contacts and accounts and issues where fen1-contact and issue(125) and member-or-affiliate-or-parish-council and not account-has-article-in-issue(125)',
|
||||
'expectValid' => true,
|
||||
],
|
||||
[
|
||||
'dsl' => 'contacts where account-has-article-in-issue(125)',
|
||||
'expectValid' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$failures = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
|
||||
|
||||
use FecaMailshots\WordPress\ProductionWordPressFacade;
|
||||
|
||||
$GLOBALS['feca_registered_menu_callback'] = null;
|
||||
|
||||
function add_action(string $hook, callable $callback): void
|
||||
{
|
||||
}
|
||||
|
||||
function add_menu_page(...$args): void
|
||||
{
|
||||
$GLOBALS['feca_registered_menu_callback'] = $args[4] ?? null;
|
||||
}
|
||||
|
||||
function add_submenu_page(...$args): void
|
||||
{
|
||||
}
|
||||
|
||||
$facade = new ProductionWordPressFacade();
|
||||
$facade->addMenuPage(
|
||||
'FECA Mailshots',
|
||||
'FECA Mailshots',
|
||||
'manage_options',
|
||||
'feca-mailshot',
|
||||
static function (): void {
|
||||
throw new RuntimeException('database connection failed');
|
||||
}
|
||||
);
|
||||
|
||||
$callback = $GLOBALS['feca_registered_menu_callback'];
|
||||
if (!is_callable($callback)) {
|
||||
fwrite(STDERR, "Expected guarded menu callback to be registered\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
ob_start();
|
||||
$callback();
|
||||
$html = (string) ob_get_clean();
|
||||
|
||||
if (strpos($html, 'Mailshots plugin error: database connection failed') === false) {
|
||||
fwrite(STDERR, "Expected guarded callback to render an admin error notice\n");
|
||||
fwrite(STDERR, $html . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "Production facade error boundary regression test passed\n";
|
||||
|
|
@ -17,17 +17,18 @@ if (!is_string($pngBytes) || $pngBytes === '') {
|
|||
|
||||
$renderer = new TemplateRenderer(
|
||||
static function (string $name) use ($pngBytes): ?array {
|
||||
if (strtolower(trim($name)) !== 'logo_asset') {
|
||||
$assetName = strtolower(trim($name));
|
||||
if (!in_array($assetName, ['logo_asset', 'right_logo_asset'], true)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'name' => 'logo_asset',
|
||||
'name' => $assetName,
|
||||
'file_name' => 'logo.png',
|
||||
'mime_type' => 'image/png',
|
||||
'file_bytes' => $pngBytes,
|
||||
'width_mm' => 20,
|
||||
'height_mm' => 10,
|
||||
'justification' => 'left',
|
||||
'justification' => $assetName === 'right_logo_asset' ? 'right' : 'left',
|
||||
];
|
||||
}
|
||||
);
|
||||
|
|
@ -53,6 +54,37 @@ if (strpos($pdfHtml, 'width:20mm;height:10mm;') === false) {
|
|||
exit(1);
|
||||
}
|
||||
|
||||
$rightRendered = $renderer->render(
|
||||
'Subject',
|
||||
'<p>Body</p>',
|
||||
'<p>{{ pdf_asset("right_logo_asset") }}Text should wrap to the left of the floated image.</p>',
|
||||
[]
|
||||
);
|
||||
$rightPdfHtml = (string) ($rightRendered['pdf_attachment'] ?? '');
|
||||
if (strpos($rightPdfHtml, 'float:right') === false) {
|
||||
fwrite(STDERR, "pdf_asset did not float right-justified image\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($rightPdfHtml, 'text-align:right') !== false) {
|
||||
fwrite(STDERR, "pdf_asset right justification should not use a block text-align wrapper\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$booleanRendered = $renderer->render(
|
||||
'{{ advertisers_islapsed }} {{ advertisers_selected }} {{ advertisers_advertisername }}',
|
||||
'<p>{{ advertisers_islapsed }} {{ advertisers_selected }} {{ advertisers_advertisername }}</p>',
|
||||
'',
|
||||
[
|
||||
'advertisers.IsLapsed?' => "\xff",
|
||||
'advertisers.Selected' => '0',
|
||||
'advertisers.AdvertiserName' => 'A R Aspinall & Sons Ltd',
|
||||
]
|
||||
);
|
||||
if (($booleanRendered['subject'] ?? '') !== 'Yes No A R Aspinall & Sons Ltd') {
|
||||
fwrite(STDERR, "Boolean-ish template fields were not rendered readably: " . ($booleanRendered['subject'] ?? '') . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$renderer->render('S', 'M', '{{ pdf_asset("missing_asset") }}', []);
|
||||
fwrite(STDERR, "Expected hard error for missing pdf_asset, but render succeeded\n");
|
||||
|
|
|
|||
Loading…
Reference in New Issue