v1 completed, tested.

This commit is contained in:
Adrian Stephens 2026-04-24 07:54:38 +01:00
parent 63e2daaa9b
commit 06fddc641f
34 changed files with 1298 additions and 697 deletions

BIN
dist/feca_mailshots_plugin-0.1.100.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-0.1.96.zip vendored Normal file

Binary file not shown.

BIN
dist/feca_mailshots_plugin-1.0.0.zip vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,55 @@
# UI Design Review (2026-04-23)
Reviewed against [`requirements/ui_design.md`](../requirements/ui_design.md).
## Summary
The plugin has improved control alignment/spacing and better internal scrolling on key pages (notably Review Recipients and Download PDF), but several global UI design requirements are still only partially implemented.
## Implemented / Largely Implemented
- WordPress admin-page navigation model is used across the plugin.
- Destructive actions use confirmation and red-link/button treatment in most CRUD lists.
- Modal editors generally use `Save` / `Quit` and preserve draft state on validation failure.
- Control label/control alignment and spacing were improved on pages with filter/selection controls.
- Long-running feedback is present for Download PDF (progress panel shown on submit).
- Review Recipients provides:
- full-page recipient inspection
- filter across fields
- sort-by + direction controls
- internal scroll region with sticky header row
- row highlight selection.
## Gaps
1. **Standard Page Region Consistency (global)**
- Requirement expects consistent context/information/statistics/actions/data pane structure where applicable.
- Current pages use ad-hoc inline layouts with inconsistent region structure and styling.
2. **Multi-pane data area splitters**
- Draggable splitters with persisted ratios are not implemented.
- No per-page split-ratio persistence exists.
3. **Default table behavior: column resizing + persistence**
- User-resizable column widths (including Firefox) are not implemented as a shared table behavior.
- Persisted widths per table are not implemented.
4. **Resize stability requirements**
- Because column-resize behavior is not implemented, the associated non-reload stability guarantees are also not yet implemented/tested.
5. **Long-operation progress coverage**
- Download PDF has progress feedback, but this is not consistently applied to all operations that may exceed 1s (for example some validate/preview/run flows depending on dataset size).
6. **Horizontal scrolling policy**
- Requirement says avoid horizontal scrolling when content width can be adjusted.
- Review Recipients intentionally uses horizontal scroll for wide datasets; this is acceptable for usability, but should be documented as an explicit page-level exception to avoid ambiguity.
## Recommended Next Steps
1. Introduce a shared admin UI layout helper (PHP render helpers + shared CSS class contract) for pane regions.
2. Introduce a shared table component for:
- edge-drag resize
- width persistence
- sort dropdown + direction wiring where needed.
3. Add a reusable progress-status helper for long-running form/API actions.
4. Add explicit requirement note for Review Recipients horizontal-scroll exception.

View File

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

View File

@ -3,9 +3,8 @@ Contributors: feca
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.5 Tested up to: 6.5
Requires PHP: 7.4 Requires PHP: 7.4
Stable tag: 0.0.0 Stable tag: 1.0.0
License: GPLv2 or later License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
Barebones plugin scaffold. Barebones plugin scaffold.

View File

