diff --git a/feca_mailshots_plugin/feca_mailshots_plugin.php b/feca_mailshots_plugin/feca_mailshots_plugin.php index 4c0774e..1f839f8 100644 --- a/feca_mailshots_plugin/feca_mailshots_plugin.php +++ b/feca_mailshots_plugin/feca_mailshots_plugin.php @@ -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 diff --git a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php index 5408d6c..09e6719 100644 --- a/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/DataSourcesAdminPage.php @@ -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;i0?" 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="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;i0?" 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;i0){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="";}};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();};}'; diff --git a/feca_mailshots_plugin/src/Admin/DownloadPdfAdminPage.php b/feca_mailshots_plugin/src/Admin/DownloadPdfAdminPage.php index aabe53b..bd4ddb9 100644 --- a/feca_mailshots_plugin/src/Admin/DownloadPdfAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/DownloadPdfAdminPage.php @@ -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')) { diff --git a/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php b/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php index 188af8e..bcca5a6 100644 --- a/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php +++ b/feca_mailshots_plugin/src/Admin/MailshotTestAdminPage.php @@ -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 '
'; echo '

1. Select Mailshot

'; - echo '
'; + echo ''; echo ''; echo '
'; echo '
'; @@ -116,16 +134,17 @@ final class MailshotTestAdminPage echo ''; } echo '
'; - echo '
'; echo '
'; echo '
'; + echo ''; echo '
'; if (!empty($preview['errors'])) { echo '
' . htmlspecialchars(implode('; ', $preview['errors'])) . '
'; } - echo '
'; + echo '
'; + echo ''; echo '

2. Render / Send Test

'; echo $this->hiddenNonceField(self::NONCE_ACTION); echo ''; @@ -153,10 +172,16 @@ final class MailshotTestAdminPage echo ''; echo ''; echo '
'; - echo '
'; + echo '
'; echo ''; echo '
'; echo '