@ -6,6 +6,109 @@ namespace FecaMailshots\Admin;
trait AdminRequestHelpers trait AdminRequestHelpers
{ {
private function renderAdminUiStyles(): string
{
return '<style id="feca-mailshots-admin-ui">'
. '.feca-mailshots-admin{--feca-space-1:6px;--feca-space-2:10px;--feca-space-3:14px;--feca-space-4:18px;--feca-radius:4px;}'
. '.feca-mailshots-admin h1{margin-bottom:var(--feca-space-3);}'
. '.feca-mailshots-admin h2{margin:var(--feca-space-4) 0 var(--feca-space-2) 0;line-height:1.3;}'
. '.feca-mailshots-admin h3{margin:var(--feca-space-3) 0 var(--feca-space-2) 0;line-height:1.3;}'
. '.feca-mailshots-admin p{line-height:1.45;}'
. '.feca-mailshots-admin .feca-panel{padding:var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin-bottom:var(--feca-space-3);border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-panel-tight{margin-bottom:0;}'
. '.feca-mailshots-admin .feca-panel-flex{flex:1;margin:0;}'
. '.feca-mailshots-admin .feca-control-row{display:flex;gap:var(--feca-space-3);align-items:flex-end;flex-wrap:wrap;margin:var(--feca-space-2) 0;}'
. '.feca-mailshots-admin .feca-control{display:flex;align-items:center;gap:var(--feca-space-2);min-width:260px;padding:var(--feca-space-1) 0;}'
. '.feca-mailshots-admin .feca-control-min-140{min-width:140px;}'
. '.feca-mailshots-admin .feca-control-min-170{min-width:170px;}'
. '.feca-mailshots-admin .feca-control-min-220{min-width:220px;}'
. '.feca-mailshots-admin .feca-control-min-240{min-width:240px;}'
. '.feca-mailshots-admin .feca-control-min-260{min-width:260px;}'
. '.feca-mailshots-admin .feca-control-min-280{min-width:280px;}'
. '.feca-mailshots-admin .feca-control-min-320{min-width:320px;}'
. '.feca-mailshots-admin .feca-control-min-360{min-width:360px;}'
. '.feca-mailshots-admin .feca-control-min-380{min-width:380px;}'
. '.feca-mailshots-admin .feca-control-min-420{min-width:420px;}'
. '.feca-mailshots-admin .feca-minw-140{min-width:140px;}'
. '.feca-mailshots-admin .feca-minw-170{min-width:170px;}'
. '.feca-mailshots-admin .feca-minw-220{min-width:220px;}'
. '.feca-mailshots-admin .feca-minw-240{min-width:240px;}'
. '.feca-mailshots-admin .feca-control input,.feca-mailshots-admin .feca-control select,.feca-mailshots-admin .feca-control textarea{margin:0;}'
. '.feca-mailshots-admin .feca-control label{white-space:nowrap;padding-left:var(--feca-space-1);font-weight:600;}'
. '.feca-mailshots-admin .feca-button-row{display:flex;gap:var(--feca-space-2);align-items:center;flex-wrap:wrap;margin-top:var(--feca-space-3);margin-bottom:0;padding:0;}'
. '.feca-mailshots-admin .feca-button-row .button{margin:0 !important;}'
. '.feca-mailshots-admin .feca-button-danger{color:#b32d2e;border-color:#d63638;}'
. '.feca-mailshots-admin .feca-button-danger:hover{color:#8a2424;border-color:#8a2424;background:#fff5f5;}'
. '.feca-mailshots-admin .feca-banner{padding:var(--feca-space-2) var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin:var(--feca-space-3) 0;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-banner-success{border-color:#8bc34a;background:#f1f8e9;}'
. '.feca-mailshots-admin .feca-banner-error{border-color:#ef9a9a;background:#ffebee;}'
. '.feca-mailshots-admin .feca-banner-info{border-color:#c8d7e1;background:#f6fbff;}'
. '.feca-mailshots-admin .feca-banner-muted{background:#f9f9f9;}'
. '.feca-mailshots-admin .feca-banner-note{margin:var(--feca-space-1) 0 0 0;}'
. '.feca-mailshots-admin .feca-note-danger{color:#b32d2e;}'
. '.feca-mailshots-admin .feca-form{padding:var(--feca-space-3);border:1px solid #dcdcde;background:#fff;margin-bottom:var(--feca-space-3);border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-form-max-860{max-width:860px;}'
. '.feca-mailshots-admin .feca-form .form-table{margin-top:0;}'
. '.feca-mailshots-admin .feca-form .form-table th,.feca-mailshots-admin .feca-form .form-table td{padding-top:12px;padding-bottom:12px;}'
. '.feca-mailshots-admin .feca-form .form-table th{width:220px;}'
. '.feca-mailshots-admin .feca-form .description{margin-top:var(--feca-space-1);}'
. '.feca-mailshots-admin .feca-inline-form{display:inline;}'
. '.feca-mailshots-admin .feca-hidden{display:none;}'
. '.feca-mailshots-admin .feca-meta-line{margin:4px 0 var(--feca-space-3) 0;color:#6b7280;font-size:12px;}'
. '.feca-mailshots-admin .feca-modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;}'
. '.feca-mailshots-admin .feca-modal-overlay-high{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;}'
. '.feca-mailshots-admin .feca-modal-shell{max-width:980px;margin:30px auto;background:#fff;padding:var(--feca-space-3);max-height:88vh;overflow:auto;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-modal-shell-wide{max-width:1100px;}'
. '.feca-mailshots-admin .feca-modal-shell-narrow{max-width:900px;}'
. '.feca-mailshots-admin .feca-modal-title{margin-top:0;}'
. '.feca-mailshots-admin .feca-section-title{margin:0 0 8px 0;}'
. '.feca-mailshots-admin .feca-modal-subtitle{margin:0 0 8px 0;}'
. '.feca-mailshots-admin .feca-close-row{justify-content:flex-end;margin-top:0;}'
. '.feca-mailshots-admin .feca-truncate-220{max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
. '.feca-mailshots-admin .feca-truncate-360{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
. '.feca-mailshots-admin .feca-truncate-420{max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}'
. '.feca-mailshots-admin .feca-warning-inline{display:none;margin-top:8px;padding:8px;border-left:4px solid #dba617;background:#fff8e1;}'
. '.feca-mailshots-admin .feca-progress-box{display:none;margin-top:var(--feca-space-2);padding:var(--feca-space-2);border:1px solid #c8d7e1;background:#f6fbff;max-width:520px;border-radius:var(--feca-radius);}'
. '.feca-mailshots-admin .feca-builder-shell{max-width:980px;margin:30px auto;background:#fff;padding:14px;border-radius:4px;max-height:88vh;overflow:auto;}'
. '.feca-mailshots-admin .feca-builder-grid{display:flex;gap:14px;flex-wrap:wrap;}'
. '.feca-mailshots-admin .feca-builder-col-left{flex:1;min-width:260px;border:1px solid #dcdcde;padding:10px;}'
. '.feca-mailshots-admin .feca-builder-col-right{flex:2;min-width:340px;border:1px solid #dcdcde;padding:10px;}'
. '.feca-mailshots-admin .feca-builder-col-left label,.feca-mailshots-admin .feca-builder-col-right label{display:inline-flex;align-items:center;gap:6px;margin:4px 0;}'
. '.feca-mailshots-admin .feca-builder-col-left select,.feca-mailshots-admin .feca-builder-col-right select,.feca-mailshots-admin .feca-builder-col-left input,.feca-mailshots-admin .feca-builder-col-right input{margin:6px 0;}'
. '.feca-mailshots-admin .feca-builder-row{border:1px solid #dcdcde;padding:10px;margin-bottom:10px;}'
. '.feca-mailshots-admin .feca-builder-row > *{margin:6px 8px 6px 0;}'
. '.feca-mailshots-admin .feca-builder-filter-select{display:block;min-width:260px;}'
. '.feca-mailshots-admin .feca-builder-output{margin-top:12px;border:1px solid #dcdcde;padding:10px;background:#f9f9f9;}'
. '.feca-mailshots-admin .feca-error-text{color:#a00;margin:6px 0 0 0;}'
. '.feca-mailshots-admin .feca-template-panel{max-width:980px;margin:30px auto;background:#fff;padding:12px;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;}'
. '.feca-mailshots-admin .feca-template-title{margin:0 0 8px 0;flex:0 0 auto;}'
. '.feca-mailshots-admin .feca-template-error{display:none;margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;}'
. '.feca-mailshots-admin .feca-template-token-controls{margin-bottom:10px;}'
. '.feca-mailshots-admin .feca-template-assets{display:none;margin-bottom:10px;}'
. '.feca-mailshots-admin .feca-template-editor-wrap{flex:1 1 auto;min-height:320px;height:60vh;max-height:60vh;overflow:auto;}'
. '.feca-mailshots-admin .feca-template-editor{height:100%;}'
. '.feca-mailshots-admin .feca-template-actions{margin:10px 0 0 0;flex:0 0 auto;}'
. '.feca-mailshots-admin .feca-template-panel .jodit-container{height:100% !important;}'
. '.feca-mailshots-admin .feca-details{margin-top:8px;}'
. '.feca-mailshots-admin .feca-details pre{margin-top:8px;}'
. '.feca-mailshots-admin .feca-scroll-frame{border:1px solid #dcdcde;background:#fff;max-width:calc(100vw - 80px);border-radius:var(--feca-radius);margin-top:var(--feca-space-2);}'
. '.feca-mailshots-admin .feca-scroll-pane{overflow:scroll;max-height:68vh;scrollbar-gutter:stable both-edges;}'
. '.feca-mailshots-admin .feca-table-wide{min-width:100%;width:max-content;margin:0;border-collapse:separate;border-spacing:0;}'
. '.feca-mailshots-admin .feca-rr-head-cell{position:sticky;top:0;z-index:2;background:#f6f7f7;white-space:normal;vertical-align:bottom;max-width:170px;padding:8px;}'
. '.feca-mailshots-admin .feca-rr-head-wrap{line-height:1.2;}'
. '.feca-mailshots-admin .feca-rr-head-prefix{display:block;font-size:11px;color:#646970;word-break:break-word;}'
. '.feca-mailshots-admin .feca-rr-head-field{display:block;font-size:12px;font-weight:600;word-break:break-word;}'
. '.feca-mailshots-admin .feca-rr-data-cell{white-space:nowrap;max-width:20ch;width:20ch;min-width:20ch;overflow:hidden;text-overflow:ellipsis;}'
. '.feca-mailshots-admin .feca-rr-row-selected td{background:#e8f4ff;}'
. '.feca-mailshots-admin .feca-preview-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;}'
. '.feca-mailshots-admin .feca-preview-iframe{width:100%;height:320px;border:1px solid #dcdcde;background:#fff;}'
. '@media (max-width: 782px){'
. '.feca-mailshots-admin .feca-control-row{display:block;}'
. '.feca-mailshots-admin .feca-control{min-width:0;width:100%;}'
. '}'
. '</style>';
}
private function requestMethod(): string private function requestMethod(): string
{ {
$method = isset($_SERVER['REQUEST_METHOD']) ? (string) $_SERVER['REQUEST_METHOD'] : 'GET'; $method = isset($_SERVER['REQUEST_METHOD']) ? (string) $_SERVER['REQUEST_METHOD'] : 'GET';
@ -170,8 +273,8 @@ trait AdminRequestHelpers
if ($result === null || !empty($result['ok']) || empty($result['errors']) || !is_array($result['errors'])) { if ($result === null || !empty($result['ok']) || empty($result['errors']) || !is_array($result['errors'])) {
return ''; return '';
} }
$html = '<div style="margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;">'; $html = '<div class="feca-banner feca-banner-error">';
$html .= '<strong>Please fix the following:</strong><ul style="margin:8px 0 0 18px;">'; $html .= '<strong>Please fix the following:</strong><ul>';
foreach ($result['errors'] as $error) { foreach ($result['errors'] as $error) {
$html .= '<li>' . htmlspecialchars((string) $error) . '</li>'; $html .= '<li>' . htmlspecialchars((string) $error) . '</li>';
} }

View File

@ -48,9 +48,14 @@ final class AttachmentsAdminPage
} }
$items = $this->service()->list(); $items = $this->service()->list();
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY); $draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); $requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) { $draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
$editId = (int) $draft['id']; $useDraft = is_array($draft) && (
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
);
$editId = $requestedEditId;
if ($editId <= 0 && $useDraft && $draftId > 0) {
$editId = $draftId;
} }
$editItem = null; $editItem = null;
foreach ($items as $row) { foreach ($items as $row) {
@ -64,32 +69,32 @@ final class AttachmentsAdminPage
$name = (string) ($editItem['name'] ?? ''); $name = (string) ($editItem['name'] ?? '');
$fileName = (string) ($editItem['file_name'] ?? ''); $fileName = (string) ($editItem['file_name'] ?? '');
$base64 = ''; $base64 = '';
if (is_array($draft)) { if ($useDraft && is_array($draft)) {
$name = (string) ($draft['name'] ?? $name); $name = (string) ($draft['name'] ?? $name);
$fileName = (string) ($draft['file_name'] ?? $fileName); $fileName = (string) ($draft['file_name'] ?? $fileName);
$base64 = (string) ($draft['file_bytes_base64'] ?? ''); $base64 = (string) ($draft['file_bytes_base64'] ?? '');
} }
echo '<div class="wrap"><h1>Attachments</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>Attachments</h1>';
echo $this->renderAdminUiStyles();
if ($result !== null) { if ($result !== null) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Attachment saved.' : 'Attachment action failed.'; $title = $ok ? 'Attachment saved.' : 'Attachment action failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . htmlspecialchars($title) . '</strong>'; echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} }
echo '</div>'; echo '</div>';
} }
echo '<p><button type="button" class="button button-primary" id="att-open-new">New Attachment</button></p>'; echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="att-open-new">New Attachment</button></p>';
echo '<div id="att-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="att-editor-modal" class="feca-modal-overlay">';
echo '<div style="max-width:900px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div class="feca-modal-shell feca-modal-shell-narrow">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>'; echo '<p class="feca-button-row feca-close-row"><button type="button" class="button" id="att-close-editor">Quit</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="att-editor-form">'; echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" class="feca-form" id="att-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>'; echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
echo $this->modalErrorHtml($result); echo $this->modalErrorHtml($result);
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">'; echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
@ -104,7 +109,7 @@ final class AttachmentsAdminPage
echo '<tr><th scope="row"><label for="att_file_upload">Upload File</label></th><td><input type="file" id="att_file_upload" name="file_upload"></td></tr>'; echo '<tr><th scope="row"><label for="att_file_upload">Upload File</label></th><td><input type="file" id="att_file_upload" name="file_upload"></td></tr>';
echo '<tr><th scope="row"><label for="att_base64">File Bytes (Base64)</label></th><td><textarea id="att_base64" name="file_bytes_base64" rows="4" class="large-text code">' . htmlspecialchars($base64) . '</textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>'; echo '<tr><th scope="row"><label for="att_base64">File Bytes (Base64)</label></th><td><textarea id="att_base64" name="file_bytes_base64" rows="4" class="large-text code">' . htmlspecialchars($base64) . '</textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '</table>'; echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Attachment' : 'Create Attachment') . '</button> '; echo '<p class="feca-button-row"><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update Attachment' : 'Create Attachment') . '</button> ';
if ($editId > 0) { if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG)) . '">Cancel Edit</a>'; echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG)) . '">Cancel Edit</a>';
} }
@ -112,6 +117,7 @@ final class AttachmentsAdminPage
echo '</div></div>'; echo '</div></div>';
echo '<h2>Existing Attachments</h2>'; echo '<h2>Existing Attachments</h2>';
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>File</th><th>MIME</th><th>Bytes</th><th>Actions</th></tr></thead><tbody>'; echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>File</th><th>MIME</th><th>Bytes</th><th>Actions</th></tr></thead><tbody>';
foreach ($items as $row) { foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0); $id = (int) ($row['id'] ?? 0);
@ -123,11 +129,11 @@ final class AttachmentsAdminPage
echo '<td>' . htmlspecialchars((string) ($row['mime_type'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['mime_type'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> '; echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">'; echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_delete">'; echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">'; echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this attachment?\');">Delete</button>'; echo '<button type="submit" class="button button-small feca-button-danger" onclick="return confirm(\'Delete this attachment?\');">Delete</button>';
echo '</form></td>'; echo '</form></td>';
echo '</tr>'; echo '</tr>';
} }
@ -135,6 +141,7 @@ final class AttachmentsAdminPage
echo '<tr><td colspan="6">No attachments found.</td></tr>'; echo '<tr><td colspan="6">No attachments found.</td></tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '</div></div>';
echo $this->modalEditorScript( echo $this->modalEditorScript(
'att-editor-modal', 'att-editor-modal',
'att-open-new', 'att-open-new',
@ -143,7 +150,7 @@ final class AttachmentsAdminPage
'att-editor-id', 'att-editor-id',
['att_name', 'att_file_name', 'att_base64'], ['att_name', 'att_file_name', 'att_base64'],
$editId > 0, $editId > 0,
is_array($draft), $useDraft,
$result !== null, $result !== null,
!empty($result['ok']), !empty($result['ok']),
null, null,

View File

@ -77,9 +77,14 @@ final class DataSourcesAdminPage
$items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort); $items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort);
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY); $draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); $requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) { $draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
$editId = (int) $draft['id']; $useDraft = is_array($draft) && (
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
);
$editId = $requestedEditId;
if ($editId <= 0 && $useDraft && $draftId > 0) {
$editId = $draftId;
} }
$editItem = null; $editItem = null;
foreach ($items as $row) { foreach ($items as $row) {
@ -90,7 +95,7 @@ final class DataSourcesAdminPage
} }
$name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : ''; $name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : '';
$dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : ''; $dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : '';
if (is_array($draft)) { if ($useDraft && is_array($draft)) {
$name = (string) ($draft['name'] ?? $name); $name = (string) ($draft['name'] ?? $name);
$dsl = (string) ($draft['dsl_text'] ?? $dsl); $dsl = (string) ($draft['dsl_text'] ?? $dsl);
} }
@ -98,43 +103,40 @@ final class DataSourcesAdminPage
$sourceFieldsMap = $this->service()->sourceFields(); $sourceFieldsMap = $this->service()->sourceFields();
$schemaList = $this->service()->listSchemas(); $schemaList = $this->service()->listSchemas();
echo '<div class="wrap">'; echo '<div class="wrap feca-mailshots-admin">';
echo $this->renderAdminUiStyles();
echo '<h1>Mailshot Data Sources</h1>'; echo '<h1>Mailshot Data Sources</h1>';
if ($result !== null) { if ($result !== null) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Action succeeded.' : 'Action failed.'; $title = $ok ? 'Action succeeded.' : 'Action failed.';
echo '<div id="ds-result-banner" style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div id="ds-result-banner" class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . htmlspecialchars($title) . '</strong>'; echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} }
if (!empty($result['warnings']) && is_array($result['warnings'])) { if (!empty($result['warnings']) && is_array($result['warnings'])) {
echo '<p style="margin:6px 0 0 0;">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>'; echo '<p class="feca-banner-note">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>';
} }
if (isset($result['count'])) { if (isset($result['count'])) {
echo '<p style="margin:6px 0 0 0;">Preview count: ' . (int) $result['count'] . '</p>'; echo '<p class="feca-banner-note">Preview count: ' . (int) $result['count'] . '</p>';
} }
echo '</div>'; echo '</div>';
echo '<script>(function(){var n=document.getElementById("ds-result-banner");if(!n){return;}window.setTimeout(function(){if(n&&n.parentNode){n.parentNode.removeChild(n);}},30000);})();</script>'; echo '<script>(function(){var n=document.getElementById("ds-result-banner");if(!n){return;}window.setTimeout(function(){if(n&&n.parentNode){n.parentNode.removeChild(n);}},30000);})();</script>';
} }
echo '<div style="display:flex;gap:12px;margin:12px 0;">'; echo '<div class="feca-panel"><strong>Total data sources:</strong> ' . count($items) . '</div>';
echo '<div style="flex:1;padding:10px;border:1px solid #dcdcde;background:#fff;"><strong>Total data sources:</strong> ' . count($items) . '</div>';
echo '<div style="flex:1;padding:10px;border:1px solid #dcdcde;background:#fff;"><strong>Selected:</strong> ' . ($editId > 0 ? '#' . $editId . ' ' . htmlspecialchars($name) : 'none') . '</div>';
echo '</div>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="padding:10px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" class="feca-form">';
echo '<input type="hidden" name="page" value="feca-mailshot-data-sources">'; echo '<input type="hidden" name="page" value="feca-mailshot-data-sources">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'; echo '<div class="feca-control-row">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="ds_filter_q" style="white-space:nowrap;padding-left:4px;"><strong>Filter</strong></label>'; echo '<label for="ds_filter_q"><strong>Filter</strong></label>';
echo '<input id="ds_filter_q" class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence">'; echo '<input id="ds_filter_q" class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence">';
echo '</div>'; echo '</div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:260px;">'; echo '<div class="feca-control feca-control-min-260">';
echo '<label for="ds_filter_sort" style="white-space:nowrap;padding-left:4px;"><strong>Sort</strong></label>'; echo '<label for="ds_filter_sort"><strong>Sort</strong></label>';
echo '<select id="ds_filter_sort" name="sort">'; echo '<select id="ds_filter_sort" name="sort">';
foreach (['name' => 'Name', 'updated_desc' => 'Updated (newest)', 'updated_asc' => 'Updated (oldest)'] as $value => $label) { foreach (['name' => 'Name', 'updated_desc' => 'Updated (newest)', 'updated_asc' => 'Updated (oldest)'] as $value => $label) {
$selected = $sort === $value ? ' selected' : ''; $selected = $sort === $value ? ' selected' : '';
@ -142,15 +144,15 @@ final class DataSourcesAdminPage
} }
echo '</select>'; echo '</select>';
echo '</div>'; echo '</div>';
echo '<button class="button" type="submit">Apply</button>'; echo '<div class="feca-control"><button class="button" type="submit">Apply</button></div>';
echo '</div>'; echo '</div>';
echo '</form>'; echo '</form>';
echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>'; echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
echo '<div id="ds-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="ds-editor-modal" class="feca-modal-overlay">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div class="feca-modal-shell">';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">'; echo '<form method="post" action="' . $action . '" class="feca-form" id="ds-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>'; echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
echo $this->modalErrorHtml($result); echo $this->modalErrorHtml($result);
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
if ($editId > 0) { if ($editId > 0) {
@ -162,31 +164,28 @@ final class DataSourcesAdminPage
echo '<tr><th scope="row"><label for="ds_name">Name</label></th><td><input class="regular-text" type="text" id="ds_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '" data-initial="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>'; echo '<tr><th scope="row"><label for="ds_name">Name</label></th><td><input class="regular-text" type="text" id="ds_name" name="name" value="' . htmlspecialchars($name, ENT_QUOTES) . '" data-initial="' . htmlspecialchars($name, ENT_QUOTES) . '"></td></tr>';
echo '<tr><th scope="row"><label for="ds_dsl">DSL Sentence</label></th><td><textarea id="ds_dsl" name="dsl_text" rows="7" class="large-text code" data-initial="' . htmlspecialchars($dsl, ENT_QUOTES) . '">' . htmlspecialchars($dsl) . '</textarea></td></tr>'; echo '<tr><th scope="row"><label for="ds_dsl">DSL Sentence</label></th><td><textarea id="ds_dsl" name="dsl_text" rows="7" class="large-text code" data-initial="' . htmlspecialchars($dsl, ENT_QUOTES) . '">' . htmlspecialchars($dsl) . '</textarea></td></tr>';
echo '</table>'; echo '</table>';
echo '<p>'; echo '<p class="feca-button-row">';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">Save</button> '; echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">Save</button> ';
echo '<button type="button" class="button" id="ds-close-editor">Quit</button> '; echo '<button type="button" class="button" id="ds-close-editor">Quit</button> ';
echo '<button type="button" class="button" id="ds-validate-btn">Validate Sentence</button> '; echo '<button type="button" class="button" id="ds-validate-btn">Validate Sentence</button> ';
echo '<button type="button" class="button" id="ds-preview-btn">Preview Recipients</button> '; echo '<button type="button" class="button" id="ds-preview-btn">Preview Recipients</button> ';
echo '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> '; echo '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshot-data-sources&q=' . rawurlencode($filter) . '&sort=' . rawurlencode($sort))) . '">Discard Changes</a>';
}
echo '</p>'; echo '</p>';
echo '<div id="ds-inline-result" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#f9f9f9;"></div>'; echo '<div id="ds-inline-result" class="feca-banner feca-banner-muted feca-hidden"></div>';
echo '<div id="ds-inline-preview" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#fff;"></div>'; echo '<div id="ds-inline-preview" class="feca-banner feca-hidden"></div>';
echo '</form>'; echo '</form>';
echo '</div></div>'; echo '</div></div>';
echo '<div id="ds-builder-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;">'; echo '<div id="ds-builder-modal" class="feca-modal-overlay-high">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:14px;border-radius:4px;max-height:88vh;overflow:auto;">'; echo '<div class="feca-builder-shell">';
echo '<h2 style="margin-top:0;">Build DSL</h2>'; echo '<h2 class="feca-modal-title">Build DSL</h2>';
echo '<p style="margin-top:0;">Construct a DSL sentence from sources and constraints.</p>'; echo '<p class="feca-banner-note">Construct a DSL sentence from sources and constraints.</p>';
echo '<div style="display:flex;gap:14px;flex-wrap:wrap;">'; echo '<div class="feca-builder-grid">';
echo '<div style="flex:1;min-width:260px;border:1px solid #dcdcde;padding:10px;">'; echo '<div class="feca-builder-col-left">';
echo '<h3 style="margin-top:0;">Data Sources</h3>'; echo '<h3 class="feca-modal-title">Data Sources</h3>';
echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="contacts"> contacts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="accounts"> accounts</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="renewals"> renewals</label><br>';
echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br><br>'; echo '<label><input type="checkbox" class="ds-source-built" value="grants"> grants</label><br><br>';
echo '<strong>Add custom source</strong><br>'; echo '<strong>Add custom source</strong><br>';
echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>'; echo '<label>Schema <select id="ds-builder-schema"><option value="">Select schema</option>';
@ -198,27 +197,26 @@ final class DataSourcesAdminPage
echo '<button type="button" class="button button-small" id="ds-builder-add-custom">Add</button>'; echo '<button type="button" class="button button-small" id="ds-builder-add-custom">Add</button>';
echo '<ul id="ds-builder-custom-list"></ul>'; echo '<ul id="ds-builder-custom-list"></ul>';
echo '</div>'; echo '</div>';
echo '<div style="flex:2;min-width:340px;border:1px solid #dcdcde;padding:10px;">'; echo '<div class="feca-builder-col-right">';
echo '<h3 style="margin-top:0;">Constraints / Filters</h3>'; echo '<h3 class="feca-modal-title">Constraints / Filters</h3>';
echo '<div id="ds-builder-rows"></div>'; echo '<div id="ds-builder-rows"></div>';
echo '<p><button type="button" class="button button-small" id="ds-builder-add-row">Add Constraint Row</button></p>'; echo '<p class="feca-button-row"><button type="button" class="button button-small" id="ds-builder-add-row">Add Constraint Row</button></p>';
echo '</div></div>'; echo '</div></div>';
echo '<div style="margin-top:12px;border:1px solid #dcdcde;padding:10px;background:#f9f9f9;">'; echo '<div class="feca-builder-output">';
echo '<strong>Generated DSL</strong>'; echo '<strong>Generated DSL</strong>';
echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>'; echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>';
echo '<p id="ds-builder-error" style="color:#a00;margin:6px 0 0 0;"></p>'; echo '<p id="ds-builder-error" class="feca-error-text"></p>';
echo '<p id="ds-builder-table-error" style="color:#a00;margin:6px 0 0 0;"></p>'; echo '<p id="ds-builder-table-error" class="feca-error-text"></p>';
echo '</div>'; echo '</div>';
echo '<p style="margin-top:12px;">'; echo '<p class="feca-button-row">';
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Apply</button> '; echo '<button type="button" class="button button-primary" id="ds-builder-apply">Save</button> ';
echo '<button type="button" class="button" id="ds-builder-reset">Reset</button> '; echo '<button type="button" class="button" id="ds-builder-cancel">Quit</button>';
echo '<button type="button" class="button" id="ds-builder-cancel">Cancel</button>';
echo '</p></div></div>'; echo '</p></div></div>';
if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) { if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) {
$rows = $result['rows']; $rows = $result['rows'];
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<div class="feca-panel">';
echo '<h2 style="margin-top:0;">Preview (first ' . count($rows) . ' rows)</h2>'; echo '<h2 class="feca-modal-title">Preview (first ' . count($rows) . ' rows)</h2>';
if ($rows === []) { if ($rows === []) {
echo '<p>No rows returned.</p>'; echo '<p>No rows returned.</p>';
} else { } else {
@ -253,6 +251,7 @@ final class DataSourcesAdminPage
} }
echo '<h2>Existing Data Sources</h2>'; echo '<h2>Existing Data Sources</h2>';
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>DSL Sentence</th><th>Updated At</th><th>Actions</th></tr></thead><tbody>'; echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>DSL Sentence</th><th>Updated At</th><th>Actions</th></tr></thead><tbody>';
foreach ($items as $row) { foreach ($items as $row) {
$id = (int) ($row['ID'] ?? 0); $id = (int) ($row['ID'] ?? 0);
@ -260,14 +259,14 @@ final class DataSourcesAdminPage
echo '<tr>'; echo '<tr>';
echo '<td>' . $id . '</td>'; echo '<td>' . $id . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['name'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['name'] ?? '')) . '</td>';
echo '<td style="max-width:420px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"><code>' . htmlspecialchars((string) ($row['dsl_text'] ?? '')) . '</code></td>'; echo '<td class="feca-truncate-420"><code>' . htmlspecialchars((string) ($row['dsl_text'] ?? '')) . '</code></td>';
echo '<td>' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['updated_at'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> '; echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">'; echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">'; echo '<input type="hidden" name="action" value="feca_mailshots_data_sources_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">'; echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this data source?\');">Delete</button>'; echo '<button type="submit" class="button button-small feca-button-danger" onclick="return confirm(\'Delete this data source?\');">Delete</button>';
echo '</form></td>'; echo '</form></td>';
echo '</tr>'; echo '</tr>';
} }
@ -275,6 +274,7 @@ final class DataSourcesAdminPage
echo '<tr><td colspan="5">No data sources found.</td></tr>'; echo '<tr><td colspan="5">No data sources found.</td></tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '</div></div>';
echo '<script>'; echo '<script>';
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([ echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
'sourceFields' => $sourceFieldsMap, 'sourceFields' => $sourceFieldsMap,
@ -288,7 +288,6 @@ final class DataSourcesAdminPage
echo 'var openBtn=document.getElementById("ds-build-dsl-open");'; echo 'var openBtn=document.getElementById("ds-build-dsl-open");';
echo 'var cancelBtn=document.getElementById("ds-builder-cancel");'; echo 'var cancelBtn=document.getElementById("ds-builder-cancel");';
echo 'var applyBtn=document.getElementById("ds-builder-apply");'; echo 'var applyBtn=document.getElementById("ds-builder-apply");';
echo 'var resetBtn=document.getElementById("ds-builder-reset");';
echo 'var addRowBtn=document.getElementById("ds-builder-add-row");'; echo 'var addRowBtn=document.getElementById("ds-builder-add-row");';
echo 'var rowsWrap=document.getElementById("ds-builder-rows");'; echo 'var rowsWrap=document.getElementById("ds-builder-rows");';
echo 'var out=document.getElementById("ds-builder-output");'; echo 'var out=document.getElementById("ds-builder-output");';
@ -304,6 +303,7 @@ final class DataSourcesAdminPage
echo 'var closeEditor=document.getElementById("ds-close-editor");'; echo 'var closeEditor=document.getElementById("ds-close-editor");';
echo 'var editorId=document.getElementById("ds-editor-id");'; echo 'var editorId=document.getElementById("ds-editor-id");';
echo 'var editorName=document.getElementById("ds_name");'; echo 'var editorName=document.getElementById("ds_name");';
echo 'var nonceField=editorForm?editorForm.querySelector("input[name=\"_wpnonce\"]"):null;';
echo 'var builtChecks=[].slice.call(document.querySelectorAll(".ds-source-built"));'; echo 'var builtChecks=[].slice.call(document.querySelectorAll(".ds-source-built"));';
echo 'var customList=document.getElementById("ds-builder-custom-list");'; echo 'var customList=document.getElementById("ds-builder-custom-list");';
echo 'var schemaSel=document.getElementById("ds-builder-schema");'; echo 'var schemaSel=document.getElementById("ds-builder-schema");';
@ -321,7 +321,7 @@ final class DataSourcesAdminPage
echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source);fetch(u,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();return;}if(err){err.textContent="Source field lookup returned no fields for "+source+".";}}).catch(function(e){if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}});}return [];}'; echo 'function sourceFields(source){var map=cfg.sourceFields||{};if(map[source]){return map[source];}if(source&&source.indexOf(".")!==-1){var u=(cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=source_fields&source="+encodeURIComponent(source);fetch(u,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){throw new Error((j&&j.error)||"API error");}var fields=(j&&j.fields)||[];if(fields&&fields.length){map[source]=fields;cfg.sourceFields=map;renderRows();updateDsl();return;}if(err){err.textContent="Source field lookup returned no fields for "+source+".";}}).catch(function(e){if(err){err.textContent="Source field lookup failed for "+source+": "+(e&&e.message?e.message:"unknown error");}});}return [];}';
echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}'; echo 'function mkSelect(options,value){var s=document.createElement("select");options.forEach(function(opt){var o=document.createElement("option");o.value=opt[0];o.textContent=opt[1];if(opt[0]===value){o.selected=true;}s.appendChild(o);});return s;}';
echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}'; echo 'function addConstraint(){constraints.push({kind:"filter",negate:false,filter:"selected-renewal",lhsSource:"",lhsField:"",op:"=",rhsMode:"literal",rhsLiteral:"",rhsSource:"",rhsField:""});renderRows();updateDsl();}';
echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.style.border="1px solid #dcdcde";box.style.padding="8px";box.style.marginBottom="8px";var top=document.createElement("div");var not=document.createElement("input");not.type="checkbox";not.checked=!!row.negate;not.onchange=function(){row.negate=not.checked;updateDsl();};top.appendChild(not);top.appendChild(document.createTextNode(" NOT "));var kind=mkSelect([["filter","Predefined Filter"],["compare","Field Comparison"]],row.kind);kind.onchange=function(){row.kind=kind.value;renderRows();updateDsl();};top.appendChild(kind);var rem=document.createElement("button");rem.type="button";rem.className="button-link-delete";rem.style.marginLeft="8px";rem.textContent="Remove";rem.onclick=function(){constraints.splice(idx,1);renderRows();updateDsl();};top.appendChild(rem);box.appendChild(top);if(row.kind==="filter"){var f=mkSelect(filters,row.filter);f.onchange=function(){row.filter=f.value;updateDsl();};box.appendChild(f);}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}'; echo 'function renderRows(){rowsWrap.innerHTML="";constraints.forEach(function(row,idx){var box=document.createElement("div");box.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;updateDsl();};box.appendChild(f);}else{var srcs=selectedSources().map(function(s){return [s,s];});if(srcs.length===0){srcs=[["","Select source"]];}else{srcs.unshift(["","Select source"]);}var lhsS=mkSelect(srcs,row.lhsSource);lhsS.onchange=function(){row.lhsSource=lhsS.value;row.lhsField="";renderRows();updateDsl();};box.appendChild(lhsS);var lhsFields=(row.lhsSource?sourceFields(row.lhsSource):[]).map(function(f){return [f,f]});lhsFields.unshift(["","Field"]);var lhsF=mkSelect(lhsFields,row.lhsField);lhsF.onchange=function(){row.lhsField=lhsF.value;updateDsl();};box.appendChild(lhsF);var op=mkSelect([["=","="],["!=","!="],["contains","contains"],["starts-with","starts-with"],["ends-with","ends-with"],["in","in"]],row.op);op.onchange=function(){row.op=op.value;updateDsl();};box.appendChild(op);var mode=mkSelect([["literal","Literal"],["field","Field ref"]],row.rhsMode);mode.onchange=function(){row.rhsMode=mode.value;renderRows();updateDsl();};box.appendChild(mode);if(row.rhsMode==="literal"){var input=document.createElement("input");input.type="text";input.value=row.rhsLiteral||"";input.placeholder="Value";input.oninput=function(){row.rhsLiteral=input.value;updateDsl();};box.appendChild(input);}else{var rhsS=mkSelect(srcs,row.rhsSource);rhsS.onchange=function(){row.rhsSource=rhsS.value;row.rhsField="";renderRows();updateDsl();};box.appendChild(rhsS);var rhsFields=(row.rhsSource?sourceFields(row.rhsSource):[]).map(function(f){return [f,f]});rhsFields.unshift(["","Field"]);var rhsF=mkSelect(rhsFields,row.rhsField);rhsF.onchange=function(){row.rhsField=rhsF.value;updateDsl();};box.appendChild(rhsF);}}rowsWrap.appendChild(box);});}';
echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}'; echo 'function quote(v){if(v==="true"||v==="false"||/^\\d+$/.test(v)){return v;}return "\'"+String(v).replace(/\'/g,"")+"\'";}';
echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=r.rhsSource+"."+r.rhsField;if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}'; echo 'function updateDsl(){err.textContent="";var srcs=selectedSources();if(srcs.length===0){out.value="";err.textContent="Select at least one data source.";return;}var base=srcs.join(" and ");var terms=[];for(var i=0;i<constraints.length;i++){var r=constraints[i];var t="";if(r.kind==="filter"){if(!r.filter){continue;}t=r.filter;}else{if(!r.lhsSource||!r.lhsField){continue;}var lhs=r.lhsSource+"."+r.lhsField;if(r.rhsMode==="field"){if(!r.rhsSource||!r.rhsField){continue;}var rhs=r.rhsSource+"."+r.rhsField;if(r.op==="in"){t=lhs+" in ("+rhs+")";}else{t=lhs+" "+r.op+" "+rhs;}}else{if((r.rhsLiteral||"")===""){continue;}if(r.op==="in"){t=lhs+" in ("+quote(r.rhsLiteral)+")";}else{t=lhs+" "+r.op+" "+quote(r.rhsLiteral);}}}if(r.negate&&t!==""){t="not ("+t+")";}if(t!==""){terms.push(t);}}out.value=base+(terms.length>0?" where "+terms.join(" and "):"");}';
echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];constraints=[];updateCustomList();renderRows();updateDsl();}'; echo 'function resetBuilder(){builtChecks.forEach(function(c){c.checked=false;});customSources=[];constraints=[];updateCustomList();renderRows();updateDsl();}';
@ -336,16 +336,15 @@ final class DataSourcesAdminPage
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}'; echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}'; echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}';
echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}'; echo 'if(cancelBtn){cancelBtn.onclick=function(){modal.style.display="none";};}';
echo 'if(resetBtn){resetBtn.onclick=function(){resetBuilder();};}';
echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}modal.style.display="none";};}'; echo 'if(applyBtn){applyBtn.onclick=function(){if(dslInput){dslInput.value=out.value;}modal.style.display="none";};}';
echo 'if(openNew){openNew.onclick=function(){if(!confirmDiscard()){return;}if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}isDirty=false;editorModal.style.display="block";};}'; echo 'if(openNew){openNew.onclick=function(){if(!confirmDiscard()){return;}if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}isDirty=false;editorModal.style.display="block";};}';
echo 'if(closeEditor){closeEditor.onclick=function(){if(!confirmDiscard()){return;}editorModal.style.display="none";};}'; echo 'if(closeEditor){closeEditor.onclick=function(){if(!confirmDiscard()){return;}editorModal.style.display="none";};}';
echo 'function esc(v){return String(v||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}'; echo 'function esc(v){return String(v||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}';
echo 'function showInlineResult(ok,title,lines){if(!inlineResult){return;}inlineResult.style.display="block";inlineResult.style.borderColor=ok?"#8bc34a":"#ef9a9a";inlineResult.style.background=ok?"#f1f8e9":"#ffebee";var h="<strong>"+esc(title)+"</strong>";if(lines&&lines.length){h+="<ul style=\"margin:8px 0 0 18px;\">"+lines.map(function(x){return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";}inlineResult.innerHTML=h;}'; echo 'function showInlineResult(ok,title,lines){if(!inlineResult){return;}inlineResult.style.display="block";inlineResult.style.borderColor=ok?"#8bc34a":"#ef9a9a";inlineResult.style.background=ok?"#f1f8e9":"#ffebee";var h="<strong>"+esc(title)+"</strong>";if(lines&&lines.length){h+="<ul style=\"margin:8px 0 0 18px;\">"+lines.map(function(x){return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";}inlineResult.innerHTML=h;}';
echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}'; echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);if(nonceField&&nonceField.value){payload.set("_wpnonce",String(nonceField.value));}fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}';
echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}'; echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");if(nonceField&&nonceField.value){payload.set("_wpnonce",String(nonceField.value));}fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}';
echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';'; echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
echo 'var hasDraft=' . (is_array($draft) ? 'true' : 'false') . ';'; echo 'var hasDraft=' . ($useDraft ? 'true' : 'false') . ';';
echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';'; echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';';
echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';'; echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';';
echo 'if((hasEdit||hasDraft)&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}'; echo 'if((hasEdit||hasDraft)&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
@ -399,8 +398,7 @@ final class DataSourcesAdminPage
public function handleUiPreview(): void public function handleUiPreview(): void
{ {
if (!$this->wp->currentUserCan(self::CAPABILITY)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return; return;
} }
$dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? ''); $dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? '');
@ -422,8 +420,7 @@ final class DataSourcesAdminPage
public function handleUiDelete(): void public function handleUiDelete(): void
{ {
if (!$this->wp->currentUserCan(self::CAPABILITY)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return; return;
} }
$id = (int) ($this->wp->requestParam('id', '0') ?? '0'); $id = (int) ($this->wp->requestParam('id', '0') ?? '0');
@ -444,8 +441,7 @@ final class DataSourcesAdminPage
public function handleApi(): void public function handleApi(): void
{ {
if (!$this->wp->currentUserCan(self::CAPABILITY)) { if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
return; return;
} }

View File

@ -59,36 +59,32 @@ final class DownloadPdfAdminPage
} }
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY); $result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$buildStamp = gmdate('Y-m-d H:i:s', (int) @filemtime(__FILE__)) . ' UTC'; echo '<div class="wrap feca-mailshots-admin"><h1>Download PDF</h1>';
$renderedAt = gmdate('Y-m-d H:i:s') . ' UTC'; echo $this->renderAdminUiStyles();
echo '<div class="wrap"><h1>Download PDF</h1>';
echo '<p style="margin:4px 0 12px 0;color:#6b7280;font-size:12px;">Build stamp: ' . htmlspecialchars($buildStamp, ENT_QUOTES) . ' | Rendered: ' . htmlspecialchars($renderedAt, ENT_QUOTES) . '</p>';
echo '<p>Run the selected mailshot without sending any email, and download generated PDF attachments.</p>'; echo '<p>Run the selected mailshot without sending any email, and download generated PDF attachments.</p>';
if ($result !== null) { if ($result !== null) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
$border = $ok ? '#8bc34a' : '#ef9a9a'; echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'PDF generation succeeded.' : 'PDF generation failed.') . '</strong>'; echo '<strong>' . ($ok ? 'PDF generation succeeded.' : 'PDF generation failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} else { } else {
echo '<p style="margin:6px 0 0 0;">Generated: ' . (int) ($result['generated_count'] ?? 0) echo '<p class="feca-banner-note">Generated: ' . (int) ($result['generated_count'] ?? 0)
. ', Recipients: ' . (int) ($result['recipient_count'] ?? 0) . ', Recipients: ' . (int) ($result['recipient_count'] ?? 0)
. ', Skipped: ' . (int) ($result['skipped_count'] ?? 0) . '</p>'; . ', Skipped: ' . (int) ($result['skipped_count'] ?? 0) . '</p>';
} }
echo '</div>'; echo '</div>';
} }
echo '<form id="feca-download-pdf-form" method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;max-width:860px;">'; echo '<form id="feca-download-pdf-form" class="feca-form feca-form-max-860" method="post" action="' . $action . '">';
echo '<input type="hidden" name="action" value="feca_mailshots_download_pdf_ui">'; echo '<input type="hidden" name="action" value="feca_mailshots_download_pdf_ui">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" id="feca-download-format-shadow" name="download_format" value="">'; echo '<input type="hidden" id="feca-download-format-shadow" name="download_format" value="">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'; echo '<div class="feca-control-row">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="dp_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>'; echo '<label for="dp_mailshot_id"><strong>Mailshot</strong></label>';
echo '<select id="dp_mailshot_id" name="mailshot_id">'; echo '<select id="dp_mailshot_id" name="mailshot_id">';
foreach ($mailshots as $mailshot) { foreach ($mailshots as $mailshot) {
$id = (int) ($mailshot['id'] ?? 0); $id = (int) ($mailshot['id'] ?? 0);
@ -100,11 +96,11 @@ final class DownloadPdfAdminPage
echo '<option value="' . $id . '"' . $selected . '>' . htmlspecialchars($purpose) . '</option>'; echo '<option value="' . $id . '"' . $selected . '>' . htmlspecialchars($purpose) . '</option>';
} }
echo '</select></div></div>'; echo '</select></div></div>';
echo '<p style="margin-top:12px;">'; echo '<p class="feca-button-row">';
echo '<button id="feca-download-merged-btn" class="button button-primary" type="submit" name="download_format" value="merged" onclick="return confirm(\'Generate and download a single merged PDF?\');">Download Merged PDF</button> '; echo '<button id="feca-download-merged-btn" class="button button-primary" type="submit" name="download_format" value="merged" onclick="return confirm(\'Generate and download a single merged PDF?\');">Download Merged PDF</button> ';
echo '<button id="feca-download-zip-btn" class="button" type="submit" name="download_format" value="zip" onclick="return confirm(\'Generate and download a ZIP of individual PDFs?\');">Download ZIP of PDFs</button>'; echo '<button id="feca-download-zip-btn" class="button" type="submit" name="download_format" value="zip" onclick="return confirm(\'Generate and download a ZIP of individual PDFs?\');">Download ZIP of PDFs</button>';
echo '</p>'; echo '</p>';
echo '<div id="feca-download-progress" style="display:none;margin-top:8px;padding:10px;border:1px solid #c8d7e1;background:#f6fbff;max-width:520px;">'; echo '<div id="feca-download-progress" class="feca-progress-box">';
echo '<strong>Generating PDFs...</strong> Please wait. This can take a few seconds for larger mailshots.'; echo '<strong>Generating PDFs...</strong> Please wait. This can take a few seconds for larger mailshots.';
echo '</div>'; echo '</div>';
echo '</form>'; echo '</form>';
@ -116,12 +112,18 @@ final class DownloadPdfAdminPage
echo 'var zip=document.getElementById("feca-download-zip-btn");'; echo 'var zip=document.getElementById("feca-download-zip-btn");';
echo 'var progress=document.getElementById("feca-download-progress");'; echo 'var progress=document.getElementById("feca-download-progress");';
echo 'var shadow=document.getElementById("feca-download-format-shadow");'; echo 'var shadow=document.getElementById("feca-download-format-shadow");';
echo 'var clearUi=function(){if(progress){progress.style.display="none";}if(merged){merged.disabled=false;merged.classList.remove("disabled");}if(zip){zip.disabled=false;zip.classList.remove("disabled");}};';
echo 'actualForm.addEventListener("submit",function(ev){'; echo 'actualForm.addEventListener("submit",function(ev){';
echo 'var submitter=ev&&ev.submitter?ev.submitter:null;'; echo 'var submitter=ev&&ev.submitter?ev.submitter:null;';
echo 'if(shadow&&submitter&&submitter.name==="download_format"){shadow.value=submitter.value||"";}'; echo 'if(shadow&&submitter&&submitter.name==="download_format"){shadow.value=submitter.value||"";}';
echo 'if(submitter){submitter.disabled=true;submitter.classList.add("disabled");}'; echo 'if(merged){merged.disabled=true;merged.classList.add("disabled");}';
echo 'if(zip){zip.disabled=true;zip.classList.add("disabled");}';
echo 'if(progress){progress.style.display="block";}'; echo 'if(progress){progress.style.display="block";}';
echo 'window.setTimeout(clearUi,45000);';
echo '});'; echo '});';
echo 'window.addEventListener("focus",clearUi);';
echo 'window.addEventListener("pageshow",clearUi);';
echo 'document.addEventListener("visibilitychange",function(){if(document.visibilityState==="visible"){clearUi();}});';
echo '})();'; echo '})();';
echo '</script>'; echo '</script>';
echo '</div>'; echo '</div>';
@ -139,54 +141,12 @@ final class DownloadPdfAdminPage
$format = 'merged'; $format = 'merged';
} }
$this->downloadDebugLog('handleDownloadUi.start', [
'mailshot_id' => $mailshotId,
'format' => $format,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
$this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget()); $this->maybeRaiseMemoryLimit($this->resolveDownloadMemoryLimitTarget());
register_shutdown_function(function () use ($mailshotId, $format): void {
$err = error_get_last();
if (!is_array($err)) {
return;
}
$type = (int) ($err['type'] ?? 0);
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR];
if (!in_array($type, $fatalTypes, true)) {
return;
}
$this->downloadDebugLog('handleDownloadUi.shutdown_fatal', [
'mailshot_id' => $mailshotId,
'format' => $format,
'error_type' => $type,
'error_message' => (string) ($err['message'] ?? ''),
'error_file' => (string) ($err['file'] ?? ''),
'error_line' => (int) ($err['line'] ?? 0),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
});
if ($format === 'zip') { if ($format === 'zip') {
try { try {
$this->downloadDebugLog('handleDownloadUi.zip.before_generate', ['mailshot_id' => $mailshotId]);
$result = $this->runService()->generatePdfZipToTemp($mailshotId); $result = $this->runService()->generatePdfZipToTemp($mailshotId);
$this->downloadDebugLog('handleDownloadUi.zip.after_generate', [
'mailshot_id' => $mailshotId,
'ok' => !empty($result['ok']),
'generated_count' => (int) ($result['generated_count'] ?? 0),
'recipient_count' => (int) ($result['recipient_count'] ?? 0),
'zip_path' => (string) ($result['zip_path'] ?? ''),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->downloadDebugLog('handleDownloadUi.zip.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]]); $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return; return;
@ -205,14 +165,9 @@ final class DownloadPdfAdminPage
} }
try { try {
$this->downloadDebugLog('handleDownloadUi.zip.before_send', ['mailshot_id' => $mailshotId, 'zip_path' => $zipPath]);
$this->sendFileDownload('mailshot_' . $mailshotId . '_pdfs.zip', 'application/zip', $zipPath); $this->sendFileDownload('mailshot_' . $mailshotId . '_pdfs.zip', 'application/zip', $zipPath);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@unlink($zipPath); @unlink($zipPath);
$this->downloadDebugLog('handleDownloadUi.zip.send_exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP download failed: ' . $e->getMessage()]]); $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['ZIP download failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
} }
@ -220,21 +175,8 @@ final class DownloadPdfAdminPage
} }
try { try {
$this->downloadDebugLog('handleDownloadUi.merged.before_generate', ['mailshot_id' => $mailshotId]);
$result = $this->runService()->generatePdfBatch($mailshotId, true, false); $result = $this->runService()->generatePdfBatch($mailshotId, true, false);
$this->downloadDebugLog('handleDownloadUi.merged.after_generate', [
'mailshot_id' => $mailshotId,
'ok' => !empty($result['ok']),
'generated_count' => (int) ($result['generated_count'] ?? 0),
'recipient_count' => (int) ($result['recipient_count'] ?? 0),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->downloadDebugLog('handleDownloadUi.merged.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]); $this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]);
$this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId)); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return; return;
@ -252,13 +194,8 @@ final class DownloadPdfAdminPage
return; return;
} }
try { try {
$this->downloadDebugLog('handleDownloadUi.merged.before_send', ['mailshot_id' => $mailshotId, 'bytes_len' => strlen($bytes)]);
$this->sendBinaryDownload('mailshot_' . $mailshotId . '_merged.pdf', 'application/pdf', $bytes); $this->sendBinaryDownload('mailshot_' . $mailshotId . '_merged.pdf', 'application/pdf', $bytes);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->downloadDebugLog('handleDownloadUi.merged.send_exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
$this->wp->updateOption(self::RESULT_OPTION_KEY, ['ok' => false, 'errors' => ['Merged PDF download failed: ' . $e->getMessage()]]); $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)); $this->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
return; return;
@ -320,33 +257,6 @@ final class DownloadPdfAdminPage
return ($this->mailshotServiceFactory)(); return ($this->mailshotServiceFactory)();
} }
/** @param array<string,mixed> $context */
private function downloadDebugLog(string $event, array $context = []): void
{
$line = '[feca-mailshots/download-pdf][' . gmdate('Y-m-d H:i:s') . ' UTC] ' . $event;
if ($context !== []) {
$json = json_encode($context);
if (is_string($json) && $json !== '') {
$line .= ' ' . $json;
}
}
error_log($line);
if (!defined('ABSPATH')) {
return;
}
$path = ABSPATH . 'wp-content/uploads/feca_mailshots_download_debug.log';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (is_dir($dir) && is_writable($dir)) {
@error_log($line . PHP_EOL, 3, $path);
}
}
private function maybeRaiseMemoryLimit(string $target): void private function maybeRaiseMemoryLimit(string $target): void
{ {
if (!function_exists('ini_get') || !function_exists('ini_set')) { if (!function_exists('ini_get') || !function_exists('ini_set')) {
@ -367,11 +277,9 @@ final class DownloadPdfAdminPage
$old = $current; $old = $current;
@ini_set('memory_limit', $target); @ini_set('memory_limit', $target);
$new = (string) ini_get('memory_limit'); $new = (string) ini_get('memory_limit');
$this->downloadDebugLog('handleDownloadUi.memory_limit_adjust', [ if ($new === $old) {
'old' => $old, return;
'new' => $new, }
'target' => $target,
]);
} }
private function resolveDownloadMemoryLimitTarget(): string private function resolveDownloadMemoryLimitTarget(): string

View File

@ -62,47 +62,49 @@ final class MailshotTestAdminPage
$rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : []; $rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : [];
$selectedRecipientIndex = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); $selectedRecipientIndex = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
if ($selectedRecipientIndex < -1) {
$selectedRecipientIndex = 0;
}
$defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? ''; $defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? '';
$testEmail = (string) ($this->wp->requestParam('test_email', (string) $defaultEmail) ?? $defaultEmail); $testEmail = (string) ($this->wp->requestParam('test_email', (string) $defaultEmail) ?? $defaultEmail);
$result = $this->result(); $result = $this->result();
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '<div class="wrap"><h1>Mailshot Test</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>Mailshot Test</h1>';
echo $this->renderAdminUiStyles();
echo '<p>Render with a selected recipient context, then optionally send one test email.</p>'; echo '<p>Render with a selected recipient context, then optionally send one test email.</p>';
if ($result !== null) { if ($result !== null) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
$border = $ok ? '#8bc34a' : '#ef9a9a'; echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'Test action succeeded.' : 'Test action failed.') . '</strong>'; echo '<strong>' . ($ok ? 'Test action succeeded.' : 'Test action failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} }
if (!empty($result['warnings']) && is_array($result['warnings'])) { if (!empty($result['warnings']) && is_array($result['warnings'])) {
echo '<p style="margin:6px 0 0 0;">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>'; echo '<p class="feca-banner-note">Warnings: ' . htmlspecialchars(implode('; ', $result['warnings'])) . '</p>';
} }
if (!empty($result['sent_to'])) { if (!empty($result['sent_to'])) {
echo '<p style="margin:6px 0 0 0;">Sent to: <code>' . htmlspecialchars((string) $result['sent_to']) . '</code></p>'; echo '<p class="feca-banner-note">Sent to: <code>' . htmlspecialchars((string) $result['sent_to']) . '</code></p>';
} }
if (!empty($result['sent_at'])) { if (!empty($result['sent_at'])) {
echo '<p style="margin:6px 0 0 0;">Sent at: <code>' . htmlspecialchars((string) $result['sent_at']) . '</code></p>'; echo '<p class="feca-banner-note">Sent at: <code>' . htmlspecialchars((string) $result['sent_at']) . '</code></p>';
} }
if (($result['ui_action'] ?? '') === 'render' && !empty($result['ok']) && is_array($result['rendered'] ?? null)) { if (($result['ui_action'] ?? '') === 'render' && !empty($result['ok']) && is_array($result['rendered'] ?? null)) {
echo '<p style="margin:8px 0 0 0;"><button type="button" class="button button-primary" id="ms-open-render-preview">Open Render Preview</button></p>'; echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="ms-open-render-preview">Open Render Preview</button></p>';
} }
echo '<details style="margin-top:8px;"><summary>Show technical details</summary><pre style="margin-top:8px;">' . htmlspecialchars($this->debugJson($result), ENT_QUOTES) . '</pre></details>';
echo '</div>'; echo '</div>';
} }
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<div class="feca-panel">';
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>'; echo '<h2 class="feca-section-title">1. Select Mailshot</h2>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;">'; echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '">';
echo '<input type="hidden" name="page" value="feca-mailshots-test">'; echo '<input type="hidden" name="page" value="feca-mailshots-test">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'; echo '<div class="feca-control-row">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="mst_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>'; echo '<label for="mst_mailshot_id"><strong>Mailshot</strong></label>';
echo '<select id="mst_mailshot_id" name="mailshot_id">'; echo '<select id="mst_mailshot_id" name="mailshot_id">';
foreach ($mailshots as $m) { foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0); $id = (int) ($m['id'] ?? 0);
@ -113,23 +115,27 @@ final class MailshotTestAdminPage
} }
echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>'; echo '<option value="' . $id . '"' . $sel . '>' . htmlspecialchars($label) . '</option>';
} }
echo '</select></div><button class="button" type="submit">Load recipients</button></div>'; echo '</select></div>';
echo '<div class="feca-control"><label>&nbsp;</label><button class="button" type="submit">Load recipients</button></div>';
echo '</div>';
echo '</form>'; echo '</form>';
echo '</div>'; echo '</div>';
if (!empty($preview['errors'])) { if (!empty($preview['errors'])) {
echo '<div style="padding:10px;border:1px solid #ef9a9a;background:#ffebee;margin:12px 0;">' . htmlspecialchars(implode('; ', $preview['errors'])) . '</div>'; echo '<div class="feca-banner feca-banner-error">' . htmlspecialchars(implode('; ', $preview['errors'])) . '</div>';
} }
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<form method="post" action="' . $action . '" class="feca-form">';
echo '<h2 style="margin-top:0;">2. Render / Send Test</h2>'; echo '<h2 class="feca-section-title">2. Render / Send Test</h2>';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">'; echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'; echo '<div class="feca-control-row">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:380px;">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="mst_recipient_index" style="white-space:nowrap;padding-left:4px;"><strong>Recipient row</strong></label>'; echo '<label for="mst_recipient_index"><strong>Recipient row</strong></label>';
echo '<select id="mst_recipient_index" name="recipient_index">'; echo '<select id="mst_recipient_index" name="recipient_index" data-recipient-count="' . count($rows) . '">';
$allSel = $selectedRecipientIndex === -1 ? ' selected' : '';
echo '<option value="-1"' . $allSel . '>All recipients (send test email to address below for every row)</option>';
foreach ($rows as $r) { foreach ($rows as $r) {
$idx = (int) ($r['index'] ?? 0); $idx = (int) ($r['index'] ?? 0);
$sel = $idx === $selectedRecipientIndex ? ' selected' : ''; $sel = $idx === $selectedRecipientIndex ? ' selected' : '';
@ -142,23 +148,34 @@ final class MailshotTestAdminPage
echo '<option value="' . $idx . '"' . $sel . '>' . htmlspecialchars($text) . '</option>'; echo '<option value="' . $idx . '"' . $sel . '>' . htmlspecialchars($text) . '</option>';
} }
echo '</select></div>'; echo '</select></div>';
echo '</div>'; echo '<div class="feca-control feca-control-min-420">';
echo '<label for="mst_test_email"><strong>Test email address</strong></label>';
echo '<p><button class="button" type="submit" name="action" value="feca_mailshots_test_render_ui">Render Test (No Send)</button></p>';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:420px;">';
echo '<label for="mst_test_email" style="white-space:nowrap;padding-left:4px;"><strong>Test email address</strong></label>';
echo '<input id="mst_test_email" class="regular-text" type="email" name="test_email" value="' . htmlspecialchars($testEmail, ENT_QUOTES) . '">'; echo '<input id="mst_test_email" class="regular-text" type="email" name="test_email" value="' . htmlspecialchars($testEmail, ENT_QUOTES) . '">';
echo '</div>'; echo '</div>';
echo '<div class="feca-control"><label>&nbsp;</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>&nbsp;</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>'; echo '</div>';
echo '<p><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" onclick="return confirm(\'Send one test email to the entered address?\');">Send Test Email</button></p>';
echo '</form>'; echo '</form>';
echo '<script>(function(){';
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?");}';
echo 'var value=String(select.value||"0");';
echo 'if(value==="-1"){';
echo 'var count=parseInt(select.getAttribute("data-recipient-count")||"0",10);';
echo 'if(!Number.isFinite(count)||count<0){count=0;}';
echo 'return confirm("Send "+count+" emails to the entered address?");';
echo '}';
echo 'return confirm("Send one test email to the entered address?");';
echo '};';
echo '})();</script>';
if ($rows !== []) { if ($rows !== []) {
$sampleColumns = $this->sampleColumns($rows, 4); $sampleColumns = $this->sampleColumns($rows, 4);
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;">'; echo '<div class="feca-panel">';
echo '<h2 style="margin-top:0;">Recipient Sample (First 20)</h2><table class="widefat striped"><thead><tr><th>#</th><th>Key</th><th>Email</th>'; echo '<h2 class="feca-section-title">Recipient Sample (First 20)</h2>';
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
echo '<table class="widefat striped"><thead><tr><th>#</th><th>Key</th><th>Email</th>';
foreach ($sampleColumns as $col) { foreach ($sampleColumns as $col) {
echo '<th>' . htmlspecialchars($col) . '</th>'; echo '<th>' . htmlspecialchars($col) . '</th>';
} }
@ -172,6 +189,7 @@ final class MailshotTestAdminPage
echo '</tr>'; echo '</tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '</div></div>';
echo '</div>'; echo '</div>';
} }
@ -189,16 +207,16 @@ final class MailshotTestAdminPage
$messageHtml = (string) ($rendered['message'] ?? ''); $messageHtml = (string) ($rendered['message'] ?? '');
$pdfHtml = (string) ($rendered['pdf_attachment'] ?? ''); $pdfHtml = (string) ($rendered['pdf_attachment'] ?? '');
echo '<div id="ms-render-preview-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:100000;">'; echo '<div id="ms-render-preview-modal" class="feca-modal-overlay-high">';
echo '<div style="max-width:1100px;margin:28px auto;background:#fff;padding:14px;max-height:90vh;overflow:auto;">'; echo '<div class="feca-modal-shell feca-modal-shell-wide">';
echo '<h2 style="margin-top:0;">Render Preview</h2>'; echo '<h2 class="feca-modal-title">Render Preview</h2>';
echo '<p style="margin:0 0 8px 0;"><strong>Recipient:</strong> ' . htmlspecialchars($recipientText) . '</p>'; echo '<p class="feca-modal-subtitle"><strong>Recipient:</strong> ' . htmlspecialchars($recipientText) . '</p>';
echo '<p style="margin:0 0 10px 0;"><strong>Subject:</strong> ' . htmlspecialchars($subject) . '</p>'; echo '<p class="feca-modal-subtitle"><strong>Subject:</strong> ' . htmlspecialchars($subject) . '</p>';
echo '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">'; echo '<div class="feca-preview-grid">';
echo '<div><h3 style="margin:0 0 8px 0;">Message (HTML)</h3><iframe sandbox="" style="width:100%;height:320px;border:1px solid #dcdcde;background:#fff;" srcdoc="' . htmlspecialchars($messageHtml, ENT_QUOTES) . '"></iframe></div>'; 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 style="margin:0 0 8px 0;">PDF Attachment (HTML)</h3><iframe sandbox="" style="width:100%;height:320px;border:1px solid #dcdcde;background:#fff;" srcdoc="' . htmlspecialchars($pdfHtml, 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>'; echo '</div>';
echo '<p style="margin-top:12px;"><button type="button" class="button button-primary" id="ms-close-render-preview">Close</button></p>'; 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 '</div></div>';
echo '<script>(function(){'; echo '<script>(function(){';
echo 'var modal=document.getElementById("ms-render-preview-modal");'; echo 'var modal=document.getElementById("ms-render-preview-modal");';
@ -239,6 +257,10 @@ final class MailshotTestAdminPage
} }
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
if ($idx < 0) {
$this->wp->sendJson(['ok' => false, 'errors' => ['Choose a specific recipient row for Render Test.']]);
return;
}
$this->wp->sendJson($service->renderTest($mailshotId, $idx)); $this->wp->sendJson($service->renderTest($mailshotId, $idx));
return; return;
} }
@ -250,6 +272,10 @@ final class MailshotTestAdminPage
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
$to = (string) ($this->wp->requestParam('test_email', '') ?? ''); $to = (string) ($this->wp->requestParam('test_email', '') ?? '');
if ($idx < 0) {
$this->wp->sendJson($service->sendTestAll($mailshotId, $to));
return;
}
$this->wp->sendJson($service->sendTest($mailshotId, $idx, $to)); $this->wp->sendJson($service->sendTest($mailshotId, $idx, $to));
return; return;
} }
@ -267,6 +293,13 @@ final class MailshotTestAdminPage
} }
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$idx = (int) ($this->wp->requestParam('recipient_index', '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', '') ?? ''));
return;
}
try { try {
$result = $this->runService()->renderTest($mailshotId, $idx); $result = $this->runService()->renderTest($mailshotId, $idx);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -286,7 +319,11 @@ final class MailshotTestAdminPage
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0'); $idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
$email = (string) ($this->wp->requestParam('test_email', '') ?? ''); $email = (string) ($this->wp->requestParam('test_email', '') ?? '');
try { try {
if ($idx < 0) {
$result = $this->runService()->sendTestAll($mailshotId, $email);
} else {
$result = $this->runService()->sendTest($mailshotId, $idx, $email); $result = $this->runService()->sendTest($mailshotId, $idx, $email);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
$result = ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]]; $result = ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
} }
@ -337,16 +374,30 @@ final class MailshotTestAdminPage
if ($firstRow === []) { if ($firstRow === []) {
return []; return [];
} }
$firstRecipientKey = trim((string) ($rows[0]['recipient_key'] ?? ''));
$firstRecipientEmail = trim((string) ($rows[0]['recipient_email'] ?? ''));
$selected = []; $selected = [];
$seenValues = [];
foreach (array_keys($firstRow) as $key) { foreach (array_keys($firstRow) as $key) {
$name = (string) $key; $name = (string) $key;
if ($name === '') { if ($name === '') {
continue; continue;
} }
if ($this->isIdLikeField($name) || $this->isEmailLikeField($name)) { if ($this->isIdLikeField($name) || $this->isEmailLikeField($name) || $this->isKeyLikeField($name)) {
continue; continue;
} }
$sampleValue = trim($this->displayCellValue($firstRow[$name] ?? null));
if ($sampleValue !== '') {
$valueKey = strtolower($sampleValue);
if ($valueKey === strtolower($firstRecipientKey) || $valueKey === strtolower($firstRecipientEmail)) {
continue;
}
if (isset($seenValues[$valueKey])) {
continue;
}
$seenValues[$valueKey] = true;
}
$selected[] = $name; $selected[] = $name;
if (count($selected) >= $maxColumns) { if (count($selected) >= $maxColumns) {
break; break;
@ -371,6 +422,15 @@ final class MailshotTestAdminPage
return $name === 'email' || str_ends_with($name, '.email') || str_contains($name, 'email'); return $name === 'email' || str_ends_with($name, '.email') || str_contains($name, 'email');
} }
private function isKeyLikeField(string $field): bool
{
$name = strtolower($field);
return $name === 'key'
|| str_ends_with($name, '.key')
|| str_contains($name, 'recipient_key')
|| str_contains($name, 'contact_key');
}
/** @param mixed $value */ /** @param mixed $value */
private function displayCellValue($value): string private function displayCellValue($value): string
{ {
@ -403,14 +463,4 @@ final class MailshotTestAdminPage
return []; return [];
} }
/** @param array<string,mixed> $payload */
private function debugJson(array $payload): string
{
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
if (is_string($json)) {
return $json;
}
$err = function_exists('json_last_error_msg') ? json_last_error_msg() : 'unknown error';
return '{"debug_error":"Failed to encode technical details: ' . $err . '"}';
}
} }

View File

@ -55,9 +55,14 @@ final class MailshotsAdminPage
$draftRaw = $this->wp->getOption(self::DRAFT_OPTION_KEY, null); $draftRaw = $this->wp->getOption(self::DRAFT_OPTION_KEY, null);
$draft = is_array($draftRaw) ? $draftRaw : null; $draft = is_array($draftRaw) ? $draftRaw : null;
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); $requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) { $draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
$editId = (int) $draft['id']; $useDraft = is_array($draft) && isset($draft['form']) && (
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
);
$editId = $requestedEditId;
if ($editId <= 0 && $useDraft && $draftId > 0) {
$editId = $draftId;
} }
$editItem = null; $editItem = null;
foreach ($items as $row) { foreach ($items as $row) {
@ -91,7 +96,7 @@ final class MailshotsAdminPage
'RecipientEmailField' => (string) ($editItem['RecipientEmailField'] ?? ''), 'RecipientEmailField' => (string) ($editItem['RecipientEmailField'] ?? ''),
'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''), 'ReplyTo' => (string) ($editItem['ReplyTo'] ?? ''),
]; ];
if (is_array($draft) && isset($draft['form']) && is_array($draft['form'])) { if ($useDraft && is_array($draft) && isset($draft['form']) && is_array($draft['form'])) {
foreach (['Purpose', 'DataSource', 'CC', 'BCC', 'Subject', 'Message', 'PDFAttachment', 'PDFFilenameDerivedFrom', 'RecipientEmailField', 'ReplyTo'] as $key) { foreach (['Purpose', 'DataSource', 'CC', 'BCC', 'Subject', 'Message', 'PDFAttachment', 'PDFFilenameDerivedFrom', 'RecipientEmailField', 'ReplyTo'] as $key) {
if (array_key_exists($key, $draft['form'])) { if (array_key_exists($key, $draft['form'])) {
$form[$key] = (string) $draft['form'][$key]; $form[$key] = (string) $draft['form'][$key];
@ -116,29 +121,39 @@ final class MailshotsAdminPage
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY); $result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
$buildStamp = gmdate('Y-m-d H:i:s', (int) @filemtime(__FILE__)) . ' UTC'; $pluginMainFile = dirname(__DIR__, 2) . '/feca_mailshots_plugin.php';
$renderedAt = gmdate('Y-m-d H:i:s') . ' UTC'; $joditCssUrl = function_exists('plugins_url')
? \plugins_url('assets/vendor/jodit/jodit.min.css', $pluginMainFile)
: '';
$joditJsUrl = function_exists('plugins_url')
? \plugins_url('assets/vendor/jodit/jodit.min.js', $pluginMainFile)
: '';
$aceJsUrl = function_exists('plugins_url')
? \plugins_url('assets/vendor/ace/ace.js', $pluginMainFile)
: '';
$aceBaseUrl = function_exists('plugins_url')
? \plugins_url('assets/vendor/ace', $pluginMainFile)
: '';
echo '<div class="wrap"><h1>Mailshots</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>Mailshots</h1>';
echo '<p style="margin:4px 0 12px 0;color:#6b7280;font-size:12px;">Build stamp: ' . htmlspecialchars($buildStamp, ENT_QUOTES) . ' | Rendered: ' . htmlspecialchars($renderedAt, ENT_QUOTES) . '</p>'; echo $this->renderAdminUiStyles();
$isSaveError = (($result['context'] ?? '') === 'mailshot_save') && empty($result['ok']); $isSaveError = (($result['context'] ?? '') === 'mailshot_save') && empty($result['ok']);
if ($result !== null && !$isSaveError && (!is_array($draft) || !empty($result['ok']))) { if ($result !== null && !$isSaveError && (!is_array($draft) || !empty($result['ok']))) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
$border = $ok ? '#8bc34a' : '#ef9a9a'; echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'Mailshot saved.' : 'Mailshot action failed.') . '</strong>'; echo '<strong>' . ($ok ? 'Mailshot saved.' : 'Mailshot action failed.') . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} }
echo '</div>'; echo '</div>';
} }
echo '<p><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>'; echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>';
echo '<div id="ms-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="ms-editor-modal" class="feca-modal-overlay">';
echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div class="feca-modal-shell feca-modal-shell-wide">';
echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<form method="post" action="' . $action . '" id="mailshot-editor" class="feca-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>'; echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
echo $this->modalErrorHtml($result); echo $this->modalErrorHtml($result);
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">'; echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
@ -160,8 +175,8 @@ final class MailshotsAdminPage
echo '<tr><th scope="row">Available Tokens</th><td colspan="3"><div id="ms-token-controls"></div><p class="description" id="ms-token-help">Select a data source to view tokens.</p></td></tr>'; echo '<tr><th scope="row">Available Tokens</th><td colspan="3"><div id="ms-token-controls"></div><p class="description" id="ms-token-help">Select a data source to view tokens.</p></td></tr>';
echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td colspan="3"><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>'; echo '<tr><th scope="row"><label for="ms_subject">Subject</label></th><td colspan="3"><textarea id="ms_subject" name="Subject" rows="2" class="large-text">' . htmlspecialchars($form['Subject']) . '</textarea></td></tr>';
echo '<tr><th scope="row">Message</th><td><button type="button" class="button" id="ms-edit-message">Edit Message</button> <span class="description" id="ms-message-meta"></span><textarea id="ms_message" name="Message" rows="10" class="large-text code" style="display:none;">' . htmlspecialchars($form['Message']) . '</textarea><div id="ms-message-snippet" class="description" style="margin-top:8px;"></div></td>'; echo '<tr><th scope="row">Message</th><td><button type="button" class="button" id="ms-edit-message">Edit Message</button> <span class="description" id="ms-message-meta"></span><textarea id="ms_message" name="Message" rows="10" class="large-text code feca-hidden">' . htmlspecialchars($form['Message']) . '</textarea><div id="ms-message-snippet" class="description feca-banner-note"></div></td>';
echo '<th scope="row">PDF Attachment</th><td><button type="button" class="button" id="ms-edit-pdf">Edit PDF Attachment</button> <span class="description" id="ms-pdf-meta"></span><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code" style="display:none;">' . htmlspecialchars($form['PDFAttachment']) . '</textarea><div id="ms-pdf-snippet" class="description" style="margin-top:8px;"></div></td></tr>'; echo '<th scope="row">PDF Attachment</th><td><button type="button" class="button" id="ms-edit-pdf">Edit PDF Attachment</button> <span class="description" id="ms-pdf-meta"></span><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code feca-hidden">' . htmlspecialchars($form['PDFAttachment']) . '</textarea><div id="ms-pdf-snippet" class="description feca-banner-note"></div></td></tr>';
echo '<tr><th scope="row"><label for="ms_pdfname">PDF Filename Derived From</label></th><td><select id="ms_pdfname" name="PDFFilenameDerivedFrom"><option value="">(none)</option>'; echo '<tr><th scope="row"><label for="ms_pdfname">PDF Filename Derived From</label></th><td><select id="ms_pdfname" name="PDFFilenameDerivedFrom"><option value="">(none)</option>';
foreach ($pdfFieldOptions as $f) { foreach ($pdfFieldOptions as $f) {
@ -175,7 +190,7 @@ final class MailshotsAdminPage
echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>'; echo '<option value="' . htmlspecialchars($f, ENT_QUOTES) . '"' . $sel . '>' . htmlspecialchars($f) . '</option>';
} }
echo '</select><p class="description">Optional. Set this for run/send workflows; leave blank for PDF-only workflows.</p>'; echo '</select><p class="description">Optional. Set this for run/send workflows; leave blank for PDF-only workflows.</p>';
echo '<div id="ms-recipient-warning" style="display:none;margin-top:8px;padding:8px;border-left:4px solid #dba617;background:#fff8e1;">Mailshot does not have a specified RecipientEmailField, it cannot be used to send a mailshot.</div>'; echo '<div id="ms-recipient-warning" class="feca-warning-inline">Mailshot does not have a specified RecipientEmailField, it cannot be used to send a mailshot.</div>';
echo '</td></tr>'; echo '</td></tr>';
echo '<tr><th scope="row">Attachments</th><td colspan="3">'; echo '<tr><th scope="row">Attachments</th><td colspan="3">';
@ -184,7 +199,7 @@ final class MailshotsAdminPage
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . '</option>'; echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . '</option>';
} }
echo '</select> <button type="button" class="button button-small" id="ms_attachment_add">Add Selected Attachment</button>'; echo '</select> <button type="button" class="button button-small" id="ms_attachment_add">Add Selected Attachment</button>';
echo '<ul id="ms_attachment_list" style="margin-top:10px;">'; echo '<ul id="ms_attachment_list" class="feca-banner-note">';
foreach ($selectedAttachments as $name) { foreach ($selectedAttachments as $name) {
echo '<li data-name="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . ' <button type="button" class="button-link-delete ms-att-remove">Remove</button></li>'; echo '<li data-name="' . htmlspecialchars($name, ENT_QUOTES) . '">' . htmlspecialchars($name) . ' <button type="button" class="button-link-delete ms-att-remove">Remove</button></li>';
} }
@ -197,48 +212,43 @@ final class MailshotsAdminPage
echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td colspan="3"><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>'; echo '<tr><th scope="row"><label for="ms_reply">Reply-To</label></th><td colspan="3"><input class="regular-text" type="text" id="ms_reply" name="ReplyTo" value="' . htmlspecialchars($form['ReplyTo'], ENT_QUOTES) . '"></td></tr>';
echo '</table>'; echo '</table>';
echo '<p><button type="submit" class="button button-primary">Save</button> '; echo '<p class="feca-button-row"><button type="submit" class="button button-primary">Save</button> ';
echo '<button type="button" class="button" id="ms-close-editor">Quit</button> '; echo '<button type="button" class="button" id="ms-close-editor">Quit</button> ';
if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">Discard Changes</a> ';
}
echo '</p>'; echo '</p>';
echo '</form>'; echo '</form>';
echo '<div id="ms-template-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:9999;">'; echo '<div id="ms-template-modal" class="feca-modal-overlay-high">';
echo '<div id="ms-template-panel" style="max-width:980px;margin:30px auto;background:#fff;padding:12px;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden;">'; echo '<div id="ms-template-panel" class="feca-template-panel">';
echo '<h3 id="ms-template-title" style="margin:0 0 8px 0;flex:0 0 auto;">Template Editor</h3>'; echo '<h3 id="ms-template-title" class="feca-template-title">Template Editor</h3>';
echo '<div id="ms-template-error" style="display:none;margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;"></div>'; echo '<div id="ms-template-error" class="feca-template-error"></div>';
echo '<div id="ms-template-token-controls" style="margin-bottom:10px;"></div>'; echo '<div id="ms-template-token-controls" class="feca-template-token-controls"></div>';
echo '<div id="ms-template-pdf-assets" style="display:none;margin-bottom:10px;"><label>PDF Asset <select id="ms_pdf_asset_pick"><option value="">Select PDF asset</option></select></label> <button type="button" class="button button-small" id="ms_pdf_asset_insert">Insert Asset Token</button><p class="description">Inserts Twig helper token, example: {{ pdf_asset(\'logo_asset\') }}.</p></div>'; echo '<div id="ms-template-pdf-assets" class="feca-template-assets"><label>PDF Asset <select id="ms_pdf_asset_pick"><option value="">Select PDF asset</option></select></label> <button type="button" class="button button-small" id="ms_pdf_asset_insert">Insert Asset Token</button><p class="description">Inserts Twig helper token, example: {{ pdf_asset(\'logo_asset\') }}.</p></div>';
echo '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.css">'; echo '<link rel="stylesheet" href="' . htmlspecialchars($joditCssUrl, ENT_QUOTES) . '">';
echo '<script src="https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.js"></script>'; echo '<script src="' . htmlspecialchars($joditJsUrl, ENT_QUOTES) . '"></script>';
echo '<script src="https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/ace.js"></script>'; echo '<script src="' . htmlspecialchars($aceJsUrl, ENT_QUOTES) . '"></script>';
echo '<div id="ms-template-editor-wrap" style="flex:1 1 auto;min-height:320px;height:60vh;max-height:60vh;overflow:auto;">'; echo '<div id="ms-template-editor-wrap" class="feca-template-editor-wrap">';
echo '<textarea id="ms-template-jodit" rows="14" class="large-text code" style="height:100%;"></textarea>'; echo '<textarea id="ms-template-jodit" rows="14" class="large-text code feca-template-editor"></textarea>';
echo '</div>'; echo '</div>';
echo '<p style="margin:10px 0 0 0;flex:0 0 auto;"><button type="button" class="button button-primary" id="ms-template-save">Save</button> <button type="button" class="button" id="ms-template-close">Quit</button></p>'; echo '<p class="feca-template-actions"><button type="button" class="button button-primary" id="ms-template-save">Save</button> <button type="button" class="button" id="ms-template-close">Quit</button></p>';
echo '</div></div>'; echo '</div></div>';
echo '</div></div>'; echo '</div></div>';
echo '<style>
#ms-template-panel .jodit-container { height: 100% !important; }
</style>';
echo '<h2>Existing Mailshots</h2>'; echo '<h2>Existing Mailshots</h2>';
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>'; 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) { foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0); $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);
echo '<tr>'; echo '<tr>';
echo '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['Purpose'] ?? '')) . '</td>'; echo '<td class="feca-truncate-220">' . htmlspecialchars((string) ($row['Purpose'] ?? '')) . '</td>';
echo '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['DataSource'] ?? '')) . '</td>'; echo '<td class="feca-truncate-220">' . htmlspecialchars((string) ($row['DataSource'] ?? '')) . '</td>';
echo '<td style="max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['Subject'] ?? '')) . '</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 '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">'; echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_delete">'; echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">'; echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this mailshot?\');">Delete</button>'; echo '<button type="submit" class="button button-small feca-button-danger" onclick="return confirm(\'Delete this mailshot?\');">Delete</button>';
echo '</form></td>'; echo '</form></td>';
echo '</tr>'; echo '</tr>';
} }
@ -246,6 +256,7 @@ final class MailshotsAdminPage
echo '<tr><td colspan="4">No mailshots found.</td></tr>'; echo '<tr><td colspan="4">No mailshots found.</td></tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '</div></div>';
echo '<script> echo '<script>
(function () { (function () {
@ -257,6 +268,7 @@ final class MailshotsAdminPage
var adminApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_mailshots_api'), JSON_UNESCAPED_SLASHES) . '; var adminApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_mailshots_api'), JSON_UNESCAPED_SLASHES) . ';
var pdfApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_pdf_assets_api'), JSON_UNESCAPED_SLASHES) . '; var pdfApiBase = ' . json_encode($this->wp->adminUrl('admin-post.php?action=feca_mailshots_pdf_assets_api'), JSON_UNESCAPED_SLASHES) . ';
var aceBaseUrl = ' . json_encode($aceBaseUrl, JSON_UNESCAPED_SLASHES) . ';
var currentTokens = ' . json_encode(array_values(array_filter((array) ($tokenData['tokens'] ?? []), static fn($t): bool => is_array($t))), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . '; var currentTokens = ' . json_encode(array_values(array_filter((array) ($tokenData['tokens'] ?? []), static fn($t): bool => is_array($t))), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';
var lastTarget = null; var lastTarget = null;
var isDirty = false; var isDirty = false;
@ -308,6 +320,12 @@ final class MailshotsAdminPage
var pdfSnippet = document.getElementById("ms-pdf-snippet"); var pdfSnippet = document.getElementById("ms-pdf-snippet");
var pdfMeta = document.getElementById("ms-pdf-meta"); var pdfMeta = document.getElementById("ms-pdf-meta");
var recipientWarning = document.getElementById("ms-recipient-warning"); var recipientWarning = document.getElementById("ms-recipient-warning");
if (window.ace && window.ace.config) {
window.ace.config.set("basePath", aceBaseUrl);
window.ace.config.set("modePath", aceBaseUrl);
window.ace.config.set("themePath", aceBaseUrl);
window.ace.config.set("workerPath", aceBaseUrl);
}
var activeTemplateField = null; var activeTemplateField = null;
var templateDirty = false; var templateDirty = false;
@ -766,7 +784,7 @@ final class MailshotsAdminPage
setCleanState(); setCleanState();
var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . '; var hasEdit = ' . ($editId > 0 ? 'true' : 'false') . ';
var hasDraft = ' . (is_array($draft) ? 'true' : 'false') . '; var hasDraft = ' . ($useDraft ? 'true' : 'false') . ';
var hasResult = ' . ($result !== null ? 'true' : 'false') . '; var hasResult = ' . ($result !== null ? 'true' : 'false') . ';
var resultOk = ' . (!empty($result['ok']) ? 'true' : 'false') . '; var resultOk = ' . (!empty($result['ok']) ? 'true' : 'false') . ';
if ((hasEdit || hasDraft) && editorModal && (!hasResult || !resultOk)) { if ((hasEdit || hasDraft) && editorModal && (!hasResult || !resultOk)) {

View File

@ -48,9 +48,14 @@ final class PdfAssetsAdminPage
} }
$items = $this->service()->list(); $items = $this->service()->list();
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY); $draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0'); $requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) { $draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
$editId = (int) $draft['id']; $useDraft = is_array($draft) && (
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
);
$editId = $requestedEditId;
if ($editId <= 0 && $useDraft && $draftId > 0) {
$editId = $draftId;
} }
$editItem = null; $editItem = null;
foreach ($items as $row) { foreach ($items as $row) {
@ -67,7 +72,7 @@ final class PdfAssetsAdminPage
$height = (string) ($editItem['height_mm'] ?? '297'); $height = (string) ($editItem['height_mm'] ?? '297');
$just = (string) ($editItem['justification'] ?? 'in-place'); $just = (string) ($editItem['justification'] ?? 'in-place');
$base64 = ''; $base64 = '';
if (is_array($draft)) { if ($useDraft && is_array($draft)) {
$name = (string) ($draft['name'] ?? $name); $name = (string) ($draft['name'] ?? $name);
$fileName = (string) ($draft['file_name'] ?? $fileName); $fileName = (string) ($draft['file_name'] ?? $fileName);
$width = (string) ($draft['width_mm'] ?? $width); $width = (string) ($draft['width_mm'] ?? $width);
@ -76,26 +81,26 @@ final class PdfAssetsAdminPage
$base64 = (string) ($draft['file_bytes_base64'] ?? ''); $base64 = (string) ($draft['file_bytes_base64'] ?? '');
} }
echo '<div class="wrap"><h1>PDF Assets</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>PDF Assets</h1>';
echo $this->renderAdminUiStyles();
if ($result !== null) { if ($result !== null) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'PDF asset saved.' : 'PDF asset action failed.'; $title = $ok ? 'PDF asset saved.' : 'PDF asset action failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . htmlspecialchars($title) . '</strong>'; echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} }
echo '</div>'; echo '</div>';
} }
echo '<p><button type="button" class="button button-primary" id="pdf-open-new">New PDF Asset</button></p>'; echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="pdf-open-new">New PDF Asset</button></p>';
echo '<div id="pdf-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">'; echo '<div id="pdf-editor-modal" class="feca-modal-overlay">';
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">'; echo '<div class="feca-modal-shell">';
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>'; echo '<p class="feca-button-row feca-close-row"><button type="button" class="button" id="pdf-close-editor">Quit</button></p>';
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="pdf-editor-form">'; echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" class="feca-form" id="pdf-editor-form">';
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>'; echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
echo $this->modalErrorHtml($result); echo $this->modalErrorHtml($result);
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">'; echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
@ -118,7 +123,7 @@ final class PdfAssetsAdminPage
echo '<tr><th scope="row"><label for="pdf_file_upload">Upload Image</label></th><td><input type="file" id="pdf_file_upload" name="file_upload" accept="image/*"></td></tr>'; echo '<tr><th scope="row"><label for="pdf_file_upload">Upload Image</label></th><td><input type="file" id="pdf_file_upload" name="file_upload" accept="image/*"></td></tr>';
echo '<tr><th scope="row"><label for="pdf_base64">File Bytes (Base64)</label></th><td><textarea id="pdf_base64" name="file_bytes_base64" rows="4" class="large-text code">' . htmlspecialchars($base64) . '</textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>'; echo '<tr><th scope="row"><label for="pdf_base64">File Bytes (Base64)</label></th><td><textarea id="pdf_base64" name="file_bytes_base64" rows="4" class="large-text code">' . htmlspecialchars($base64) . '</textarea><p class="description">Provide base64 directly, or use file upload above.</p></td></tr>';
echo '</table>'; echo '</table>';
echo '<p><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update PDF Asset' : 'Create PDF Asset') . '</button> '; echo '<p class="feca-button-row"><button type="submit" class="button button-primary">' . ($editId > 0 ? 'Update PDF Asset' : 'Create PDF Asset') . '</button> ';
if ($editId > 0) { if ($editId > 0) {
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG)) . '">Cancel Edit</a>'; echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG)) . '">Cancel Edit</a>';
} }
@ -126,6 +131,7 @@ final class PdfAssetsAdminPage
echo '</div></div>'; echo '</div></div>';
echo '<h2>Existing PDF Assets</h2>'; echo '<h2>Existing PDF Assets</h2>';
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>File</th><th>Size (mm)</th><th>Justification</th><th>Bytes</th><th>Actions</th></tr></thead><tbody>'; echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>File</th><th>Size (mm)</th><th>Justification</th><th>Bytes</th><th>Actions</th></tr></thead><tbody>';
foreach ($items as $row) { foreach ($items as $row) {
$id = (int) ($row['id'] ?? 0); $id = (int) ($row['id'] ?? 0);
@ -138,11 +144,11 @@ final class PdfAssetsAdminPage
echo '<td>' . htmlspecialchars((string) ($row['justification'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['justification'] ?? '')) . '</td>';
echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>'; echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>';
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> '; echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
echo '<form method="post" action="' . $action . '" style="display:inline;">'; echo '<form method="post" action="' . $action . '" class="feca-inline-form">';
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_delete">'; echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_delete">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="id" value="' . $id . '">'; echo '<input type="hidden" name="id" value="' . $id . '">';
echo '<button type="submit" class="button button-small" onclick="return confirm(\'Delete this PDF asset?\');">Delete</button>'; echo '<button type="submit" class="button button-small feca-button-danger" onclick="return confirm(\'Delete this PDF asset?\');">Delete</button>';
echo '</form></td>'; echo '</form></td>';
echo '</tr>'; echo '</tr>';
} }
@ -150,6 +156,7 @@ final class PdfAssetsAdminPage
echo '<tr><td colspan="7">No PDF assets found.</td></tr>'; echo '<tr><td colspan="7">No PDF assets found.</td></tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '</div></div>';
echo $this->modalEditorScript( echo $this->modalEditorScript(
'pdf-editor-modal', 'pdf-editor-modal',
'pdf-open-new', 'pdf-open-new',
@ -158,7 +165,7 @@ final class PdfAssetsAdminPage
'pdf-editor-id', 'pdf-editor-id',
['pdf_name', 'pdf_file_name', 'pdf_width', 'pdf_height', 'pdf_base64'], ['pdf_name', 'pdf_file_name', 'pdf_width', 'pdf_height', 'pdf_base64'],
$editId > 0, $editId > 0,
is_array($draft), $useDraft,
$result !== null, $result !== null,
!empty($result['ok']), !empty($result['ok']),
'pdf_just', 'pdf_just',

View File

@ -13,7 +13,6 @@ final class ProfileAdminPage
private const CAPABILITY = 'edit_pages'; private const CAPABILITY = 'edit_pages';
private const TEST_RESULT_OPTION_PREFIX = 'feca_mailshots_profile_test_result_'; private const TEST_RESULT_OPTION_PREFIX = 'feca_mailshots_profile_test_result_';
private const DOWNLOAD_MEMORY_LIMIT_OPTION = 'feca_mailshots_download_memory_limit';
private const NONCE_ACTION = 'feca_mailshots_profile'; private const NONCE_ACTION = 'feca_mailshots_profile';
private WordPressFacade $wp; private WordPressFacade $wp;
@ -48,22 +47,21 @@ final class ProfileAdminPage
$uid = $this->wp->currentUserId(); $uid = $this->wp->currentUserId();
$saved = $uid > 0 ? ($this->repo()->findByUserId($uid) ?? []) : []; $saved = $uid > 0 ? ($this->repo()->findByUserId($uid) ?? []) : [];
$downloadMemoryLimit = trim((string) ($this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '')));
$status = $this->wp->requestParam('saved', '') === '1' ? 'Profile credentials saved.' : ''; $status = $this->wp->requestParam('saved', '') === '1' ? 'Profile credentials saved.' : '';
$test = $uid > 0 ? $this->testResult($uid) : null; $test = $uid > 0 ? $this->testResult($uid) : null;
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '<div class="wrap"><h1>Mailshot Profile</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>Mailshot Profile</h1>';
echo $this->renderAdminUiStyles();
if ($status !== '') { if ($status !== '') {
echo '<div style="padding:10px;border:1px solid #8bc34a;background:#f1f8e9;margin:12px 0;">' . htmlspecialchars($status) . '</div>'; echo '<div class="feca-banner feca-banner-success">' . htmlspecialchars($status) . '</div>';
} }
if ($test !== null) { if ($test !== null) {
$ok = !empty($test['ok']); $ok = !empty($test['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$kind = strtoupper((string) ($test['kind'] ?? 'CREDENTIAL')); $kind = strtoupper((string) ($test['kind'] ?? 'CREDENTIAL'));
$title = $kind . ' test ' . ($ok ? 'passed.' : 'failed.'); $title = $kind . ' test ' . ($ok ? 'passed.' : 'failed.');
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . htmlspecialchars($title) . '</strong>'; echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($test['messages']) && is_array($test['messages'])) { if (!empty($test['messages']) && is_array($test['messages'])) {
echo '<ul>'; echo '<ul>';
@ -76,9 +74,9 @@ final class ProfileAdminPage
} }
echo '<p>Configure your personal SMTP and IMAP credentials for test/run operations.</p>'; echo '<p>Configure your personal SMTP and IMAP credentials for test/run operations.</p>';
echo '<p><em>Passwords are stored encrypted in the mailshots database.</em></p>'; echo '<p><em>Passwords are stored encrypted in the mailshots database.</em></p>';
echo '<div style="padding:10px;border:1px solid #c8d7e1;background:#f6fbff;margin:12px 0;">'; echo '<div class="feca-banner feca-banner-info">';
echo '<strong>Common configuration patterns</strong>'; echo '<strong>Common configuration patterns</strong>';
echo '<ul style="margin:8px 0 0 18px;list-style:disc;">'; echo '<ul>';
echo '<li>SMTP port 465 usually means <em>Require TLS</em> should be enabled (implicit TLS).</li>'; echo '<li>SMTP port 465 usually means <em>Require TLS</em> should be enabled (implicit TLS).</li>';
echo '<li>SMTP port 587 usually means <em>Require TLS</em> should be disabled so STARTTLS can be negotiated.</li>'; echo '<li>SMTP port 587 usually means <em>Require TLS</em> should be disabled so STARTTLS can be negotiated.</li>';
echo '<li>IMAP port 993 usually uses mailbox flags <code>/imap/ssl</code>.</li>'; echo '<li>IMAP port 993 usually uses mailbox flags <code>/imap/ssl</code>.</li>';
@ -86,10 +84,11 @@ final class ProfileAdminPage
echo '</ul>'; echo '</ul>';
echo '</div>'; echo '</div>';
echo '<form method="post" action="' . $action . '">'; echo '<form method="post" action="' . $action . '" class="feca-form">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<h2>SMTP</h2>'; echo '<h2>SMTP</h2>';
echo '<table class="form-table" role="presentation">';
$this->field('SMTP Host', 'smtp_host', $saved['smtp_host'] ?? ''); $this->field('SMTP Host', 'smtp_host', $saved['smtp_host'] ?? '');
$this->field('SMTP Port', 'smtp_port', (string) ($saved['smtp_port'] ?? '')); $this->field('SMTP Port', 'smtp_port', (string) ($saved['smtp_port'] ?? ''));
$this->field('SMTP User', 'smtp_user', $saved['smtp_user'] ?? ''); $this->field('SMTP User', 'smtp_user', $saved['smtp_user'] ?? '');
@ -98,26 +97,20 @@ final class ProfileAdminPage
$this->field('From Name', 'smtp_from_name', $saved['smtp_from_name'] ?? ''); $this->field('From Name', 'smtp_from_name', $saved['smtp_from_name'] ?? '');
$tlsChecked = !empty($saved['smtp_require_tls']) ? 'checked' : ''; $tlsChecked = !empty($saved['smtp_require_tls']) ? 'checked' : '';
echo '<table class="form-table" role="presentation"><tr><th scope="row">Require TLS</th><td><label><input type="checkbox" name="smtp_require_tls" value="1" ' . $tlsChecked . '> Use implicit TLS transport</label></td></tr></table>'; echo '<tr><th scope="row">Require TLS</th><td><label><input type="checkbox" name="smtp_require_tls" value="1" ' . $tlsChecked . '> Use implicit TLS transport</label></td></tr>';
echo '</table>';
echo '<h2>IMAP Sent Copy</h2>'; echo '<h2>IMAP Sent Copy</h2>';
echo '<table class="form-table" role="presentation">';
$this->field('IMAP Host', 'imap_host', $saved['imap_host'] ?? ''); $this->field('IMAP Host', 'imap_host', $saved['imap_host'] ?? '');
$this->field('IMAP Port', 'imap_port', (string) ($saved['imap_port'] ?? '')); $this->field('IMAP Port', 'imap_port', (string) ($saved['imap_port'] ?? ''));
$this->field('IMAP User', 'imap_user', $saved['imap_user'] ?? ''); $this->field('IMAP User', 'imap_user', $saved['imap_user'] ?? '');
$this->field('IMAP Password', 'imap_password', '', 'password', 'Required on each save/test request'); $this->field('IMAP Password', 'imap_password', '', 'password', 'Required on each save/test request');
$this->field('IMAP Sent Folder', 'imap_sent_folder', $saved['imap_sent_folder'] ?? ''); $this->field('IMAP Sent Folder', 'imap_sent_folder', $saved['imap_sent_folder'] ?? '');
$this->field('IMAP Mailbox Flags', 'imap_mailbox_flags', $saved['imap_mailbox_flags'] ?? ''); $this->field('IMAP Mailbox Flags', 'imap_mailbox_flags', $saved['imap_mailbox_flags'] ?? '');
echo '</table>';
echo '<h2>Operational Settings</h2>'; echo '<p class="feca-button-row">';
$this->field(
'Download PDF Memory Limit',
'download_memory_limit',
$downloadMemoryLimit,
'text',
'PHP memory_limit value used for Download PDF actions (for example: 256M, 512M, 1G). Leave blank to use plugin default.'
);
echo '<p>';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_profile_save">Save Profile</button> '; echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_profile_save">Save Profile</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test SMTP login" onclick="this.form.test_kind.value=\'smtp\';">Test SMTP</button> '; echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test SMTP login" onclick="this.form.test_kind.value=\'smtp\';">Test SMTP</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test IMAP login" onclick="this.form.test_kind.value=\'imap\';">Test IMAP</button>'; echo '<button type="submit" class="button" name="action" value="feca_mailshots_profile_test" formaction="' . $action . '" formmethod="post" title="Test IMAP login" onclick="this.form.test_kind.value=\'imap\';">Test IMAP</button>';
@ -153,8 +146,6 @@ final class ProfileAdminPage
'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', '') ?? '')), 'imap_sent_folder' => trim((string) ($this->wp->requestParam('imap_sent_folder', '') ?? '')),
'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '') ?? '')), 'imap_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '') ?? '')),
]; ];
$downloadMemoryLimit = trim((string) ($this->wp->requestParam('download_memory_limit', '') ?? ''));
$errors = []; $errors = [];
foreach (['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'imap_host', 'imap_port', 'imap_user', 'imap_password', 'imap_sent_folder', 'imap_mailbox_flags'] as $required) { foreach (['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'imap_host', 'imap_port', 'imap_user', 'imap_password', 'imap_sent_folder', 'imap_mailbox_flags'] as $required) {
if ($payload[$required] === '') { if ($payload[$required] === '') {
@ -165,16 +156,7 @@ final class ProfileAdminPage
$this->wp->sendJson(['ok' => false, 'errors' => $errors], 400); $this->wp->sendJson(['ok' => false, 'errors' => $errors], 400);
return; return;
} }
if ($downloadMemoryLimit !== '' && $this->memoryLimitToBytes($downloadMemoryLimit) <= 0) {
$this->wp->sendJson([
'ok' => false,
'errors' => ['Invalid download_memory_limit. Use a PHP memory value such as 256M, 512M, or 1G.'],
], 400);
return;
}
$this->repo()->upsertForUser($uid, $payload); $this->repo()->upsertForUser($uid, $payload);
$this->wp->updateOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, $downloadMemoryLimit);
if (!headers_sent()) { if (!headers_sent()) {
$location = $this->wp->adminUrl('admin.php?page=feca-mailshots-profile&saved=1'); $location = $this->wp->adminUrl('admin.php?page=feca-mailshots-profile&saved=1');
@ -241,41 +223,13 @@ final class ProfileAdminPage
private function field(string $label, string $name, string $value, string $type = 'text', string $hint = ''): void private function field(string $label, string $name, string $value, string $type = 'text', string $hint = ''): void
{ {
echo '<table class="form-table" role="presentation"><tr>'; echo '<tr>';
echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>'; echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>';
echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">'; echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
if ($hint !== '') { if ($hint !== '') {
echo '<p class="description">' . htmlspecialchars($hint) . '</p>'; echo '<p class="description">' . htmlspecialchars($hint) . '</p>';
} }
echo '</td></tr></table>'; echo '</td></tr>';
}
private function memoryLimitToBytes(string $limit): int
{
$v = trim($limit);
if ($v === '') {
return 0;
}
if ($v === '-1') {
return -1;
}
$unit = strtolower(substr($v, -1));
if (ctype_alpha($unit)) {
$num = (float) substr($v, 0, -1);
switch ($unit) {
case 'g':
return (int) ($num * 1024 * 1024 * 1024);
case 'm':
return (int) ($num * 1024 * 1024);
case 'k':
return (int) ($num * 1024);
default:
return (int) $num;
}
}
return (int) $v;
} }
private function repo(): MailCredentialRepository private function repo(): MailCredentialRepository

View File

@ -66,14 +66,15 @@ final class ReviewRecipientsAdminPage
} }
} }
echo '<div class="wrap"><h1>Review Recipients</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>Review Recipients</h1>';
echo $this->renderAdminUiStyles();
echo '<p>Inspect recipient rows for a selected data source with full-field filtering and sorting.</p>'; echo '<p>Inspect recipient rows for a selected data source with full-field filtering and sorting.</p>';
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<div class="feca-panel">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'; echo '<div class="feca-control-row">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:320px;">'; echo '<div class="feca-control feca-control-min-320">';
echo '<label for="rr_data_source" style="white-space:nowrap;padding-left:4px;"><strong>Data Source</strong></label>'; echo '<label for="rr_data_source"><strong>Data Source</strong></label>';
echo '<select id="rr_data_source" style="min-width:220px;">'; echo '<select id="rr_data_source" class="feca-minw-220">';
echo '<option value="">Select data source</option>'; echo '<option value="">Select data source</option>';
foreach ($sources as $source) { foreach ($sources as $source) {
$name = trim((string) ($source['name'] ?? '')); $name = trim((string) ($source['name'] ?? ''));
@ -84,27 +85,27 @@ final class ReviewRecipientsAdminPage
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>'; echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>';
} }
echo '</select></div>'; echo '</select></div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="rr_filter" style="white-space:nowrap;padding-left:4px;"><strong>Filter by</strong></label>'; echo '<label for="rr_filter"><strong>Filter by</strong></label>';
echo '<input id="rr_filter" class="regular-text" type="text" placeholder="Matches any field" style="min-width:240px;">'; echo '<input id="rr_filter" class="regular-text feca-minw-240" type="text" placeholder="Matches any field">';
echo '</div>'; echo '</div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:260px;">'; echo '<div class="feca-control feca-control-min-260">';
echo '<label for="rr_sort_by" style="white-space:nowrap;padding-left:4px;"><strong>Sort by</strong></label>'; echo '<label for="rr_sort_by"><strong>Sort by</strong></label>';
echo '<select id="rr_sort_by" style="min-width:170px;"><option value="">(no fields)</option></select>'; echo '<select id="rr_sort_by" class="feca-minw-170"><option value="">(no fields)</option></select>';
echo '</div>'; echo '</div>';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:280px;">'; echo '<div class="feca-control feca-control-min-280">';
echo '<label for="rr_sort_direction" style="white-space:nowrap;padding-left:4px;"><strong>Sort Direction</strong></label>'; echo '<label for="rr_sort_direction"><strong>Sort Direction</strong></label>';
echo '<select id="rr_sort_direction" style="min-width:140px;"><option value="asc">Ascending</option><option value="desc">Descending</option></select>'; echo '<select id="rr_sort_direction" class="feca-minw-140"><option value="asc">Ascending</option><option value="desc">Descending</option></select>';
echo '</div>'; echo '</div>';
echo '</div></div>'; echo '</div></div>';
echo '<div id="rr_status" style="padding:10px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<div id="rr_status" class="feca-banner">';
echo 'Select a data source to load recipients.'; echo 'Select a data source to load recipients.';
echo '</div>'; echo '</div>';
echo '<div id="rr_error" style="display:none;padding:10px;border:1px solid #ef9a9a;background:#ffebee;margin-bottom:12px;"></div>'; echo '<div id="rr_error" class="feca-banner feca-banner-error feca-hidden"></div>';
echo '<div style="border:1px solid #dcdcde;background:#fff;max-width:calc(100vw - 80px);">'; echo '<div class="feca-scroll-frame">';
echo '<div id="rr_scroll" style="overflow:scroll;max-height:68vh;scrollbar-gutter:stable both-edges;">'; echo '<div id="rr_scroll" class="feca-scroll-pane">';
echo '<table class="widefat striped" id="rr_table" style="min-width:100%;width:max-content;margin:0;border-collapse:separate;border-spacing:0;">'; echo '<table class="widefat striped feca-table-wide" id="rr_table">';
echo '<colgroup id="rr_cols"></colgroup>'; echo '<colgroup id="rr_cols"></colgroup>';
echo '<thead><tr id="rr_head_row"><th>No recipients loaded.</th></tr></thead>'; echo '<thead><tr id="rr_head_row"><th>No recipients loaded.</th></tr></thead>';
echo '<tbody id="rr_body"></tbody></table>'; echo '<tbody id="rr_body"></tbody></table>';
@ -148,30 +149,39 @@ final class ReviewRecipientsAdminPage
echo 'var headRow=document.getElementById("rr_head_row");'; echo 'var headRow=document.getElementById("rr_head_row");';
echo 'var bodyEl=document.getElementById("rr_body");'; echo 'var bodyEl=document.getElementById("rr_body");';
echo 'var cache={};'; echo 'var cache={};';
echo 'var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedRowIndex:-1};'; echo 'var storagePrefix="fecaReviewRecipientsSelection::";';
echo 'var state={source:"",filter:"",sortBy:"",sortDirection:"asc",selectedKeys:{}};';
echo 'function text(v){if(v===null||v===undefined){return "";}if(typeof v==="string"){return v;}if(typeof v==="number"||typeof v==="boolean"){return String(v);}try{return JSON.stringify(v);}catch(_){return String(v);}}'; echo 'function text(v){if(v===null||v===undefined){return "";}if(typeof v==="string"){return v;}if(typeof v==="number"||typeof v==="boolean"){return String(v);}try{return JSON.stringify(v);}catch(_){return String(v);}}';
echo 'function clearErr(){errEl.style.display="none";errEl.textContent="";}'; echo 'function clearErr(){errEl.classList.add("feca-hidden");errEl.textContent="";}';
echo 'function showErr(msg){errEl.style.display="block";errEl.textContent=msg||"Unknown error";}'; echo 'function showErr(msg){errEl.classList.remove("feca-hidden");errEl.textContent=msg||"Unknown error";}';
echo 'function status(msg){statusEl.textContent=msg||"";}'; echo 'function status(msg){statusEl.textContent=msg||"";}';
echo 'function setColumnWidths(cols){var widthPx=170;var c="";for(var i=0;i<cols.length;i++){c+="<col style=\\"width:"+widthPx+"px;min-width:"+widthPx+"px;max-width:"+widthPx+"px\\">";}colsEl.innerHTML=c;}'; echo 'function setColumnWidths(cols){var widthPx=170;var c="<col style=\\"width:44px;min-width:44px;max-width:44px\\">";for(var i=0;i<cols.length;i++){c+="<col style=\\"width:"+widthPx+"px;min-width:"+widthPx+"px;max-width:"+widthPx+"px\\">";}colsEl.innerHTML=c;}';
echo 'function rebuildSortColumns(cols,keep){sortSel.innerHTML="";if(!cols||!cols.length){var o=document.createElement("option");o.value="";o.textContent="(no fields)";sortSel.appendChild(o);state.sortBy="";return;}cols.forEach(function(c,idx){var o=document.createElement("option");o.value=c;o.textContent=c;if((keep&&c===keep)||(!keep&&idx===0)){o.selected=true;state.sortBy=c;}sortSel.appendChild(o);});}'; echo 'function rebuildSortColumns(cols,keep){sortSel.innerHTML="";if(!cols||!cols.length){var o=document.createElement("option");o.value="";o.textContent="(no fields)";sortSel.appendChild(o);state.sortBy="";return;}cols.forEach(function(c,idx){var o=document.createElement("option");o.value=c;o.textContent=c;if((keep&&c===keep)||(!keep&&idx===0)){o.selected=true;state.sortBy=c;}sortSel.appendChild(o);});}';
echo 'function isIdField(name){var n=String(name||"").trim().toLowerCase();if(!n){return false;}return n==="id"||n.endsWith(".id")||n.endsWith("_id")||n==="accountid"||n.endsWith(".accountid");}'; echo 'function isIdField(name){var n=String(name||"").trim().toLowerCase();if(!n){return false;}return n==="id"||n.endsWith(".id")||n.endsWith("_id")||n==="accountid"||n.endsWith(".accountid");}';
echo 'function normalizeColumns(rawCols,rows){var cols=(rawCols||[]).slice();if(cols.length===0&&rows&&rows.length){var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});}var seenOut={};var out=[];cols.forEach(function(c){var key=String(c||"").trim();if(!key||isIdField(key)){return;}var lk=key.toLowerCase();if(seenOut[lk]){return;}seenOut[lk]=1;out.push(key);});out.sort(function(a,b){return a.localeCompare(b,undefined,{sensitivity:"base"});});return out;}'; echo 'function normalizeColumns(rawCols,rows){var cols=(rawCols||[]).slice();if(cols.length===0&&rows&&rows.length){var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});}var seenOut={};var out=[];cols.forEach(function(c){var key=String(c||"").trim();if(!key||isIdField(key)){return;}var lk=key.toLowerCase();if(seenOut[lk]){return;}seenOut[lk]=1;out.push(key);});out.sort(function(a,b){return a.localeCompare(b,undefined,{sensitivity:"base"});});return out;}';
echo 'function dedupeEquivalentColumns(cols,rows){if(!cols||cols.length<2||!rows||rows.length===0){return cols||[];}var keep=[];var signatures={};var limit=Math.min(rows.length,50);for(var i=0;i<cols.length;i++){var col=cols[i];var sig=[];for(var r=0;r<limit;r++){var row=rows[r]||{};sig.push(text(row[col]));}var sigKey=sig.join("\\u241f").toLowerCase();if(sigKey!==""&&signatures[sigKey]){continue;}signatures[sigKey]=col;keep.push(col);}return keep;}';
echo 'function rowMatches(row,needle,cols){if(!needle){return true;}var n=needle.toLowerCase();for(var i=0;i<cols.length;i++){var k=cols[i];if(text((row||{})[k]).toLowerCase().indexOf(n)!==-1){return true;}}return false;}'; echo 'function rowMatches(row,needle,cols){if(!needle){return true;}var n=needle.toLowerCase();for(var i=0;i<cols.length;i++){var k=cols[i];if(text((row||{})[k]).toLowerCase().indexOf(n)!==-1){return true;}}return false;}';
echo 'function headerParts(name){var n=String(name||"");var idx=n.lastIndexOf(".");if(idx<=0||idx>=n.length-1){return {prefix:"",field:n};}return {prefix:n.substring(0,idx),field:n.substring(idx+1)};}'; echo 'function headerParts(name){var n=String(name||"");var idx=n.lastIndexOf(".");if(idx<=0||idx>=n.length-1){return {prefix:"",field:n};}return {prefix:n.substring(0,idx),field:n.substring(idx+1)};}';
echo 'function renderTable(){clearErr();var src=state.source;if(!src){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");return;}var payload=cache[src];if(!payload){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");return;}if(payload.errors&&payload.errors.length){setColumnWidths([]);showErr(payload.errors.join("; "));headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");return;}var rows=(payload.rows||[]).slice();var cols=normalizeColumns(payload.columns||[],rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0));rebuildSortColumns([], "");return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}'; echo 'function sourceStorageKey(src){return storagePrefix+String(src||"");}';
echo 'if(state.selectedRowIndex>=filtered.length){state.selectedRowIndex=-1;}setColumnWidths(cols);headRow.innerHTML="";if(cols.length===0){headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0));return;}cols.forEach(function(c){var th=document.createElement("th");th.style.position="sticky";th.style.top="0";th.style.zIndex="2";th.style.background="#f6f7f7";th.style.whiteSpace="normal";th.style.verticalAlign="bottom";th.style.maxWidth="170px";th.style.padding="8px";var p=headerParts(c);if(p.prefix){th.innerHTML="<div style=\\"line-height:1.2\\"><span style=\\"display:block;font-size:11px;color:#646970;word-break:break-word\\">"+p.prefix+"</span><span style=\\"display:block;font-size:12px;font-weight:600;word-break:break-word\\">"+p.field+"</span></div>";}else{th.innerHTML="<div style=\\"line-height:1.2\\"><span style=\\"display:block;font-size:12px;font-weight:600;word-break:break-word\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r,rowIndex){var tr=document.createElement("tr");tr.setAttribute("data-row-index",String(rowIndex));if(rowIndex===state.selectedRowIndex){tr.style.background="#e8f4ff";}cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.style.whiteSpace="nowrap";td.style.maxWidth="20ch";td.style.width="20ch";td.style.minWidth="20ch";td.style.overflow="hidden";td.style.textOverflow="ellipsis";td.title=v;td.textContent=v;if(rowIndex===state.selectedRowIndex){td.style.background="#e8f4ff";}tr.appendChild(td);});bodyEl.appendChild(tr);});status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+(state.filter?(" | Filter: "+state.filter):""));}'; echo 'function loadSelection(src){if(!src){return {};}try{var raw=sessionStorage.getItem(sourceStorageKey(src));if(!raw){return {};}var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){return parsed;}}catch(_){ }return {};}';
echo 'function saveSelection(){if(!state.source){return;}try{sessionStorage.setItem(sourceStorageKey(state.source),JSON.stringify(state.selectedKeys||{}));}catch(_){ }}';
echo 'function detectKey(row){var r=row||{};var fields=["ID","id","Accountid","account_id"];for(var i=0;i<fields.length;i++){var f=fields[i];if(r[f]!==undefined&&r[f]!==null&&String(r[f]).trim()!==""){return String(r[f]).trim();}}if(r.Email!==undefined&&String(r.Email).trim()!==""){return String(r.Email).trim();}if(r.email!==undefined&&String(r.email).trim()!==""){return String(r.email).trim();}if(r.__rr_index!==undefined){return "row_index:"+String(r.__rr_index);}return "";}';
echo 'function renderTable(){clearErr();var src=state.source;if(!src){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Select a data source to load recipients.");rebuildSortColumns([], "");return;}var payload=cache[src];if(!payload){setColumnWidths([]);headRow.innerHTML="<th>No recipients loaded.</th>";bodyEl.innerHTML="";status("Loading recipients...");return;}if(payload.errors&&payload.errors.length){setColumnWidths([]);showErr(payload.errors.join("; "));headRow.innerHTML="<th>Unable to render recipients.</th>";bodyEl.innerHTML="";status("Load failed.");rebuildSortColumns([], "");return;}var rows=(payload.rows||[]).map(function(r,i){var out=(r&&typeof r==="object")?Object.assign({},r):{};out.__rr_index=i;return out;});var cols=normalizeColumns(payload.columns||[],rows);cols=dedupeEquivalentColumns(cols,rows);if(cols.length===0){setColumnWidths([]);headRow.innerHTML="<th>No fields</th>";bodyEl.innerHTML="";status("Rows: 0 | Total from query: "+(payload.count||0)+" | Selected: 0");rebuildSortColumns([], "");return;}if(state.sortBy&&cols.indexOf(state.sortBy)===-1){state.sortBy="";}rebuildSortColumns(cols,state.sortBy);var filtered=rows.filter(function(r){return rowMatches(r||{},state.filter,cols);});if(state.sortBy){filtered.sort(function(a,b){var l=text((a||{})[state.sortBy]).toLowerCase();var r=text((b||{})[state.sortBy]).toLowerCase();var cmp=l<r?-1:(l>r?1:0);return state.sortDirection==="desc"?-cmp:cmp;});}';
echo 'setColumnWidths(cols);headRow.innerHTML="";var selHead=document.createElement("th");selHead.className="feca-rr-head-cell";selHead.innerHTML="<input id=\\"rr_select_all\\" type=\\"checkbox\\" title=\\"Select/Deselect all visible rows\\">";headRow.appendChild(selHead);cols.forEach(function(c){var th=document.createElement("th");th.className="feca-rr-head-cell";var p=headerParts(c);if(p.prefix){th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-prefix\\">"+p.prefix+"</span><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}else{th.innerHTML="<div class=\\"feca-rr-head-wrap\\"><span class=\\"feca-rr-head-field\\">"+p.field+"</span></div>";}headRow.appendChild(th);});bodyEl.innerHTML="";filtered.forEach(function(r){var tr=document.createElement("tr");var rowKey=detectKey(r);tr.setAttribute("data-row-key",rowKey);var selTd=document.createElement("td");selTd.className="feca-rr-data-cell";var checked=!!(rowKey&&state.selectedKeys[rowKey]);selTd.innerHTML="<input class=\\"rr-row-select\\" type=\\"checkbox\\" data-key=\\""+String(rowKey).replace(/"/g,"&quot;")+"\\""+(checked?" checked":"")+">";tr.appendChild(selTd);cols.forEach(function(c){var td=document.createElement("td");var v=text((r||{})[c]);td.className="feca-rr-data-cell";td.title=v;td.textContent=v;tr.appendChild(td);});bodyEl.appendChild(tr);});';
echo 'var allKeys=rows.map(detectKey).filter(function(k){return !!k;});var selectedInSource=0;allKeys.forEach(function(k){if(state.selectedKeys[k]){selectedInSource++;}});var visibleKeys=filtered.map(detectKey).filter(function(k){return !!k;});var allVisibleSelected=visibleKeys.length>0&&visibleKeys.every(function(k){return !!state.selectedKeys[k];});var selAll=document.getElementById("rr_select_all");if(selAll){selAll.checked=allVisibleSelected;selAll.indeterminate=visibleKeys.length>0&&!allVisibleSelected&&visibleKeys.some(function(k){return !!state.selectedKeys[k];});}status("Rows: "+filtered.length+" | Total from query: "+(payload.count||0)+" | Selected: "+selectedInSource+(state.filter?(" | Filter: "+state.filter):""));saveSelection();}';
echo 'function fetchSource(sourceName){if(!sourceName){state.source="";renderTable();return;}if(cache[sourceName]){state.source=sourceName;renderTable();return;}clearErr();status("Loading recipients...");var url=(cfg.api||"")+""+(String(cfg.api||"").indexOf("?")===-1?"?":"&")+"op=load&data_source="+encodeURIComponent(sourceName);fetch(url,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){var errors=(j&&j.errors)||[(j&&j.error)||"Unknown API error"];cache[sourceName]={errors:errors,rows:[],columns:[],count:0};}else{cache[sourceName]={errors:[],rows:(j.rows||[]),columns:(j.columns||[]),count:(j.count||0)};}state.source=sourceName;renderTable();}).catch(function(e){cache[sourceName]={errors:[(e&&e.message)||"Request failed"],rows:[],columns:[],count:0};state.source=sourceName;renderTable();});}'; echo 'function fetchSource(sourceName){if(!sourceName){state.source="";renderTable();return;}if(cache[sourceName]){state.source=sourceName;renderTable();return;}clearErr();status("Loading recipients...");var url=(cfg.api||"")+""+(String(cfg.api||"").indexOf("?")===-1?"?":"&")+"op=load&data_source="+encodeURIComponent(sourceName);fetch(url,{credentials:"same-origin"}).then(function(r){if(!r.ok){throw new Error("API "+r.status);}return r.json();}).then(function(j){if(!j||j.ok===false){var errors=(j&&j.errors)||[(j&&j.error)||"Unknown API error"];cache[sourceName]={errors:errors,rows:[],columns:[],count:0};}else{cache[sourceName]={errors:[],rows:(j.rows||[]),columns:(j.columns||[]),count:(j.count||0)};}state.source=sourceName;renderTable();}).catch(function(e){cache[sourceName]={errors:[(e&&e.message)||"Request failed"],rows:[],columns:[],count:0};state.source=sourceName;renderTable();});}';
echo 'function updateUrlSource(sourceName){var url=new URL(window.location.href);url.searchParams.set("page",cfg.pageSlug||"feca-mailshots-review-recipients");if(sourceName){url.searchParams.set("data_source",sourceName);}else{url.searchParams.delete("data_source");}window.history.replaceState({}, "", url.toString());}'; echo 'function updateUrlSource(sourceName){var url=new URL(window.location.href);url.searchParams.set("page",cfg.pageSlug||"feca-mailshots-review-recipients");if(sourceName){url.searchParams.set("data_source",sourceName);}else{url.searchParams.delete("data_source");}window.history.replaceState({}, "", url.toString());}';
echo 'sourceSel.addEventListener("change",function(){var s=String(sourceSel.value||"").trim();state.source=s;updateUrlSource(s);fetchSource(s);});'; echo 'sourceSel.addEventListener("change",function(){var s=String(sourceSel.value||"").trim();state.source=s;state.selectedKeys=loadSelection(s);updateUrlSource(s);fetchSource(s);});';
echo 'filterInput.addEventListener("input",function(){state.filter=String(filterInput.value||"").trim();renderTable();});'; echo 'filterInput.addEventListener("input",function(){state.filter=String(filterInput.value||"").trim();renderTable();});';
echo 'sortSel.addEventListener("change",function(){state.sortBy=String(sortSel.value||"").trim();renderTable();});'; echo 'sortSel.addEventListener("change",function(){state.sortBy=String(sortSel.value||"").trim();renderTable();});';
echo 'dirSel.addEventListener("change",function(){state.sortDirection=String(dirSel.value||"asc")==="desc"?"desc":"asc";renderTable();});'; echo 'dirSel.addEventListener("change",function(){state.sortDirection=String(dirSel.value||"asc")==="desc"?"desc":"asc";renderTable();});';
echo 'bodyEl.addEventListener("click",function(ev){var node=ev.target;while(node&&node.tagName!=="TR"){node=node.parentNode;}if(!node){return;}var idx=parseInt(node.getAttribute("data-row-index")||"-1",10);if(!isNaN(idx)&&idx>=0){state.selectedRowIndex=idx;renderTable();}});'; echo 'headRow.addEventListener("change",function(ev){var t=ev.target;if(!t||t.id!=="rr_select_all"){return;}var checked=!!t.checked;var nodes=bodyEl.querySelectorAll("input.rr-row-select[data-key]");nodes.forEach(function(node){var key=String(node.getAttribute("data-key")||"");if(!key){return;}if(checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}node.checked=checked;});renderTable();});';
echo 'bodyEl.addEventListener("change",function(ev){var t=ev.target;if(!t||!t.classList||!t.classList.contains("rr-row-select")){return;}var key=String(t.getAttribute("data-key")||"");if(!key){return;}if(t.checked){state.selectedKeys[key]=true;}else{delete state.selectedKeys[key];}renderTable();});';
echo 'state.sortDirection="asc";dirSel.value="asc";'; echo 'state.sortDirection="asc";dirSel.value="asc";';
echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.ok){cache[cfg.initial.source]={errors:[],rows:(cfg.initial.rows||[]),columns:(cfg.initial.columns||[]),count:(cfg.initial.count||0)};}'; echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.ok){cache[cfg.initial.source]={errors:[],rows:(cfg.initial.rows||[]),columns:(cfg.initial.columns||[]),count:(cfg.initial.count||0)};}';
echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.errors&&cfg.initial.errors.length){cache[cfg.initial.source]={errors:cfg.initial.errors,rows:[],columns:[],count:0};}'; echo 'if(cfg.initial&&cfg.initial.source&&cfg.initial.errors&&cfg.initial.errors.length){cache[cfg.initial.source]={errors:cfg.initial.errors,rows:[],columns:[],count:0};}';
echo 'var startSource=String((cfg.selectedSource||sourceSel.value||"")).trim();state.source=startSource;'; echo 'var startSource=String((cfg.selectedSource||sourceSel.value||"")).trim();state.source=startSource;';
echo 'state.selectedKeys=loadSelection(startSource);';
echo 'if(startSource){fetchSource(startSource);}else{renderTable();}'; echo 'if(startSource){fetchSource(startSource);}else{renderTable();}';
echo '})();'; echo '})();';
echo '</script>'; echo '</script>';

View File

@ -37,6 +37,7 @@ final class RunMailshotAdminPage
$this->wp->addAction('admin_menu', [$this, 'registerMenu']); $this->wp->addAction('admin_menu', [$this, 'registerMenu']);
$this->wp->addAction('admin_post_feca_mailshots_run_api', [$this, 'handleApi']); $this->wp->addAction('admin_post_feca_mailshots_run_api', [$this, 'handleApi']);
$this->wp->addAction('admin_post_feca_mailshots_run_execute_ui', [$this, 'handleRunUi']); $this->wp->addAction('admin_post_feca_mailshots_run_execute_ui', [$this, 'handleRunUi']);
$this->wp->addAction('admin_post_feca_mailshots_run_selected_ui', [$this, 'handleRunSelectedUi']);
$this->wp->addAction('admin_post_feca_mailshots_retry_failed_ui', [$this, 'handleRetryFailedUi']); $this->wp->addAction('admin_post_feca_mailshots_retry_failed_ui', [$this, 'handleRetryFailedUi']);
$this->wp->addAction('admin_post_feca_mailshots_retry_recipient_ui', [$this, 'handleRetryRecipientUi']); $this->wp->addAction('admin_post_feca_mailshots_retry_recipient_ui', [$this, 'handleRetryRecipientUi']);
} }
@ -65,14 +66,14 @@ final class RunMailshotAdminPage
$runCount = !empty($recipientSummary['ok']) ? (int) ($recipientSummary['count'] ?? 0) : 0; $runCount = !empty($recipientSummary['ok']) ? (int) ($recipientSummary['count'] ?? 0) : 0;
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '<div class="wrap"><h1>Run Mailshot</h1>'; echo '<div class="wrap feca-mailshots-admin"><h1>Run Mailshot</h1>';
echo $this->renderAdminUiStyles();
echo '<p>Execute full sends and retry failures from the current mailshot last-run rows.</p>'; echo '<p>Execute full sends and retry failures from the current mailshot last-run rows.</p>';
if ($result !== null) { if ($result !== null) {
$ok = !empty($result['ok']); $ok = !empty($result['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
$border = $ok ? '#8bc34a' : '#ef9a9a'; echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">';
echo '<strong>' . ($ok ? 'Run action completed.' : 'Run action failed.') . '</strong>'; echo '<strong>' . ($ok ? 'Run action completed.' : 'Run action failed.') . '</strong>';
if ($ok) { if ($ok) {
echo '<p>attempted=' . (int) ($result['attempted'] ?? 0) echo '<p>attempted=' . (int) ($result['attempted'] ?? 0)
@ -83,19 +84,18 @@ final class RunMailshotAdminPage
. '</p>'; . '</p>';
} }
if (!empty($result['errors']) && is_array($result['errors'])) { if (!empty($result['errors']) && is_array($result['errors'])) {
echo '<p style="margin:6px 0 0 0;">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>'; echo '<p class="feca-banner-note">' . htmlspecialchars(implode('; ', $result['errors'])) . '</p>';
} }
echo '<details style="margin-top:8px;"><summary>Show technical details</summary><pre style="margin-top:8px;">' . htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) . '</pre></details>';
echo '</div>'; echo '</div>';
} }
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; echo '<div class="feca-panel">';
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>'; echo '<h2 class="feca-section-title">1. Select Mailshot</h2>';
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;" id="run-mailshot-picker">'; echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" id="run-mailshot-picker">';
echo '<input type="hidden" name="page" value="feca-mailshots-run">'; echo '<input type="hidden" name="page" value="feca-mailshots-run">';
echo '<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;">'; echo '<div class="feca-control-row">';
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">'; echo '<div class="feca-control feca-control-min-360">';
echo '<label for="run_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>'; echo '<label for="run_mailshot_id"><strong>Mailshot</strong></label>';
echo '<select id="run_mailshot_id" name="mailshot_id" onchange="document.getElementById(\'run-mailshot-picker\').submit();">'; echo '<select id="run_mailshot_id" name="mailshot_id" onchange="document.getElementById(\'run-mailshot-picker\').submit();">';
foreach ($mailshots as $m) { foreach ($mailshots as $m) {
$id = (int) ($m['id'] ?? 0); $id = (int) ($m['id'] ?? 0);
@ -109,25 +109,49 @@ final class RunMailshotAdminPage
echo '</select></div></div>'; echo '</select></div></div>';
echo '</form>'; echo '</form>';
if (!empty($recipientSummary['ok'])) { if (!empty($recipientSummary['ok'])) {
echo '<p style="margin-top:8px;"><strong>Recipient rows:</strong> ' . (int) ($recipientSummary['count'] ?? 0) . '</p>'; echo '<p class="feca-banner-note"><strong>Recipient rows:</strong> ' . (int) ($recipientSummary['count'] ?? 0) . '</p>';
} elseif (!empty($recipientSummary['errors']) && is_array($recipientSummary['errors'])) { } elseif (!empty($recipientSummary['errors']) && is_array($recipientSummary['errors'])) {
echo '<p style="margin-top:8px;color:#b32d2e;"><strong>Recipient count failed:</strong> ' . htmlspecialchars(implode('; ', $recipientSummary['errors'])) . '</p>'; echo '<p class="feca-banner-note feca-note-danger"><strong>Recipient count failed:</strong> ' . htmlspecialchars(implode('; ', $recipientSummary['errors'])) . '</p>';
} }
echo '</div>'; echo '</div>';
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">'; $reviewUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-review-recipients');
echo '<h2 style="margin-top:0;">2. Run Actions</h2>'; echo '<form method="post" action="' . $action . '" class="feca-form" id="feca-run-actions-form">';
echo '<h2 class="feca-section-title">2. Run Actions</h2>';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">'; echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
echo '<input type="hidden" name="selected_recipient_keys" id="run_selected_recipient_keys" value="[]">';
echo '<p class="feca-button-row">';
echo '<button class="button button-primary" type="submit" name="action" value="feca_mailshots_run_execute_ui" onclick="return confirm(\'Run Mailshot for ' . $runCount . ' recipients?\');">Run Mailshot</button> '; echo '<button class="button button-primary" type="submit" name="action" value="feca_mailshots_run_execute_ui" onclick="return confirm(\'Run Mailshot for ' . $runCount . ' recipients?\');">Run Mailshot</button> ';
echo '<button class="button" type="submit" name="action" value="feca_mailshots_run_selected_ui" id="run-mailshot-selected-btn">Run Mailshot to Selected Rows</button> ';
echo '<button class="button" type="submit" name="action" value="feca_mailshots_retry_failed_ui" onclick="return confirm(\'Retry all failed recipients for this mailshot?\');">Retry Failed Sends</button>'; echo '<button class="button" type="submit" name="action" value="feca_mailshots_retry_failed_ui" onclick="return confirm(\'Retry all failed recipients for this mailshot?\');">Retry Failed Sends</button>';
echo '</p>';
echo '<p class="feca-banner-note">Select rows for mailshot in <a href="' . htmlspecialchars($reviewUrl, ENT_QUOTES) . '">Review Recipients</a> page.</p>';
echo '</form>'; echo '</form>';
echo '<script>';
echo '(function(){';
echo 'var form=document.getElementById("feca-run-actions-form");';
echo 'if(!form){return;}';
echo 'var selectedBtn=document.getElementById("run-mailshot-selected-btn");';
echo 'var selectedInput=document.getElementById("run_selected_recipient_keys");';
echo 'var source=' . json_encode((string) ($this->selectedMailshotDataSource($mailshots, $selectedMailshotId)), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';';
echo 'var keyPrefix="fecaReviewRecipientsSelection::";';
echo 'if(selectedBtn){selectedBtn.addEventListener("click",function(ev){';
echo 'var keys=[];';
echo 'try{if(source){var raw=sessionStorage.getItem(keyPrefix+source);if(raw){var parsed=JSON.parse(raw);if(parsed&&typeof parsed==="object"){keys=Object.keys(parsed).filter(function(k){return !!parsed[k];});}}}}catch(_){keys=[];}';
echo 'if(selectedInput){selectedInput.value=JSON.stringify(keys);}';
echo 'if(keys.length===0){ev.preventDefault();window.alert("No selected rows found. Select rows in Review Recipients first.");return false;}';
echo 'return window.confirm("Run Mailshot to "+keys.length+" selected rows?");';
echo '});}';
echo '})();';
echo '</script>';
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;">'; echo '<div class="feca-panel">';
echo '<h2 style="margin-top:0;">3. Last Run Rows</h2>'; echo '<h2 class="feca-section-title">3. Last Run Rows</h2>';
if ($lastRunRows === []) { if ($lastRunRows === []) {
echo '<p>No rows in mailshot_last_run for this mailshot.</p>'; echo '<p>No rows in mailshot_last_run for this mailshot.</p>';
} else { } else {
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
echo '<table class="widefat striped"><thead><tr><th>Recipient Key</th><th>Email</th><th>Status</th><th>Attempts</th><th>Error/Warning</th><th>Retry</th></tr></thead><tbody>'; echo '<table class="widefat striped"><thead><tr><th>Recipient Key</th><th>Email</th><th>Status</th><th>Attempts</th><th>Error/Warning</th><th>Retry</th></tr></thead><tbody>';
foreach ($lastRunRows as $row) { foreach ($lastRunRows as $row) {
$recipientKey = (string) ($row['recipient_key'] ?? ''); $recipientKey = (string) ($row['recipient_key'] ?? '');
@ -153,6 +177,7 @@ final class RunMailshotAdminPage
echo '</tr>'; echo '</tr>';
} }
echo '</tbody></table>'; echo '</tbody></table>';
echo '</div></div>';
} }
echo '</div>'; echo '</div>';
@ -175,6 +200,13 @@ final class RunMailshotAdminPage
return; return;
} }
if ($op === 'run_mailshot_selected') {
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$selectedRecipientKeys = $this->selectedRecipientKeysFromRequest();
$this->wp->sendJson($service->runMailshotSelected($mailshotId, $selectedRecipientKeys));
return;
}
if ($op === 'retry_failed') { if ($op === 'retry_failed') {
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0'); $mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$this->wp->sendJson($service->retryFailed($mailshotId)); $this->wp->sendJson($service->retryFailed($mailshotId));
@ -205,6 +237,18 @@ final class RunMailshotAdminPage
$this->redirect($mailshotId); $this->redirect($mailshotId);
} }
public function handleRunSelectedUi(): void
{
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
return;
}
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
$selectedRecipientKeys = $this->selectedRecipientKeysFromRequest();
$result = $this->runService()->runMailshotSelected($mailshotId, $selectedRecipientKeys);
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
$this->redirect($mailshotId);
}
public function handleRetryFailedUi(): void public function handleRetryFailedUi(): void
{ {
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) { if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
@ -254,4 +298,36 @@ final class RunMailshotAdminPage
{ {
return ($this->mailshotServiceFactory)(); return ($this->mailshotServiceFactory)();
} }
/** @param list<array<string,mixed>> $mailshots */
private function selectedMailshotDataSource(array $mailshots, int $mailshotId): string
{
foreach ($mailshots as $mailshot) {
if ((int) ($mailshot['id'] ?? 0) === $mailshotId) {
return trim((string) ($mailshot['DataSource'] ?? ''));
}
}
return '';
}
/** @return list<string> */
private function selectedRecipientKeysFromRequest(): array
{
$raw = trim((string) ($this->wp->requestParam('selected_recipient_keys', '[]') ?? '[]'));
if ($raw === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return [];
}
$out = [];
foreach ($decoded as $value) {
$key = trim((string) $value);
if ($key !== '') {
$out[] = $key;
}
}
return array_values(array_unique($out));
}
} }

View File

@ -12,6 +12,7 @@ final class SetupAdminPage
public const OPTION_KEY = 'feca_mailshots_db_settings'; public const OPTION_KEY = 'feca_mailshots_db_settings';
public const TEST_RESULT_OPTION_KEY = 'feca_mailshots_db_test_result'; public const TEST_RESULT_OPTION_KEY = 'feca_mailshots_db_test_result';
public const DOWNLOAD_MEMORY_LIMIT_OPTION = 'feca_mailshots_download_memory_limit';
private const NONCE_ACTION = 'feca_mailshots_setup'; private const NONCE_ACTION = 'feca_mailshots_setup';
private WordPressFacade $wp; private WordPressFacade $wp;
@ -41,21 +42,22 @@ final class SetupAdminPage
} }
$saved = $this->settings(); $saved = $this->settings();
$downloadMemoryLimit = trim((string) ($this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '')));
$status = $this->wp->requestParam('saved', '') === '1' ? 'Settings saved.' : ''; $status = $this->wp->requestParam('saved', '') === '1' ? 'Settings saved.' : '';
$test = $this->testResult(); $test = $this->testResult();
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php')); $action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
echo '<div class="wrap">'; echo '<div class="wrap feca-mailshots-admin">';
echo $this->renderAdminUiStyles();
echo '<h1>FECA Mailshots Setup</h1>'; echo '<h1>FECA Mailshots Setup</h1>';
if ($status !== '') { if ($status !== '') {
echo '<div style="padding:10px;border:1px solid #8bc34a;background:#f1f8e9;margin:12px 0;">' . htmlspecialchars($status) . '</div>'; echo '<div class="feca-banner feca-banner-success">' . htmlspecialchars($status) . '</div>';
} }
if ($test !== null) { if ($test !== null) {
$ok = !empty($test['ok']); $ok = !empty($test['ok']);
$bg = $ok ? '#f1f8e9' : '#ffebee';
$border = $ok ? '#8bc34a' : '#ef9a9a';
$title = $ok ? 'Connection test passed.' : 'Connection test failed.'; $title = $ok ? 'Connection test passed.' : 'Connection test failed.';
echo '<div style="padding:10px;border:1px solid ' . $border . ';background:' . $bg . ';margin:12px 0;">'; $bannerClass = $ok ? 'feca-banner-success' : 'feca-banner-error';
echo '<div class="feca-banner ' . $bannerClass . '">';
echo '<strong>' . htmlspecialchars($title) . '</strong>'; echo '<strong>' . htmlspecialchars($title) . '</strong>';
if (!empty($test['messages']) && is_array($test['messages'])) { if (!empty($test['messages']) && is_array($test['messages'])) {
echo '<ul>'; echo '<ul>';
@ -67,17 +69,25 @@ final class SetupAdminPage
echo '</div>'; echo '</div>';
} }
echo '<p>Configure database credentials used by FECA Mailshots in this WordPress environment.</p>'; echo '<p>Configure database credentials used by FECA Mailshots in this WordPress environment.</p>';
echo '<form method="post" action="' . $action . '">'; echo '<form method="post" action="' . $action . '" class="feca-form feca-form-max-860">';
echo $this->hiddenNonceField(self::NONCE_ACTION); echo $this->hiddenNonceField(self::NONCE_ACTION);
echo '<table class="form-table" role="presentation">';
$this->field('Host', 'db_host', $saved['db_host'] ?? ''); $this->field('Host', 'db_host', $saved['db_host'] ?? '');
$this->field('Port', 'db_port', $saved['db_port'] ?? '3306'); $this->field('Port', 'db_port', $saved['db_port'] ?? '3306');
$this->field('User', 'db_user', $saved['db_user'] ?? ''); $this->field('User', 'db_user', $saved['db_user'] ?? '');
$this->field('Password', 'db_password', $saved['db_password'] ?? '', 'password'); $this->field('Password', 'db_password', $saved['db_password'] ?? '', 'password');
$this->field('Mailshots DB Name', 'mailshots_db_name', $saved['mailshots_db_name'] ?? ''); $this->field('Mailshots DB Name', 'mailshots_db_name', $saved['mailshots_db_name'] ?? '');
$this->field('Members DB Name', 'members_db_name', $saved['members_db_name'] ?? ''); $this->field('Members DB Name', 'members_db_name', $saved['members_db_name'] ?? '');
$this->field(
'Download PDF Memory Limit',
'download_memory_limit',
$downloadMemoryLimit,
'text',
'PHP memory_limit value used for Download PDF actions (for example: 256M, 512M, 1G). Suggested value 512M.'
);
echo '</table>';
echo '<p>'; echo '<p class="feca-button-row">';
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_setup_save">Save Setup</button> '; echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_setup_save">Save Setup</button> ';
echo '<button type="submit" class="button" name="action" value="feca_mailshots_setup_test">Test Connection</button>'; echo '<button type="submit" class="button" name="action" value="feca_mailshots_setup_test">Test Connection</button>';
echo '</p>'; echo '</p>';
@ -99,8 +109,17 @@ final class SetupAdminPage
'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')), 'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_db_name', '') ?? '')),
'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')), 'members_db_name' => trim((string) ($this->wp->requestParam('members_db_name', '') ?? '')),
]; ];
$downloadMemoryLimit = trim((string) ($this->wp->requestParam('download_memory_limit', '') ?? ''));
if ($downloadMemoryLimit !== '' && $this->memoryLimitToBytes($downloadMemoryLimit) <= 0) {
$this->wp->sendJson([
'ok' => false,
'errors' => ['Invalid download_memory_limit. Use a PHP memory value such as 256M, 512M, or 1G.'],
], 400);
return;
}
$this->wp->updateOption(self::OPTION_KEY, $settings); $this->wp->updateOption(self::OPTION_KEY, $settings);
$this->wp->updateOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, $downloadMemoryLimit);
if (!headers_sent()) { if (!headers_sent()) {
$location = $this->wp->adminUrl('admin.php?page=feca-mailshots-setup&saved=1'); $location = $this->wp->adminUrl('admin.php?page=feca-mailshots-setup&saved=1');
@ -152,12 +171,44 @@ final class SetupAdminPage
return $out; return $out;
} }
private function field(string $label, string $name, string $value, string $type = 'text'): void private function field(string $label, string $name, string $value, string $type = 'text', string $hint = ''): void
{ {
echo '<table class="form-table" role="presentation"><tr>'; echo '<tr>';
echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>'; echo '<th scope="row"><label for="' . htmlspecialchars($name) . '">' . htmlspecialchars($label) . '</label></th>';
echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '"></td>'; echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
echo '</tr></table>'; if ($hint !== '') {
echo '<p class="description">' . htmlspecialchars($hint) . '</p>';
}
echo '</td>';
echo '</tr>';
}
private function memoryLimitToBytes(string $limit): int
{
$v = trim($limit);
if ($v === '') {
return 0;
}
if ($v === '-1') {
return -1;
}
$unit = strtolower(substr($v, -1));
if (ctype_alpha($unit)) {
$num = (float) substr($v, 0, -1);
switch ($unit) {
case 'g':
return (int) ($num * 1024 * 1024 * 1024);
case 'm':
return (int) ($num * 1024 * 1024);
case 'k':
return (int) ($num * 1024);
default:
return (int) $num;
}
}
return (int) $v;
} }
/** @return array{ok:bool,messages:list<string>} */ /** @return array{ok:bool,messages:list<string>} */

View File

@ -191,6 +191,77 @@ final class MailshotRunService
} }
} }
/** @return array<string, mixed> */
public function sendTestAll(int $mailshotId, string $testEmail): array
{
$testEmail = trim($testEmail);
if ($testEmail === '') {
return ['ok' => false, 'errors' => ['Test email address is required.']];
}
try {
$creds = $this->credentials->credentials();
if ($creds === null) {
return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
}
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
if ($rows === []) {
return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
}
$cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
$bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
$counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0];
$errors = [];
foreach (array_values($rows) as $index => $row) {
$counters['attempted']++;
[$recipientKeyField, $recipientKey] = $this->detectRecipientKey((array) $row, $index);
try {
$render = $this->renderer->render(
(string) $mailshot['Subject'],
(string) $mailshot['Message'],
(string) ($mailshot['PDFAttachment'] ?? ''),
(array) $row
);
$attachments = array_merge(
$this->staticAttachments($mailshot),
$this->renderedPdfAttachments($mailshot, (array) $row, $render)
);
$send = $this->smtp->send(
$creds,
[$testEmail],
$cc,
$bcc,
(string) $render['subject'],
(string) $render['message'],
(string) ($mailshot['ReplyTo'] ?? ''),
$attachments
);
$counters['sent']++;
try {
$attemptId = sprintf('test_all_%d_%s_%d', $mailshotId, $recipientKey, time());
$this->imap->appendSent($creds, (string) $send['raw_mime'], $attemptId);
} catch (\Throwable $imapErr) {
$counters['warnings']++;
}
} catch (\Throwable $e) {
$counters['failed']++;
$errors[] = 'Recipient ' . $recipientKeyField . '=' . $recipientKey . ': ' . $e->getMessage();
}
}
return [
'ok' => $counters['failed'] === 0,
'sent_to' => $testEmail,
'sent_at' => gmdate('c'),
] + $counters + ['errors' => $errors];
} catch (\Throwable $e) {
return ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
}
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function runMailshot(int $mailshotId): array public function runMailshot(int $mailshotId): array
{ {
@ -208,6 +279,43 @@ final class MailshotRunService
return $this->executeSendLoop($mailshotId, $mailshot, $rows, $creds, true); return $this->executeSendLoop($mailshotId, $mailshot, $rows, $creds, true);
} }
/** @param list<string> $selectedRecipientKeys @return array<string, mixed> */
public function runMailshotSelected(int $mailshotId, array $selectedRecipientKeys): array
{
$selectedRecipientKeys = array_values(array_filter(array_map(static fn($v): string => trim((string) $v), $selectedRecipientKeys), static fn(string $v): bool => $v !== ''));
if ($selectedRecipientKeys === []) {
return ['ok' => false, 'errors' => ['No selected recipient rows were provided.']];
}
$creds = $this->credentials->credentials();
if ($creds === null) {
return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
}
[$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
if ($rows === []) {
return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
}
$selectedSet = array_fill_keys($selectedRecipientKeys, true);
$filteredRows = [];
foreach (array_values($rows) as $index => $row) {
[, $recipientKey] = $this->detectRecipientKey((array) $row, $index);
if (isset($selectedSet[$recipientKey])) {
$filteredRows[] = $row;
}
}
if ($filteredRows === []) {
return ['ok' => false, 'errors' => ['None of the selected recipient rows exist in the current query result.']];
}
$this->lastRun->clearForMailshot($mailshotId);
$result = $this->executeSendLoop($mailshotId, $mailshot, $filteredRows, $creds, true);
$result['selected_count'] = count($filteredRows);
return $result;
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function retryFailed(int $mailshotId): array public function retryFailed(int $mailshotId): array
{ {
@ -273,13 +381,6 @@ final class MailshotRunService
/** @return array<string,mixed> */ /** @return array<string,mixed> */
public function generatePdfBatch(int $mailshotId, bool $includeMerged = true, bool $includeFiles = true): array public function generatePdfBatch(int $mailshotId, bool $includeMerged = true, bool $includeFiles = true): array
{ {
$this->downloadDebugLog('generatePdfBatch.start', [
'mailshot_id' => $mailshotId,
'include_merged' => $includeMerged,
'include_files' => $includeFiles,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
if (!$includeMerged && !$includeFiles) { if (!$includeMerged && !$includeFiles) {
return ['ok' => false, 'errors' => ['PDF generation requested no outputs.']]; return ['ok' => false, 'errors' => ['PDF generation requested no outputs.']];
} }
@ -311,11 +412,6 @@ final class MailshotRunService
if (!@mkdir($mergedPdfTmpDir, 0700, true) && !is_dir($mergedPdfTmpDir)) { if (!@mkdir($mergedPdfTmpDir, 0700, true) && !is_dir($mergedPdfTmpDir)) {
return ['ok' => false, 'errors' => ['Unable to prepare temporary directory for merged PDF build.']]; return ['ok' => false, 'errors' => ['Unable to prepare temporary directory for merged PDF build.']];
} }
$this->downloadDebugLog('generatePdfBatch.merged.ghostscript_enabled', [
'mailshot_id' => $mailshotId,
'ghostscript' => $ghostscriptBinary,
'tmp_dir' => $mergedPdfTmpDir,
]);
} elseif ($includeMerged) { } elseif ($includeMerged) {
$tmpBasePath = tempnam(sys_get_temp_dir(), 'feca_mailshots_merged_html_'); $tmpBasePath = tempnam(sys_get_temp_dir(), 'feca_mailshots_merged_html_');
if (!is_string($tmpBasePath) || $tmpBasePath === '') { if (!is_string($tmpBasePath) || $tmpBasePath === '') {
@ -384,17 +480,6 @@ final class MailshotRunService
if (($index % 10) === 0 && function_exists('gc_collect_cycles')) { if (($index % 10) === 0 && function_exists('gc_collect_cycles')) {
gc_collect_cycles(); gc_collect_cycles();
} }
if (($index % 10) === 0) {
$this->downloadDebugLog('generatePdfBatch.progress', [
'mailshot_id' => $mailshotId,
'row_index' => $index,
'generated_files' => count($files),
'merged_sections' => $mergedSectionCount,
'skipped' => $skipped,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
}
} }
if ($includeMerged && is_resource($mergedHtmlHandle)) { if ($includeMerged && is_resource($mergedHtmlHandle)) {
@ -429,42 +514,19 @@ final class MailshotRunService
if ($includeMerged) { if ($includeMerged) {
if ($useGhostscriptMerge) { if ($useGhostscriptMerge) {
try { try {
$this->downloadDebugLog('generatePdfBatch.merged.before_ghostscript', [
'mailshot_id' => $mailshotId,
'parts' => count($mergedPdfPartPaths),
]);
$mergedPdfBytes = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths); $mergedPdfBytes = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths); $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
$this->downloadDebugLog('generatePdfBatch.merged.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]; return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
} }
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths); $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
} else { } else {
try { try {
$this->downloadDebugLog('generatePdfBatch.merged.before_render', [
'mailshot_id' => $mailshotId,
'merged_html_path' => (string) $mergedHtmlTmpPath,
'merged_sections' => $mergedSectionCount,
]);
$mergedPdfBytes = $this->renderPdfBytesFromHtmlFile((string) $mergedHtmlTmpPath); $mergedPdfBytes = $this->renderPdfBytesFromHtmlFile((string) $mergedHtmlTmpPath);
$this->downloadDebugLog('generatePdfBatch.merged.after_render', [
'mailshot_id' => $mailshotId,
'bytes_len' => strlen($mergedPdfBytes),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
if ($mergedHtmlTmpPath !== null) { if ($mergedHtmlTmpPath !== null) {
@unlink($mergedHtmlTmpPath); @unlink($mergedHtmlTmpPath);
} }
$this->downloadDebugLog('generatePdfBatch.merged.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
]);
return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]]; return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
} }
if ($mergedHtmlTmpPath !== null) { if ($mergedHtmlTmpPath !== null) {
@ -473,15 +535,6 @@ final class MailshotRunService
} }
} }
$this->downloadDebugLog('generatePdfBatch.done', [
'mailshot_id' => $mailshotId,
'recipient_count' => count($rows),
'generated_count' => $includeFiles ? count($files) : $mergedSectionCount,
'skipped_count' => $skipped,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
return [ return [
'ok' => true, 'ok' => true,
'recipient_count' => count($rows), 'recipient_count' => count($rows),
@ -495,11 +548,6 @@ final class MailshotRunService
/** @return array<string,mixed> */ /** @return array<string,mixed> */
public function generatePdfZipToTemp(int $mailshotId): array public function generatePdfZipToTemp(int $mailshotId): array
{ {
$this->downloadDebugLog('generatePdfZipToTemp.start', [
'mailshot_id' => $mailshotId,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
$canUseZipArchive = class_exists('ZipArchive'); $canUseZipArchive = class_exists('ZipArchive');
$zipBinary = $this->findExecutableBinary('zip', ['/usr/bin/zip', '/bin/zip']); $zipBinary = $this->findExecutableBinary('zip', ['/usr/bin/zip', '/bin/zip']);
if (!$canUseZipArchive && $zipBinary === '') { if (!$canUseZipArchive && $zipBinary === '') {
@ -578,16 +626,6 @@ final class MailshotRunService
if (($index % 5) === 0 && function_exists('gc_mem_caches')) { if (($index % 5) === 0 && function_exists('gc_mem_caches')) {
gc_mem_caches(); gc_mem_caches();
} }
if (($index % 10) === 0) {
$this->downloadDebugLog('generatePdfZipToTemp.progress', [
'mailshot_id' => $mailshotId,
'row_index' => $index,
'generated' => $generated,
'skipped' => $skipped,
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
}
} }
if ($errors !== []) { if ($errors !== []) {
@ -605,11 +643,6 @@ final class MailshotRunService
} }
if ($canUseZipArchive) { if ($canUseZipArchive) {
$this->downloadDebugLog('generatePdfZipToTemp.ziparchive.begin', [
'mailshot_id' => $mailshotId,
'zip_path' => $tmpZipPath,
'file_count' => count($generatedPdfNames),
]);
$zip = new \ZipArchive(); $zip = new \ZipArchive();
$opened = $zip->open($tmpZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); $opened = $zip->open($tmpZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($opened !== true) { if ($opened !== true) {
@ -622,11 +655,6 @@ final class MailshotRunService
} }
$zip->close(); $zip->close();
} else { } else {
$this->downloadDebugLog('generatePdfZipToTemp.zipcli.begin', [
'mailshot_id' => $mailshotId,
'zip_path' => $tmpZipPath,
'file_count' => count($generatedPdfNames),
]);
if ($zipBinary === '') { if ($zipBinary === '') {
throw new \RuntimeException('zip CLI binary is not available.'); throw new \RuntimeException('zip CLI binary is not available.');
} }
@ -670,12 +698,6 @@ final class MailshotRunService
'zip_size' => $size, 'zip_size' => $size,
]; ];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->downloadDebugLog('generatePdfZipToTemp.exception', [
'mailshot_id' => $mailshotId,
'error' => $e->getMessage(),
'memory_usage' => memory_get_usage(true),
'memory_peak' => memory_get_peak_usage(true),
]);
foreach ($generatedPdfNames as $name) { foreach ($generatedPdfNames as $name) {
@unlink($tmpPdfDir . '/' . $name); @unlink($tmpPdfDir . '/' . $name);
} }
@ -1137,32 +1159,6 @@ final class MailshotRunService
} }
} }
/** @param array<string,mixed> $context */
private function downloadDebugLog(string $event, array $context = []): void
{
$line = '[feca-mailshots/run-service][' . gmdate('Y-m-d H:i:s') . ' UTC] ' . $event;
if ($context !== []) {
$json = json_encode($context);
if (is_string($json) && $json !== '') {
$line .= ' ' . $json;
}
}
error_log($line);
if (!defined('ABSPATH')) {
return;
}
$path = ABSPATH . 'wp-content/uploads/feca_mailshots_download_debug.log';
$dir = dirname($path);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
if (is_dir($dir) && is_writable($dir)) {
@error_log($line . PHP_EOL, 3, $path);
}
}
/** /**
* @param array<string,mixed> $mailshot * @param array<string,mixed> $mailshot
* @return list<array{filename:string,mime_type:string,content_bytes:string}> * @return list<array{filename:string,mime_type:string,content_bytes:string}>

View File

@ -3,9 +3,15 @@
declare(strict_types=1); declare(strict_types=1);
// Load Composer dependencies (Twig, Dompdf, etc.) when available. // Load Composer dependencies (Twig, Dompdf, etc.) when available.
$composerAutoload = dirname(__DIR__, 2) . '/vendor/autoload.php'; $autoloadCandidates = [
dirname(__DIR__) . '/vendor/autoload.php', // Preferred: packaged/deployed plugin-local vendor
dirname(__DIR__, 2) . '/vendor/autoload.php', // Backward-compatible: workspace/shared vendor
];
foreach ($autoloadCandidates as $composerAutoload) {
if (is_file($composerAutoload)) { if (is_file($composerAutoload)) {
require_once $composerAutoload; require_once $composerAutoload;
break;
}
} }
spl_autoload_register(static function (string $class): void { spl_autoload_register(static function (string $class): void {

View File

@ -37,12 +37,12 @@ $dbConfig = [
$wp = new ProductionWordPressFacade(); $wp = new ProductionWordPressFacade();
$container = Plugin::buildContainer($dbConfig, $wp); $container = Plugin::buildContainer($dbConfig, $wp);
$container->get(FecaMailshots\Admin\DataSourcesAdminPage::class)->register(); $container->get(FecaMailshots\Admin\DataSourcesAdminPage::class)->register();
$container->get(FecaMailshots\Admin\MailshotsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\AttachmentsAdminPage::class)->register(); $container->get(FecaMailshots\Admin\AttachmentsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\PdfAssetsAdminPage::class)->register(); $container->get(FecaMailshots\Admin\PdfAssetsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\SetupAdminPage::class)->register(); $container->get(FecaMailshots\Admin\MailshotsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register(); $container->get(FecaMailshots\Admin\ReviewRecipientsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\MailshotTestAdminPage::class)->register(); $container->get(FecaMailshots\Admin\MailshotTestAdminPage::class)->register();
$container->get(FecaMailshots\Admin\RunMailshotAdminPage::class)->register(); $container->get(FecaMailshots\Admin\RunMailshotAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ReviewRecipientsAdminPage::class)->register();
$container->get(FecaMailshots\Admin\DownloadPdfAdminPage::class)->register(); $container->get(FecaMailshots\Admin\DownloadPdfAdminPage::class)->register();
$container->get(FecaMailshots\Admin\SetupAdminPage::class)->register();
$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register();

View File

@ -61,6 +61,40 @@ Hard constraints for this task:
- `scripts/` must contain a deployment script for deploying to the production server. - `scripts/` must contain a deployment script for deploying to the production server.
- `scripts/` must contain a packaging script that creates an uploadable WordPress plugin package. - `scripts/` must contain a packaging script that creates an uploadable WordPress plugin package.
## Third-Party Software Inventory
- PHP/Composer libraries (installed into `vendor/`):
- `twig/twig`
- `dompdf/dompdf`
- transitive dependencies from `composer.lock` (for example `masterminds/html5`, `sabberworm/php-css-parser`, `thecodingmachine/safe`, Symfony polyfills)
- Frontend editor/runtime assets (vendored under plugin `assets/vendor/`):
- `Jodit` rich-text editor
- `Ace` editor (`ace.js`, html mode/worker, language tools, theme)
- Node/test toolchain:
- `@playwright/test`
- Playwright Chromium browser binaries (installed by Playwright CLI)
- System CLI/runtime dependencies used by scripts/runtime:
- `zip` (required for plugin packaging)
- `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
## Dependency Installation Scripts
- `scripts/install_dependencies.sh`
- Installs Composer dependencies (`vendor/`) for plugin runtime.
- Installs/bundles editor assets (Jodit + Ace) into `feca_mailshots_plugin/assets/vendor/`.
- Installs Node dependencies (`npm ci`) unless skipped by flag.
- `scripts/package_plugin.sh`
- Calls `scripts/install_dependencies.sh --skip-node` before packaging.
- Builds versioned WordPress zip under `dist/`, including runtime `vendor/` and bundled editor assets.
- `scripts/deploy_remote.sh`
- Calls `scripts/install_dependencies.sh --skip-node` before deploy.
- Deploys plugin files and runtime `vendor/` to the configured remote WordPress plugin directory.
- `tests/e2e/run.sh`
- Installs Node modules if missing, installs Playwright Chromium, and runs E2E tests (fixture or remote mode).
## Versioning Requirements ## Versioning Requirements
- The code must expose a WordPress-reportable semantic version number in `x.y.z` format. - The code must expose a WordPress-reportable semantic version number in `x.y.z` format.

View File

@ -151,6 +151,11 @@ This is defined in `requirements/mailshot_data_source.md`.
* Display columns from a deterministic field contract derived from all DSL-cited sources, plus any additional source-native columns present in preview output. * Display columns from a deterministic field contract derived from all DSL-cited sources, plus any additional source-native columns present in preview output.
* The deterministic field contract must include all allowed fields for each cited source (built-in or custom). * The deterministic field contract must include all allowed fields for each cited source (built-in or custom).
* Provide row count and sample-limit indicator in the information pane. * Provide row count and sample-limit indicator in the information pane.
* Provide per-row checkbox selection in the recipients table.
* Provide a header-level checkbox to select/deselect all currently visible rows.
* Display count of currently selected rows in the information pane/status line.
* Persist row selection state for the current browser session only (no database storage).
* Session-persisted selection must survive leaving and re-entering the page in the same browser session.
* Provide generalized recipient sorting controls (`Sort by` and sort direction) and apply them to the displayed preview rows. * Provide generalized recipient sorting controls (`Sort by` and sort direction) and apply them to the displayed preview rows.
* If query returns zero rows, display zero-result message (no error state). * If query returns zero rows, display zero-result message (no error state).
* Provide `Download spreadsheet` action. * Provide `Download spreadsheet` action.
@ -169,6 +174,7 @@ This is defined in `requirements/mailshot_data_source.md`.
* Provide control to select a mailshot. * Provide control to select a mailshot.
* Provide control to select one preview/test recipient row from the resolved query output. * Provide control to select one preview/test recipient row from the resolved query output.
* 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. * 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. * 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. * Rendered Message control must display the html message according to the html formatting.
@ -190,6 +196,11 @@ This is defined in `requirements/mailshot_data_source.md`.
* When `Test email address` is blank, block send and show validation error in the information pane. * When `Test email address` is blank, block send and show validation error in the information pane.
* Use selected test row only for template rendering context. * Use selected test row only for template rendering context.
* Send exactly one email to `Test email address` (not to original recipient address). * Send exactly one email to `Test email address` (not to original recipient address).
* When `All recipients` is selected, send one rendered test email per recipient row, all to `Test email address`.
* Confirmation prompt text must be context-aware:
* one-row mode: `Send one test email to the entered address?`
* all-recipient mode: `Send <n> emails to the entered address?`
* `Render Test (No Send)` must require a specific recipient row (not `All recipients`).
* Copy sent test email to IMAP `Sent`. * Copy sent test email to IMAP `Sent`.
* Display success/failure status and timestamp. * Display success/failure status and timestamp.
* Keep rendered `Subject`, `Message`, and `PDFAttachment` visible after send. * Keep rendered `Subject`, `Message`, and `PDFAttachment` visible after send.
@ -211,6 +222,10 @@ This is defined in `requirements/mailshot_data_source.md`.
* send one email to the recipient destination email * send one email to the recipient destination email
* copy successful sends to IMAP `Sent` * copy successful sends to IMAP `Sent`
* Continue processing remaining recipients after per-recipient failures. * Continue processing remaining recipients after per-recipient failures.
* Provide action `Run Mailshot to Selected Rows` to run only rows selected in `Review Recipients`.
* `Run Mailshot to Selected Rows` must use session-persisted selected recipient keys from `Review Recipients` (same browser session, no database storage).
* If no selected rows are available for the selected mailshot data source, block action with explicit validation message.
* Run page must include hint text: `Select rows for mailshot in Review Recipients page.` with a link to the Review Recipients admin page.
### 1.8.3 Run Results ### 1.8.3 Run Results

View File

@ -47,8 +47,6 @@ For pages using info/statistics/actions/data regions:
* Persist user-adjusted split ratios per page. * Persist user-adjusted split ratios per page.
* On narrow viewports, collapse split layouts to a vertical stack and disable drag interaction. * On narrow viewports, collapse split layouts to a vertical stack and disable drag interaction.
* Keep each pane independently scrollable when content exceeds pane bounds. * Keep each pane independently scrollable when content exceeds pane bounds.
*
*
## Common UX Rules ## Common UX Rules
@ -57,8 +55,11 @@ For pages using info/statistics/actions/data regions:
* Use red visual treatment for destructive actions. * Use red visual treatment for destructive actions.
* Provide spacing between action buttons and panel edges. * Provide spacing between action buttons and panel edges.
* Use "Save" / "Quit" to save (and leave) a modal or "Quit" to leave it. * Use "Save" / "Quit" to save (and leave) a modal or "Quit" to leave it.
* If "Save" fails - display a message and do not leave the modal. * If "Save" fails - display a message in the modal and do not leave the modal.
* Under no conditions allow a user-entry error to lose user entered data - always allow them a way to recover without reentering work. * Under no conditions allow a user-entry error to lose user entered data - always allow them a way to recover without reentering work.
* If an operation takes a long time (more than 1s), display a progress message while the operation is taking place, and remove it afterwards.
* Keep control labels and the controls they label together.
* Allow both vertical and horizontal space between controls.
## Default Table Behavior ## Default Table Behavior
@ -69,7 +70,7 @@ For pages using info/statistics/actions/data regions:
* a `Sort by` dropdown listing sortable columns * a `Sort by` dropdown listing sortable columns
* an adjacent direction dropdown with options `Ascending` and `Descending` * an adjacent direction dropdown with options `Ascending` and `Descending`
* Do not use per-column header sort controls. * Do not use per-column header sort controls.
* Keep `Actions` column non-sortable. * Keep any `Actions` column non-sortable.
* If a page-specific rule conflicts with default table behavior, page-specific rule takes precedence. * If a page-specific rule conflicts with default table behavior, page-specific rule takes precedence.
## Resize Stability Requirements ## Resize Stability Requirements

View File

@ -115,8 +115,7 @@ if [[ "${REMOTE_DIR_RAW}" = /* ]]; then
else else
REMOTE_DIR="/home/${SSH_USER}/${REMOTE_DIR_RAW}" REMOTE_DIR="/home/${SSH_USER}/${REMOTE_DIR_RAW}"
fi fi
REMOTE_PARENT_DIR="$(dirname "${REMOTE_DIR}")" REMOTE_PLUGIN_VENDOR_DIR="${REMOTE_DIR}/vendor"
REMOTE_VENDOR_DIR="${REMOTE_PARENT_DIR}/vendor"
CURRENT_VERSION="$(awk ' CURRENT_VERSION="$(awk '
/Version:/ { /Version:/ {
@ -163,24 +162,16 @@ fi
echo "Remote host: ${SSH_TARGET}:${SSH_PORT}" echo "Remote host: ${SSH_TARGET}:${SSH_PORT}"
echo "Source dir: ${SOURCE_DIR}" echo "Source dir: ${SOURCE_DIR}"
echo "Deploy target: ${REMOTE_DIR}" echo "Deploy target: ${REMOTE_DIR}"
echo "Vendor target: ${REMOTE_VENDOR_DIR}" echo "Vendor target: ${REMOTE_PLUGIN_VENDOR_DIR}"
if [[ "${DRY_RUN}" -eq 1 ]]; then if [[ "${DRY_RUN}" -eq 1 ]]; then
if [[ ! -f "${LOCAL_VENDOR_DIR}/autoload.php" ]]; then if [[ ! -f "${LOCAL_VENDOR_DIR}/autoload.php" ]]; then
echo "Warning: ${LOCAL_VENDOR_DIR}/autoload.php not found. Run 'composer install --no-dev' before real deploy." echo "Warning: ${LOCAL_VENDOR_DIR}/autoload.php not found. Run scripts/install_dependencies.sh before real deploy."
fi fi
else else
if command -v composer >/dev/null 2>&1; then "${REPO_ROOT}/scripts/install_dependencies.sh" --skip-node
echo "Installing/updating PHP dependencies from composer.lock..."
(
cd "${REPO_ROOT}"
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
)
else
echo "composer not found on PATH; using existing ${LOCAL_VENDOR_DIR}."
fi
if [[ ! -f "${LOCAL_VENDOR_DIR}/autoload.php" ]]; then if [[ ! -f "${LOCAL_VENDOR_DIR}/autoload.php" ]]; then
echo "Error: ${LOCAL_VENDOR_DIR}/autoload.php is missing. Install dependencies first (composer install --no-dev)." >&2 echo "Error: ${LOCAL_VENDOR_DIR}/autoload.php is missing. Install dependencies first (scripts/install_dependencies.sh)." >&2
exit 1 exit 1
fi fi
fi fi
@ -190,7 +181,7 @@ ssh -p "${SSH_PORT}" \
-o BatchMode=yes \ -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
"${SSH_TARGET}" \ "${SSH_TARGET}" \
"mkdir -p '${REMOTE_DIR}' '${REMOTE_VENDOR_DIR}'" "mkdir -p '${REMOTE_DIR}' '${REMOTE_PLUGIN_VENDOR_DIR}'"
RSYNC_SSH="ssh -p ${SSH_PORT} -i ${SSH_KEY_PATH} -o BatchMode=yes -o StrictHostKeyChecking=accept-new" RSYNC_SSH="ssh -p ${SSH_PORT} -i ${SSH_KEY_PATH} -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
RSYNC_ARGS=( RSYNC_ARGS=(
@ -210,10 +201,9 @@ rsync "${RSYNC_ARGS[@]}" \
"${SOURCE_DIR}/" \ "${SOURCE_DIR}/" \
"${SSH_TARGET}:${REMOTE_DIR}/" "${SSH_TARGET}:${REMOTE_DIR}/"
# Keep root-level Composer dependencies in sync for plugin autoloading.
# Intentionally avoids --delete so we don't remove unrelated packages that may share this vendor dir.
VENDOR_RSYNC_ARGS=( VENDOR_RSYNC_ARGS=(
-avz -avz
--delete
) )
if [[ "${DRY_RUN}" -eq 1 ]]; then if [[ "${DRY_RUN}" -eq 1 ]]; then
VENDOR_RSYNC_ARGS+=(-n) VENDOR_RSYNC_ARGS+=(-n)
@ -222,7 +212,7 @@ fi
rsync "${VENDOR_RSYNC_ARGS[@]}" \ rsync "${VENDOR_RSYNC_ARGS[@]}" \
-e "${RSYNC_SSH}" \ -e "${RSYNC_SSH}" \
"${LOCAL_VENDOR_DIR}/" \ "${LOCAL_VENDOR_DIR}/" \
"${SSH_TARGET}:${REMOTE_VENDOR_DIR}/" "${SSH_TARGET}:${REMOTE_PLUGIN_VENDOR_DIR}/"
if [[ "${DRY_RUN}" -eq 1 ]]; then if [[ "${DRY_RUN}" -eq 1 ]]; then
echo "Dry-run complete. No remote files were changed." echo "Dry-run complete. No remote files were changed."

107
scripts/install_dependencies.sh Executable file
View File

@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
PLUGIN_DIR="${REPO_ROOT}/feca_mailshots_plugin"
ASSET_DIR="${PLUGIN_DIR}/assets/vendor"
INSTALL_PHP=1
INSTALL_EDITOR_ASSETS=1
INSTALL_NODE=1
usage() {
cat <<'USAGE'
Usage: scripts/install_dependencies.sh [--skip-php] [--skip-editor-assets] [--skip-node]
Installs third-party dependencies used by the plugin:
- PHP runtime deps (Twig, Dompdf) via Composer
- Bundled editor assets (Jodit + Ace) under feca_mailshots_plugin/assets/vendor
- Node test deps via npm ci (Playwright package only; browsers are not installed here)
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-php)
INSTALL_PHP=0
shift
;;
--skip-editor-assets)
INSTALL_EDITOR_ASSETS=0
shift
;;
--skip-node)
INSTALL_NODE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ ! -d "${PLUGIN_DIR}" ]]; then
echo "Error: plugin directory not found: ${PLUGIN_DIR}" >&2
exit 1
fi
if [[ "${INSTALL_PHP}" -eq 1 ]]; then
if command -v composer >/dev/null 2>&1; then
echo "Installing PHP dependencies via Composer..."
(
cd "${REPO_ROOT}"
composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader
)
elif [[ -f "${REPO_ROOT}/vendor/autoload.php" ]]; then
echo "Warning: composer not found; using existing ${REPO_ROOT}/vendor."
else
echo "Error: composer is required (or provide prebuilt vendor/autoload.php)." >&2
exit 1
fi
fi
if [[ "${INSTALL_EDITOR_ASSETS}" -eq 1 ]]; then
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required to download editor assets." >&2
exit 1
fi
echo "Installing bundled editor assets (Jodit + Ace)..."
mkdir -p "${ASSET_DIR}/jodit" "${ASSET_DIR}/ace"
download() {
local url="$1"
local dest="$2"
curl -fsSL "${url}" -o "${dest}"
}
download "https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.css" "${ASSET_DIR}/jodit/jodit.min.css"
download "https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.js" "${ASSET_DIR}/jodit/jodit.min.js"
download "https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/ace.js" "${ASSET_DIR}/ace/ace.js"
download "https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/mode-html.js" "${ASSET_DIR}/ace/mode-html.js"
download "https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/worker-html.js" "${ASSET_DIR}/ace/worker-html.js"
download "https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/ext-language_tools.js" "${ASSET_DIR}/ace/ext-language_tools.js"
download "https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/theme-textmate.js" "${ASSET_DIR}/ace/theme-textmate.js"
fi
if [[ "${INSTALL_NODE}" -eq 1 && -f "${REPO_ROOT}/package-lock.json" ]]; then
if ! command -v npm >/dev/null 2>&1; then
echo "Error: npm is required to install Node dependencies." >&2
exit 1
fi
echo "Installing Node dependencies via npm ci..."
(
cd "${REPO_ROOT}"
npm ci
)
fi
echo "Dependency installation complete."

View File

@ -3,6 +3,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
INSTALL_SCRIPT="${REPO_ROOT}/scripts/install_dependencies.sh"
PLUGIN_SLUG="${PLUGIN_SLUG:-feca_mailshots_plugin}" PLUGIN_SLUG="${PLUGIN_SLUG:-feca_mailshots_plugin}"
SOURCE_DIR="${SOURCE_DIR:-${REPO_ROOT}/${PLUGIN_SLUG}}" SOURCE_DIR="${SOURCE_DIR:-${REPO_ROOT}/${PLUGIN_SLUG}}"
@ -54,6 +55,11 @@ if [[ ! -f "${PLUGIN_MAIN_FILE}" ]]; then
exit 1 exit 1
fi fi
if [[ ! -x "${INSTALL_SCRIPT}" ]]; then
echo "Error: install script not found or not executable: ${INSTALL_SCRIPT}" >&2
exit 1
fi
if ! command -v zip >/dev/null 2>&1; then if ! command -v zip >/dev/null 2>&1; then
echo "Error: 'zip' command is required but not installed." >&2 echo "Error: 'zip' command is required but not installed." >&2
exit 1 exit 1
@ -82,6 +88,13 @@ trap 'rm -rf "${TMP_DIR}"' EXIT
STAGE_DIR="${TMP_DIR}/${PLUGIN_BASENAME}" STAGE_DIR="${TMP_DIR}/${PLUGIN_BASENAME}"
mkdir -p "${STAGE_DIR}" mkdir -p "${STAGE_DIR}"
"${INSTALL_SCRIPT}" --skip-node
if [[ ! -f "${REPO_ROOT}/vendor/autoload.php" ]]; then
echo "Error: vendor/autoload.php is missing after dependency install." >&2
exit 1
fi
rsync -a \ rsync -a \
--delete \ --delete \
--exclude '.git/' \ --exclude '.git/' \
@ -90,8 +103,12 @@ rsync -a \
--exclude '.idea/' \ --exclude '.idea/' \
--exclude '.vscode/' \ --exclude '.vscode/' \
--exclude 'node_modules/' \ --exclude 'node_modules/' \
--exclude 'vendor/' \
"${SOURCE_DIR}/" "${STAGE_DIR}/" "${SOURCE_DIR}/" "${STAGE_DIR}/"
mkdir -p "${STAGE_DIR}/vendor"
rsync -a --delete "${REPO_ROOT}/vendor/" "${STAGE_DIR}/vendor/"
ARCHIVE_PATH="${OUTPUT_DIR}/${PLUGIN_BASENAME}-${VERSION}.zip" ARCHIVE_PATH="${OUTPUT_DIR}/${PLUGIN_BASENAME}-${VERSION}.zip"
rm -f "${ARCHIVE_PATH}" rm -f "${ARCHIVE_PATH}"

View File

@ -1,17 +1,11 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import { adminPath, apiList, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
import { fileURLToPath } from 'node:url';
import { adminPath, apiList, apiPost, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const renewalsTemplate = fs.readFileSync(path.resolve(__dirname, '../../../formats/renewals_v4.html'), 'utf8');
const dsName = process.env.E2E_RENEWAL_DATASOURCE || 'renewal_accounts_with_contacts';
const mailshotPurpose = process.env.E2E_RENEWAL_MAILSHOT_PURPOSE || 'Renewals (pending accounts and contacts)'; const mailshotPurpose = process.env.E2E_RENEWAL_MAILSHOT_PURPOSE || 'Renewals (pending accounts and contacts)';
const allowCreate = process.env.E2E_RENEWAL_ALLOW_CREATE === '1'; const allowCreate = process.env.E2E_RENEWAL_ALLOW_CREATE !== '0';
const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1'; const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1';
const fallbackPdfTemplate = '<div>Download PDF test</div>';
test.describe('renewal dataset: download pdf e2e', () => { test.describe('renewal dataset: download pdf e2e', () => {
test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1'); test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1');
@ -21,27 +15,13 @@ test.describe('renewal dataset: download pdf e2e', () => {
await dialog.accept(); await dialog.accept();
}); });
const nextMatchingDownload = async () => {
const deadline = Date.now() + 240000;
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const download = await page.waitForEvent('download', { timeout: remaining }).catch(() => null);
if (!download) {
break;
}
if (download.suggestedFilename().includes(expectedFilenamePart)) {
return download;
}
}
return null;
};
const downloadPromise = nextMatchingDownload();
await page.getByRole('button', { name: buttonName }).click();
let downloaded; let downloaded;
try { try {
downloaded = await downloadPromise; const out = await Promise.all([
page.waitForEvent('download', { timeout: 90000 }),
page.getByRole('button', { name: buttonName }).click()
]);
downloaded = out[0] || null;
} catch { } catch {
const critical = page.getByText('There has been a critical error on this website.'); const critical = page.getByText('There has been a critical error on this website.');
if (await critical.count()) { if (await critical.count()) {
@ -55,7 +35,16 @@ test.describe('renewal dataset: download pdf e2e', () => {
throw new Error(`Download action "${buttonName}" timed out waiting for file download.`); throw new Error(`Download action "${buttonName}" timed out waiting for file download.`);
} }
if (!downloaded) { if (!downloaded) {
throw new Error(`Download action "${buttonName}" did not produce expected file "${expectedFilenamePart}".`); const critical = page.getByText('There has been a critical error on this website.');
if (await critical.count()) {
throw new Error(`Download action "${buttonName}" reached a WordPress critical error page.`);
}
const failBanner = page.locator('strong', { hasText: 'PDF generation failed.' });
if (await failBanner.count()) {
const msg = (await page.locator('.wrap').innerText()).replace(/\s+/g, ' ').trim();
throw new Error(`Download action "${buttonName}" failed in UI: ${msg}`);
}
throw new Error(`Download action "${buttonName}" timed out waiting for file download.`);
} }
expect(downloaded.suggestedFilename()).toContain(expectedFilenamePart); expect(downloaded.suggestedFilename()).toContain(expectedFilenamePart);
@ -70,71 +59,43 @@ test.describe('renewal dataset: download pdf e2e', () => {
const purpose = `e2e_renewal_download_${uniq}`; const purpose = `e2e_renewal_download_${uniq}`;
const dsCreatedName = `e2e_renewal_ds_${uniq}`; const dsCreatedName = `e2e_renewal_ds_${uniq}`;
let mailshotId = ''; if (!allowCreate) {
let effectiveDsName = dsName;
let createdMailshot = false;
const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api'); const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api');
expect(mailshotList.ok).toBeTruthy(); expect(mailshotList.ok).toBeTruthy();
const existing = (mailshotList.items || []).find( const existing = (mailshotList.items || []).find(
(row) => String(row.Purpose || '') === mailshotPurpose (row) => String(row.Purpose || '') === mailshotPurpose
); );
if (!existing) {
if (existing) {
mailshotId = String(existing.id || '');
effectiveDsName = String(existing.DataSource || '').trim();
if (effectiveDsName === '') {
throw new Error(`Mailshot "${mailshotPurpose}" has no DataSource.`);
}
} else {
const dsList = await apiList(request, 'feca_mailshots_data_sources_api');
expect(dsList.ok).toBeTruthy();
const dsExists = (dsList.items || []).some((row) => String(row.name || '') === dsName);
if (!dsExists) {
if (!allowCreate) {
throw new Error(`Required data source not found: ${dsName}`);
}
await ensureDataSource(request, dsCreatedName, dsName);
effectiveDsName = dsCreatedName;
}
if (!allowCreate) {
throw new Error(`Required mailshot not found: ${mailshotPurpose}`); throw new Error(`Required mailshot not found: ${mailshotPurpose}`);
} }
const existingId = String(existing.id || '');
expect(existingId).not.toBe('');
return { mailshotId: existingId, cleanup: async () => {} };
}
await ensureDataSource(request, dsCreatedName, 'contacts');
const saved = await ensureMailshot(request, { const saved = await ensureMailshot(request, {
Purpose: purpose, Purpose: purpose,
DataSource: effectiveDsName, DataSource: dsCreatedName,
CC: '', CC: '',
BCC: '', BCC: '',
Subject: 'Renewal for {{ account_name }}', Subject: 'Renewal PDF e2e',
Message: '', Message: '',
PDFAttachment: renewalsTemplate, PDFAttachment: fallbackPdfTemplate,
AttachmentNames: '[]', AttachmentNames: '[]',
PDFFilenameDerivedFrom: 'account_name', PDFFilenameDerivedFrom: '',
RecipientEmailField: '', RecipientEmailField: '',
ReplyTo: '' ReplyTo: ''
}); });
mailshotId = String(saved.id || ''); let mailshotId = String(saved.id || '');
createdMailshot = true;
}
expect(mailshotId).not.toBe(''); expect(mailshotId).not.toBe('');
const dsListFinal = await apiList(request, 'feca_mailshots_data_sources_api');
expect(dsListFinal.ok).toBeTruthy();
const effectiveDsExists = (dsListFinal.items || []).some((row) => String(row.name || '') === effectiveDsName);
expect(effectiveDsExists).toBeTruthy();
const render = await apiPost(request, 'feca_mailshots_test_api', 'render_test', {
mailshot_id: mailshotId,
recipient_index: '0'
});
expect(render.ok).toBeTruthy();
expect(String(render?.rendered?.pdf_attachment || '')).toContain('Membership Renewal');
return { return {
mailshotId, mailshotId,
cleanup: async () => { cleanup: async () => {
await cleanupByNames(request, { await cleanupByNames(request, {
dataSourceNames: [dsCreatedName], dataSourceNames: [dsCreatedName],
mailshotPurposes: createdMailshot ? [purpose] : [] mailshotPurposes: [purpose]
}); });
} }
}; };

View File

@ -3,6 +3,7 @@ import { adminPath, apiList } from './helpers.mjs';
const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1'; const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1';
const mailshotPurpose = process.env.E2E_RENEWAL_MAILSHOT_PURPOSE || 'Renewals (pending accounts and contacts)'; const mailshotPurpose = process.env.E2E_RENEWAL_MAILSHOT_PURPOSE || 'Renewals (pending accounts and contacts)';
const dsName = process.env.E2E_RENEWAL_DATASOURCE || 'renewal_accounts_with_contacts';
test.describe('download pdf ui feedback', () => { test.describe('download pdf ui feedback', () => {
test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1'); test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1');
@ -15,11 +16,16 @@ test.describe('download pdf ui feedback', () => {
const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api'); const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api');
expect(mailshotList.ok).toBeTruthy(); expect(mailshotList.ok).toBeTruthy();
const existing = (mailshotList.items || []).find( const items = Array.isArray(mailshotList.items) ? mailshotList.items : [];
const existingByPurpose = items.find(
(row) => String(row.Purpose || '') === mailshotPurpose (row) => String(row.Purpose || '') === mailshotPurpose
); );
expect(existing).toBeTruthy(); const existingByDataSource = items.find(
const mailshotId = String(existing.id || ''); (row) => String(row.DataSource || '') === dsName
);
const existing = existingByPurpose || existingByDataSource || items[0];
expect(existing, 'No mailshots available for Download PDF UI test').toBeTruthy();
const mailshotId = String((existing && existing.id) || '');
expect(mailshotId).not.toBe(''); expect(mailshotId).not.toBe('');
await page.goto(`${adminPath('feca-mailshots-download-pdf')}&mailshot_id=${encodeURIComponent(mailshotId)}`); await page.goto(`${adminPath('feca-mailshots-download-pdf')}&mailshot_id=${encodeURIComponent(mailshotId)}`);
@ -44,6 +50,6 @@ test.describe('download pdf ui feedback', () => {
await expect(page.locator('#feca-download-progress')).toBeVisible(); await expect(page.locator('#feca-download-progress')).toBeVisible();
await expect(page.locator('#feca-download-merged-btn')).toBeDisabled(); await expect(page.locator('#feca-download-merged-btn')).toBeDisabled();
await expect(page.locator('#feca-download-zip-btn')).toBeEnabled(); await expect(page.locator('#feca-download-zip-btn')).toBeDisabled();
}); });
}); });

View File

@ -3,8 +3,43 @@ import { expect } from '@playwright/test';
export const adminPath = (page) => `/wp-admin/admin.php?page=${page}`; export const adminPath = (page) => `/wp-admin/admin.php?page=${page}`;
export const postPath = (action, op = '') => `/wp-admin/admin-post.php?action=${encodeURIComponent(action)}${op ? `&op=${encodeURIComponent(op)}` : ''}`; export const postPath = (action, op = '') => `/wp-admin/admin-post.php?action=${encodeURIComponent(action)}${op ? `&op=${encodeURIComponent(op)}` : ''}`;
const actionNoncePage = {
feca_mailshots_data_sources_api: 'feca-mailshot-data-sources',
feca_mailshots_mailshots_api: 'feca-mailshots-mailshots',
feca_mailshots_test_api: 'feca-mailshots-test',
feca_mailshots_run_api: 'feca-mailshots-run'
};
const nonceCache = new Map();
async function fetchNonceForAction(request, action) {
const page = actionNoncePage[action];
if (!page) {
return '';
}
if (nonceCache.has(action)) {
return nonceCache.get(action) || '';
}
const res = await request.get(adminPath(page));
const html = await res.text();
const match = html.match(/name="_wpnonce"\s+value="([^"]+)"/i);
const nonce = match && match[1] ? String(match[1]) : '';
if (nonce) {
nonceCache.set(action, nonce);
}
return nonce;
}
export async function apiPost(request, action, op, form = {}) { export async function apiPost(request, action, op, form = {}) {
const res = await request.post(postPath(action, op), { form }); const nextForm = { ...form };
if (!nextForm._wpnonce) {
const nonce = await fetchNonceForAction(request, action);
if (nonce) {
nextForm._wpnonce = nonce;
}
}
const res = await request.post(postPath(action, op), { form: nextForm });
const status = res.status(); const status = res.status();
const raw = await res.text(); const raw = await res.text();
let decoded; let decoded;

View File

@ -52,6 +52,34 @@ const setJoditMode = async (page, modeName) => {
}, modeName); }, modeName);
}; };
const ensureMailshotModalsClosed = async (page) => {
const acceptDialog = async (dialog) => {
await dialog.accept();
};
page.on('dialog', acceptDialog);
try {
const templateModal = page.locator('#ms-template-modal');
if (await templateModal.isVisible().catch(() => false)) {
await page.locator('#ms-template-close').click();
await expect(templateModal).toBeHidden();
}
const editorModal = page.locator('#ms-editor-modal');
if (await editorModal.isVisible().catch(() => false)) {
await page.locator('#ms-close-editor').click();
await expect(editorModal).toBeHidden();
}
} finally {
page.off('dialog', acceptDialog);
}
};
const openNewMailshotEditor = async (page) => {
await ensureMailshotModalsClosed(page);
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
};
test('mailshots editor: datasource-driven options + overlay editors + create', async ({ page, request }) => { test('mailshots editor: datasource-driven options + overlay editors + create', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`; const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_ms_ds_${uniq}`; const dsName = `e2e_ms_ds_${uniq}`;
@ -63,8 +91,7 @@ test('mailshots editor: datasource-driven options + overlay editors + create', a
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill(purpose); await page.locator('#ms_purpose').fill(purpose);
await page.locator('#ms_ds').selectOption({ label: dsName }); await page.locator('#ms_ds').selectOption({ label: dsName });
@ -116,8 +143,7 @@ test('mailshots editor: message save closes modal and persists on reopen', async
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click(); await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible(); await expect(page.locator('#ms-template-modal')).toBeVisible();
@ -166,8 +192,7 @@ test('mailshots editor: save works in WYSIWYG mode with a single click', async (
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click(); await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible(); await expect(page.locator('#ms-template-modal')).toBeVisible();
@ -190,8 +215,7 @@ test('mailshots editor: allows save without recipient field and shows warning',
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill(purpose); await page.locator('#ms_purpose').fill(purpose);
await page.locator('#ms_ds').selectOption({ label: dsName }); await page.locator('#ms_ds').selectOption({ label: dsName });
await page.locator('#ms_subject').fill('No recipient email field'); await page.locator('#ms_subject').fill('No recipient email field');

View File

@ -35,6 +35,34 @@ const setJoditHtml = async (page, html) => {
}, html); }, html);
}; };
const ensureMailshotModalsClosed = async (page) => {
const acceptDialog = async (dialog) => {
await dialog.accept();
};
page.on('dialog', acceptDialog);
try {
const templateModal = page.locator('#ms-template-modal');
if (await templateModal.isVisible().catch(() => false)) {
await page.locator('#ms-template-close').click();
await expect(templateModal).toBeHidden();
}
const editorModal = page.locator('#ms-editor-modal');
if (await editorModal.isVisible().catch(() => false)) {
await page.locator('#ms-close-editor').click();
await expect(editorModal).toBeHidden();
}
} finally {
page.off('dialog', acceptDialog);
}
};
const openNewMailshotEditor = async (page) => {
await ensureMailshotModalsClosed(page);
await page.locator('#ms-open-new').click();
await expect(page.locator('#ms-editor-modal')).toBeVisible();
};
test('regression: mailshot message modal saves and closes on first click', async ({ page }) => { test('regression: mailshot message modal saves and closes on first click', async ({ page }) => {
const longBody = Array.from({ length: 120 }, (_, i) => `<p>Line ${i + 1}</p>`).join(''); const longBody = Array.from({ length: 120 }, (_, i) => `<p>Line ${i + 1}</p>`).join('');
const html = `<style>body{font-family:Cambria, serif;}</style><div class="regression-one-click">One click save marker</div>${longBody}<div id="tail-marker">Tail marker</div>`; const html = `<style>body{font-family:Cambria, serif;}</style><div class="regression-one-click">One click save marker</div>${longBody}<div id="tail-marker">Tail marker</div>`;
@ -42,8 +70,7 @@ test('regression: mailshot message modal saves and closes on first click', async
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click(); await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible(); await expect(page.locator('#ms-template-modal')).toBeVisible();
@ -60,8 +87,7 @@ test('regression: message editor single-save with invoicing template (scroll cas
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click(); await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible(); await expect(page.locator('#ms-template-modal')).toBeVisible();
@ -93,8 +119,7 @@ test('regression: message editor manual source edit saves on first click', async
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms-edit-message').click(); await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible(); await expect(page.locator('#ms-template-modal')).toBeVisible();
@ -125,8 +150,7 @@ test('regression: mailshot validation failure keeps entered values in modal', as
await page.goto(adminPath('feca-mailshots-mailshots')); await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible(); await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.locator('#ms-open-new').click(); await openNewMailshotEditor(page);
await expect(page.locator('#ms-editor-modal')).toBeVisible();
await page.locator('#ms_purpose').fill('validation draft mailshot'); await page.locator('#ms_purpose').fill('validation draft mailshot');
await page.locator('#ms_subject').fill(''); await page.locator('#ms_subject').fill('');
@ -174,7 +198,11 @@ test('regression: editing existing mailshot allows empty message for pdf-only wo
await page.locator('#ms_purpose').fill(editedPurpose); await page.locator('#ms_purpose').fill(editedPurpose);
await page.locator('#ms_subject').fill('Edited subject'); await page.locator('#ms_subject').fill('Edited subject');
await page.locator('#ms_message').fill(''); await page.locator('#ms-edit-message').click();
await expect(page.locator('#ms-template-modal')).toBeVisible();
await setJoditHtml(page, '');
await page.locator('#ms-template-save').click();
await expect(page.locator('#ms-template-modal')).toBeHidden();
await page.locator('#mailshot-editor button[type="submit"]').click(); await page.locator('#mailshot-editor button[type="submit"]').click();
await expect(page.locator('#ms-editor-modal')).toBeHidden(); await expect(page.locator('#ms-editor-modal')).toBeHidden();

View File

@ -0,0 +1,136 @@
import { test, expect } from '@playwright/test';
import { adminPath, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
test.describe('new selected-rows and all-recipients features', () => {
test('mailshot test: all recipients uses count-based confirm text', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_newfeat_ds_${uniq}`;
const purpose = `e2e_newfeat_ms_${uniq}`;
try {
await ensureDataSource(request, dsName, 'contacts');
const saved = await ensureMailshot(request, {
Purpose: purpose,
DataSource: dsName,
CC: '',
BCC: '',
Subject: 'Subj {{ contacts_id }}',
Message: '<p>Hi {{ contacts_id }}</p>',
PDFAttachment: '<p>PDF</p>',
AttachmentNames: '[]',
PDFFilenameDerivedFrom: '',
RecipientEmailField: 'contacts.contact_email_1',
ReplyTo: ''
});
await page.goto(`${adminPath('feca-mailshots-test')}&mailshot_id=${encodeURIComponent(String(saved.id || ''))}`);
await expect(page.getByRole('heading', { name: 'Mailshot Test' })).toBeVisible();
await expect(page.locator('#mst_recipient_index')).toBeVisible();
await page.locator('#mst_recipient_index').selectOption('-1');
const recipientCount = await page.locator('#mst_recipient_index').evaluate((el) => {
const raw = el.getAttribute('data-recipient-count') || '0';
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
});
let dialogText = '';
page.once('dialog', async (dialog) => {
dialogText = dialog.message();
await dialog.dismiss();
});
await page.getByRole('button', { name: 'Send Test Email' }).click();
await expect.poll(() => dialogText).toContain(`Send ${recipientCount} emails to the entered address?`);
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
} catch {
// best effort
}
}
});
test('review recipients: row selection persists in session and selected count updates', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_rrsel_ds_${uniq}`;
try {
await ensureDataSource(request, dsName, 'contacts');
await page.goto(`${adminPath('feca-mailshots-review-recipients')}&data_source=${encodeURIComponent(dsName)}`);
await expect(page.getByRole('heading', { name: 'Review Recipients' })).toBeVisible();
const firstRowCheckbox = page.locator('#rr_body input.rr-row-select').first();
await expect(firstRowCheckbox).toBeVisible({ timeout: 20000 });
await firstRowCheckbox.check();
await expect(page.locator('#rr_status')).toContainText('Selected: 1');
await page.goto(adminPath('feca-mailshots-mailshots'));
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
await page.goto(`${adminPath('feca-mailshots-review-recipients')}&data_source=${encodeURIComponent(dsName)}`);
await expect(page.locator('#rr_body input.rr-row-select').first()).toBeChecked({ timeout: 20000 });
await expect(page.locator('#rr_status')).toContainText('Selected: 1');
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [] });
} catch {
// best effort
}
}
});
test('run mailshot: selected-rows action shows guard and count-based confirm', async ({ page, request }) => {
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
const dsName = `e2e_runsel_ds_${uniq}`;
const purpose = `e2e_runsel_ms_${uniq}`;
try {
await ensureDataSource(request, dsName, 'contacts');
const saved = await ensureMailshot(request, {
Purpose: purpose,
DataSource: dsName,
CC: '',
BCC: '',
Subject: 'Subj {{ contacts_id }}',
Message: '<p>Hi {{ contacts_id }}</p>',
PDFAttachment: '<p>PDF</p>',
AttachmentNames: '[]',
PDFFilenameDerivedFrom: '',
RecipientEmailField: 'contacts.contact_email_1',
ReplyTo: ''
});
await page.goto(`${adminPath('feca-mailshots-run')}&mailshot_id=${encodeURIComponent(String(saved.id || ''))}`);
await expect(page.getByRole('heading', { name: 'Run Mailshot' })).toBeVisible();
await expect(page.locator('#run-mailshot-selected-btn')).toBeVisible();
await expect(page.locator('#feca-run-actions-form a', { hasText: 'Review Recipients' })).toBeVisible();
let guardDialog = '';
page.once('dialog', async (dialog) => {
guardDialog = dialog.message();
await dialog.accept();
});
await page.locator('#run-mailshot-selected-btn').click();
await expect.poll(() => guardDialog).toContain('No selected rows found');
await page.evaluate((sourceName) => {
const key = `fecaReviewRecipientsSelection::${sourceName}`;
sessionStorage.setItem(key, JSON.stringify({ 'row_index:0': true, 'row_index:1': true }));
}, dsName);
let confirmDialog = '';
page.once('dialog', async (dialog) => {
confirmDialog = dialog.message();
await dialog.dismiss();
});
await page.locator('#run-mailshot-selected-btn').click();
await expect.poll(() => confirmDialog).toContain('Run Mailshot to 2 selected rows?');
} finally {
try {
await cleanupByNames(request, { dataSourceNames: [dsName], mailshotPurposes: [purpose] });
} catch {
// best effort
}
}
});
});

View File

@ -39,7 +39,9 @@ test('run/test pages: load and failure-path API assertions', async ({ page, requ
const runInvalid = await apiPost(request, 'feca_mailshots_run_api', 'run_mailshot', { mailshot_id: '0' }); const runInvalid = await apiPost(request, 'feca_mailshots_run_api', 'run_mailshot', { mailshot_id: '0' });
expect(runInvalid.ok).toBeFalsy(); expect(runInvalid.ok).toBeFalsy();
expect(String((runInvalid.errors || []).join(' | ') || runInvalid.error || '')).toContain('Missing mail credentials'); const runInvalidMessage = String((runInvalid.errors || []).join(' | ') || runInvalid.error || '');
// Current behavior returns "Mailshot not found." for id=0 before credential checks.
expect(runInvalidMessage).toContain('Mailshot not found');
const sendBlank = await apiPost(request, 'feca_mailshots_test_api', 'send_test', { const sendBlank = await apiPost(request, 'feca_mailshots_test_api', 'send_test', {
mailshot_id: '1', mailshot_id: '1',

View File

@ -6,8 +6,17 @@ require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
use FecaMailshots\Application\TemplateRenderer; use FecaMailshots\Application\TemplateRenderer;
$pngBytes = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQAAAAA3bvkkAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAACYktHRAAB3YoTpAAAAAd0SU1FB+oEGAYzAgXOM1oAAAAKSURBVAjXY2gAAACCAIHdQ2r0AAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA0LTI0VDA2OjUxOjAyKzAwOjAwPufkWAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wNC0yNFQwNjo1MTowMiswMDowME+6XOQAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDQtMjRUMDY6NTE6MDIrMDA6MDAYr307AAAAAElFTkSuQmCC',
true
);
if (!is_string($pngBytes) || $pngBytes === '') {
fwrite(STDERR, "Failed to decode png fixture bytes\n");
exit(1);
}
$renderer = new TemplateRenderer( $renderer = new TemplateRenderer(
static function (string $name): ?array { static function (string $name) use ($pngBytes): ?array {
if (strtolower(trim($name)) !== 'logo_asset') { if (strtolower(trim($name)) !== 'logo_asset') {
return null; return null;
} }
@ -15,7 +24,7 @@ $renderer = new TemplateRenderer(
'name' => 'logo_asset', 'name' => 'logo_asset',
'file_name' => 'logo.png', 'file_name' => 'logo.png',
'mime_type' => 'image/png', 'mime_type' => 'image/png',
'file_bytes' => "PNG_BYTES", 'file_bytes' => $pngBytes,
'width_mm' => 20, 'width_mm' => 20,
'height_mm' => 10, 'height_mm' => 10,
'justification' => 'left', 'justification' => 'left',
@ -31,8 +40,8 @@ $rendered = $renderer->render(
); );
$pdfHtml = (string) ($rendered['pdf_attachment'] ?? ''); $pdfHtml = (string) ($rendered['pdf_attachment'] ?? '');
if (strpos($pdfHtml, 'data:image/png;base64,') === false) { if (strpos($pdfHtml, 'data:image/png;base64,') === false && strpos($pdfHtml, 'data:image/jpeg;base64,') === false) {
fwrite(STDERR, "pdf_asset did not render data-uri image HTML\n"); fwrite(STDERR, "pdf_asset did not render png/jpeg data-uri image HTML\n");
exit(1); exit(1);
} }
if (strpos($pdfHtml, 'text-align:left') === false) { if (strpos($pdfHtml, 'text-align:left') === false) {