v1 completed, tested.
This commit is contained in:
parent
63e2daaa9b
commit
06fddc641f
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
* Plugin Name: FECA Mailshots
|
||||
* Plugin URI: https://fenedge.co.uk/
|
||||
* Description: FECA mailshots plugin.
|
||||
* Version: 0.1.93
|
||||
* Version: 1.0.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: FECA
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ Contributors: feca
|
|||
Requires at least: 6.0
|
||||
Tested up to: 6.5
|
||||
Requires PHP: 7.4
|
||||
Stable tag: 0.0.0
|
||||
Stable tag: 1.0.0
|
||||
License: GPLv2 or later
|
||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
Barebones plugin scaffold.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,109 @@ namespace FecaMailshots\Admin;
|
|||
|
||||
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
|
||||
{
|
||||
$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'])) {
|
||||
return '';
|
||||
}
|
||||
$html = '<div style="margin:8px 0;padding:10px;border:1px solid #ef9a9a;background:#ffebee;">';
|
||||
$html .= '<strong>Please fix the following:</strong><ul style="margin:8px 0 0 18px;">';
|
||||
$html = '<div class="feca-banner feca-banner-error">';
|
||||
$html .= '<strong>Please fix the following:</strong><ul>';
|
||||
foreach ($result['errors'] as $error) {
|
||||
$html .= '<li>' . htmlspecialchars((string) $error) . '</li>';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,14 @@ final class AttachmentsAdminPage
|
|||
}
|
||||
$items = $this->service()->list();
|
||||
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
|
||||
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
|
||||
$editId = (int) $draft['id'];
|
||||
$requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
$draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
|
||||
$useDraft = is_array($draft) && (
|
||||
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
|
||||
);
|
||||
$editId = $requestedEditId;
|
||||
if ($editId <= 0 && $useDraft && $draftId > 0) {
|
||||
$editId = $draftId;
|
||||
}
|
||||
$editItem = null;
|
||||
foreach ($items as $row) {
|
||||
|
|
@ -64,32 +69,32 @@ final class AttachmentsAdminPage
|
|||
$name = (string) ($editItem['name'] ?? '');
|
||||
$fileName = (string) ($editItem['file_name'] ?? '');
|
||||
$base64 = '';
|
||||
if (is_array($draft)) {
|
||||
if ($useDraft && is_array($draft)) {
|
||||
$name = (string) ($draft['name'] ?? $name);
|
||||
$fileName = (string) ($draft['file_name'] ?? $fileName);
|
||||
$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) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
$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>';
|
||||
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 '<p><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 style="max-width:900px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="att-close-editor">Close</button></p>';
|
||||
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="att-editor-form">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
|
||||
echo '<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" class="feca-modal-overlay">';
|
||||
echo '<div class="feca-modal-shell feca-modal-shell-narrow">';
|
||||
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 . '" class="feca-form" id="att-editor-form">';
|
||||
echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Attachment' : 'New Attachment') . '</h2>';
|
||||
echo $this->modalErrorHtml($result);
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_attachments_ui_save">';
|
||||
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_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 '<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) {
|
||||
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 '<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>';
|
||||
foreach ($items as $row) {
|
||||
$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['byte_size'] ?? '')) . '</td>';
|
||||
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 $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
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 '</tr>';
|
||||
}
|
||||
|
|
@ -135,6 +141,7 @@ final class AttachmentsAdminPage
|
|||
echo '<tr><td colspan="6">No attachments found.</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '</div></div>';
|
||||
echo $this->modalEditorScript(
|
||||
'att-editor-modal',
|
||||
'att-open-new',
|
||||
|
|
@ -143,7 +150,7 @@ final class AttachmentsAdminPage
|
|||
'att-editor-id',
|
||||
['att_name', 'att_file_name', 'att_base64'],
|
||||
$editId > 0,
|
||||
is_array($draft),
|
||||
$useDraft,
|
||||
$result !== null,
|
||||
!empty($result['ok']),
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -77,9 +77,14 @@ final class DataSourcesAdminPage
|
|||
|
||||
$items = $this->filteredAndSortedItems($this->service()->list(), $filter, $sort);
|
||||
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
|
||||
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
|
||||
$editId = (int) $draft['id'];
|
||||
$requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
$draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
|
||||
$useDraft = is_array($draft) && (
|
||||
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
|
||||
);
|
||||
$editId = $requestedEditId;
|
||||
if ($editId <= 0 && $useDraft && $draftId > 0) {
|
||||
$editId = $draftId;
|
||||
}
|
||||
$editItem = null;
|
||||
foreach ($items as $row) {
|
||||
|
|
@ -90,7 +95,7 @@ final class DataSourcesAdminPage
|
|||
}
|
||||
$name = is_array($editItem) ? (string) ($editItem['name'] ?? '') : '';
|
||||
$dsl = is_array($editItem) ? (string) ($editItem['dsl_text'] ?? '') : '';
|
||||
if (is_array($draft)) {
|
||||
if ($useDraft && is_array($draft)) {
|
||||
$name = (string) ($draft['name'] ?? $name);
|
||||
$dsl = (string) ($draft['dsl_text'] ?? $dsl);
|
||||
}
|
||||
|
|
@ -98,43 +103,40 @@ final class DataSourcesAdminPage
|
|||
$sourceFieldsMap = $this->service()->sourceFields();
|
||||
$schemaList = $this->service()->listSchemas();
|
||||
|
||||
echo '<div class="wrap">';
|
||||
echo '<div class="wrap feca-mailshots-admin">';
|
||||
echo $this->renderAdminUiStyles();
|
||||
echo '<h1>Mailshot Data Sources</h1>';
|
||||
|
||||
if ($result !== null) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
$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>';
|
||||
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'])) {
|
||||
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'])) {
|
||||
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 '<script>(function(){var n=document.getElementById("ds-result-banner");if(!n){return;}window.setTimeout(function(){if(n&&n.parentNode){n.parentNode.removeChild(n);}},30000);})();</script>';
|
||||
}
|
||||
|
||||
echo '<div style="display:flex;gap:12px;margin:12px 0;">';
|
||||
echo '<div style="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 '<div class="feca-panel"><strong>Total data sources:</strong> ' . count($items) . '</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 '<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:360px;">';
|
||||
echo '<label for="ds_filter_q" style="white-space:nowrap;padding-left:4px;"><strong>Filter</strong></label>';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
echo '<label for="ds_filter_q"><strong>Filter</strong></label>';
|
||||
echo '<input id="ds_filter_q" class="regular-text" type="text" name="q" value="' . htmlspecialchars($filter, ENT_QUOTES) . '" placeholder="Name or sentence">';
|
||||
echo '</div>';
|
||||
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:260px;">';
|
||||
echo '<label for="ds_filter_sort" style="white-space:nowrap;padding-left:4px;"><strong>Sort</strong></label>';
|
||||
echo '<div class="feca-control feca-control-min-260">';
|
||||
echo '<label for="ds_filter_sort"><strong>Sort</strong></label>';
|
||||
echo '<select id="ds_filter_sort" name="sort">';
|
||||
foreach (['name' => 'Name', 'updated_desc' => 'Updated (newest)', 'updated_asc' => 'Updated (oldest)'] as $value => $label) {
|
||||
$selected = $sort === $value ? ' selected' : '';
|
||||
|
|
@ -142,15 +144,15 @@ final class DataSourcesAdminPage
|
|||
}
|
||||
echo '</select>';
|
||||
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 '</form>';
|
||||
|
||||
echo '<p><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
|
||||
echo '<div id="ds-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
|
||||
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="ds-editor-form">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
|
||||
echo '<p class="feca-button-row"><button type="button" class="button button-primary" id="ds-open-new">New Data Source</button></p>';
|
||||
echo '<div id="ds-editor-modal" class="feca-modal-overlay">';
|
||||
echo '<div class="feca-modal-shell">';
|
||||
echo '<form method="post" action="' . $action . '" class="feca-form" id="ds-editor-form">';
|
||||
echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Data Source' : 'New Data Source') . '</h2>';
|
||||
echo $this->modalErrorHtml($result);
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
if ($editId > 0) {
|
||||
|
|
@ -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_dsl">DSL Sentence</label></th><td><textarea id="ds_dsl" name="dsl_text" rows="7" class="large-text code" data-initial="' . htmlspecialchars($dsl, ENT_QUOTES) . '">' . htmlspecialchars($dsl) . '</textarea></td></tr>';
|
||||
echo '</table>';
|
||||
echo '<p>';
|
||||
echo '<p class="feca-button-row">';
|
||||
echo '<button type="submit" class="button button-primary" name="action" value="feca_mailshots_data_sources_ui_save">Save</button> ';
|
||||
echo '<button type="button" class="button" id="ds-close-editor">Quit</button> ';
|
||||
echo '<button type="button" class="button" id="ds-validate-btn">Validate Sentence</button> ';
|
||||
echo '<button type="button" class="button" id="ds-preview-btn">Preview Recipients</button> ';
|
||||
echo '<button type="button" class="button" id="ds-build-dsl-open">Build DSL</button> ';
|
||||
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 '<div id="ds-inline-result" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#f9f9f9;"></div>';
|
||||
echo '<div id="ds-inline-preview" style="display:none;margin:10px 0 0 0;padding:10px;border:1px solid #dcdcde;background:#fff;"></div>';
|
||||
echo '<div id="ds-inline-result" class="feca-banner feca-banner-muted feca-hidden"></div>';
|
||||
echo '<div id="ds-inline-preview" class="feca-banner feca-hidden"></div>';
|
||||
echo '</form>';
|
||||
echo '</div></div>';
|
||||
|
||||
echo '<div id="ds-builder-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9999;">';
|
||||
echo '<div style="max-width:980px;margin:30px auto;background:#fff;padding:14px;border-radius:4px;max-height:88vh;overflow:auto;">';
|
||||
echo '<h2 style="margin-top:0;">Build DSL</h2>';
|
||||
echo '<p style="margin-top:0;">Construct a DSL sentence from sources and constraints.</p>';
|
||||
echo '<div style="display:flex;gap:14px;flex-wrap:wrap;">';
|
||||
echo '<div style="flex:1;min-width:260px;border:1px solid #dcdcde;padding:10px;">';
|
||||
echo '<h3 style="margin-top:0;">Data Sources</h3>';
|
||||
echo '<div id="ds-builder-modal" class="feca-modal-overlay-high">';
|
||||
echo '<div class="feca-builder-shell">';
|
||||
echo '<h2 class="feca-modal-title">Build DSL</h2>';
|
||||
echo '<p class="feca-banner-note">Construct a DSL sentence from sources and constraints.</p>';
|
||||
echo '<div class="feca-builder-grid">';
|
||||
echo '<div class="feca-builder-col-left">';
|
||||
echo '<h3 class="feca-modal-title">Data Sources</h3>';
|
||||
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="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 '<strong>Add custom source</strong><br>';
|
||||
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 '<ul id="ds-builder-custom-list"></ul>';
|
||||
echo '</div>';
|
||||
echo '<div style="flex:2;min-width:340px;border:1px solid #dcdcde;padding:10px;">';
|
||||
echo '<h3 style="margin-top:0;">Constraints / Filters</h3>';
|
||||
echo '<div class="feca-builder-col-right">';
|
||||
echo '<h3 class="feca-modal-title">Constraints / Filters</h3>';
|
||||
echo '<div id="ds-builder-rows"></div>';
|
||||
echo '<p><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 style="margin-top:12px;border:1px solid #dcdcde;padding:10px;background:#f9f9f9;">';
|
||||
echo '<div class="feca-builder-output">';
|
||||
echo '<strong>Generated DSL</strong>';
|
||||
echo '<textarea id="ds-builder-output" rows="4" class="large-text code" readonly></textarea>';
|
||||
echo '<p id="ds-builder-error" style="color:#a00;margin:6px 0 0 0;"></p>';
|
||||
echo '<p id="ds-builder-table-error" style="color:#a00;margin:6px 0 0 0;"></p>';
|
||||
echo '<p id="ds-builder-error" class="feca-error-text"></p>';
|
||||
echo '<p id="ds-builder-table-error" class="feca-error-text"></p>';
|
||||
echo '</div>';
|
||||
echo '<p style="margin-top:12px;">';
|
||||
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Apply</button> ';
|
||||
echo '<button type="button" class="button" id="ds-builder-reset">Reset</button> ';
|
||||
echo '<button type="button" class="button" id="ds-builder-cancel">Cancel</button>';
|
||||
echo '<p class="feca-button-row">';
|
||||
echo '<button type="button" class="button button-primary" id="ds-builder-apply">Save</button> ';
|
||||
echo '<button type="button" class="button" id="ds-builder-cancel">Quit</button>';
|
||||
echo '</p></div></div>';
|
||||
|
||||
if (is_array($result) && isset($result['rows']) && is_array($result['rows'])) {
|
||||
$rows = $result['rows'];
|
||||
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<h2 style="margin-top:0;">Preview (first ' . count($rows) . ' rows)</h2>';
|
||||
echo '<div class="feca-panel">';
|
||||
echo '<h2 class="feca-modal-title">Preview (first ' . count($rows) . ' rows)</h2>';
|
||||
if ($rows === []) {
|
||||
echo '<p>No rows returned.</p>';
|
||||
} else {
|
||||
|
|
@ -253,6 +251,7 @@ final class DataSourcesAdminPage
|
|||
}
|
||||
|
||||
echo '<h2>Existing Data Sources</h2>';
|
||||
echo '<div class="feca-scroll-frame"><div class="feca-scroll-pane">';
|
||||
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>Name</th><th>DSL Sentence</th><th>Updated At</th><th>Actions</th></tr></thead><tbody>';
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['ID'] ?? 0);
|
||||
|
|
@ -260,14 +259,14 @@ final class DataSourcesAdminPage
|
|||
echo '<tr>';
|
||||
echo '<td>' . $id . '</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><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 $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
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 '</tr>';
|
||||
}
|
||||
|
|
@ -275,6 +274,7 @@ final class DataSourcesAdminPage
|
|||
echo '<tr><td colspan="5">No data sources found.</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '</div></div>';
|
||||
echo '<script>';
|
||||
echo 'window.fecaDataSourcesBuilderConfig = ' . json_encode([
|
||||
'sourceFields' => $sourceFieldsMap,
|
||||
|
|
@ -288,7 +288,6 @@ final class DataSourcesAdminPage
|
|||
echo 'var openBtn=document.getElementById("ds-build-dsl-open");';
|
||||
echo 'var cancelBtn=document.getElementById("ds-builder-cancel");';
|
||||
echo 'var applyBtn=document.getElementById("ds-builder-apply");';
|
||||
echo 'var resetBtn=document.getElementById("ds-builder-reset");';
|
||||
echo 'var addRowBtn=document.getElementById("ds-builder-add-row");';
|
||||
echo 'var rowsWrap=document.getElementById("ds-builder-rows");';
|
||||
echo 'var out=document.getElementById("ds-builder-output");';
|
||||
|
|
@ -304,6 +303,7 @@ final class DataSourcesAdminPage
|
|||
echo 'var closeEditor=document.getElementById("ds-close-editor");';
|
||||
echo 'var editorId=document.getElementById("ds-editor-id");';
|
||||
echo 'var editorName=document.getElementById("ds_name");';
|
||||
echo 'var nonceField=editorForm?editorForm.querySelector("input[name=\"_wpnonce\"]"):null;';
|
||||
echo 'var builtChecks=[].slice.call(document.querySelectorAll(".ds-source-built"));';
|
||||
echo 'var customList=document.getElementById("ds-builder-custom-list");';
|
||||
echo 'var schemaSel=document.getElementById("ds-builder-schema");';
|
||||
|
|
@ -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 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 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 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();}';
|
||||
|
|
@ -336,16 +336,15 @@ final class DataSourcesAdminPage
|
|||
echo 'if(addRowBtn){addRowBtn.onclick=addConstraint;}';
|
||||
echo 'if(openBtn){openBtn.onclick=function(){resetBuilder();prefillFromDsl();modal.style.display="block";};}';
|
||||
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(openNew){openNew.onclick=function(){if(!confirmDiscard()){return;}if(editorId){editorId.value="";}if(editorName){editorName.value="";}if(dslInput){dslInput.value="";}isDirty=false;editorModal.style.display="block";};}';
|
||||
echo 'if(closeEditor){closeEditor.onclick=function(){if(!confirmDiscard()){return;}editorModal.style.display="none";};}';
|
||||
echo 'function esc(v){return String(v||"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");}';
|
||||
echo 'function showInlineResult(ok,title,lines){if(!inlineResult){return;}inlineResult.style.display="block";inlineResult.style.borderColor=ok?"#8bc34a":"#ef9a9a";inlineResult.style.background=ok?"#f1f8e9":"#ffebee";var h="<strong>"+esc(title)+"</strong>";if(lines&&lines.length){h+="<ul style=\"margin:8px 0 0 18px;\">"+lines.map(function(x){return "<li>"+esc(x)+"</li>";}).join("")+"</ul>";}inlineResult.innerHTML=h;}';
|
||||
echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}';
|
||||
echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}';
|
||||
echo 'if(validateBtn){validateBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);if(nonceField&&nonceField.value){payload.set("_wpnonce",String(nonceField.value));}fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=validate",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Validation failed",[j&&j.error?j.error:"Unknown validation error"]);return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length===0){showInlineResult(true,"Validation passed",warnings.length?warnings:["No errors"]);}else{showInlineResult(false,"Validation failed",errors.concat(warnings));}}).catch(function(e){showInlineResult(false,"Validation failed",[e&&e.message?e.message:"Request error"]);});};}';
|
||||
echo 'if(previewBtn){previewBtn.onclick=function(){var dsl=(dslInput&&dslInput.value?dslInput.value:"");var payload=new URLSearchParams();payload.set("dsl_text",dsl);payload.set("limit","50");if(nonceField&&nonceField.value){payload.set("_wpnonce",String(nonceField.value));}fetch((cfg.adminPostApi||"/wp-admin/admin-post.php?action=feca_mailshots_data_sources_api")+"&op=preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/x-www-form-urlencoded; charset=UTF-8"},body:payload.toString()}).then(function(r){return r.json();}).then(function(j){if(!j||j.ok===false){showInlineResult(false,"Preview failed",[j&&j.error?j.error:"Unknown preview error"]);if(inlinePreview){inlinePreview.style.display="none";}return;}var errors=(j.errors||[]);var warnings=(j.warnings||[]);if(errors.length){showInlineResult(false,"Preview failed",errors.concat(warnings));if(inlinePreview){inlinePreview.style.display="none";}return;}showInlineResult(true,"Preview ready",["Count: "+(j.count||0)].concat(warnings));if(!inlinePreview){return;}var rows=(j.rows||[]);if(!rows.length){inlinePreview.style.display="block";inlinePreview.innerHTML="<p style=\"margin:0;\">No rows returned.</p>";return;}var cols=[];var seen={};rows.forEach(function(r){Object.keys(r||{}).forEach(function(k){if(!seen[k]){seen[k]=1;cols.push(k);}});});var html="<table class=\"widefat striped\"><thead><tr>"+cols.map(function(c){return "<th>"+esc(c)+"</th>";}).join("")+"</tr></thead><tbody>";rows.forEach(function(r){html+="<tr>"+cols.map(function(c){return "<td>"+esc((r&&r[c]!==undefined)?r[c]:"")+"</td>";}).join("")+"</tr>";});html+="</tbody></table>";inlinePreview.style.display="block";inlinePreview.innerHTML=html;}).catch(function(e){showInlineResult(false,"Preview failed",[e&&e.message?e.message:"Request error"]);if(inlinePreview){inlinePreview.style.display="none";}});};}';
|
||||
echo 'var hasEdit=' . ($editId > 0 ? 'true' : 'false') . ';';
|
||||
echo 'var hasDraft=' . (is_array($draft) ? 'true' : 'false') . ';';
|
||||
echo 'var hasDraft=' . ($useDraft ? 'true' : 'false') . ';';
|
||||
echo 'var hasResult=' . ($result !== null ? 'true' : 'false') . ';';
|
||||
echo 'var resultOk=' . (!empty($result['ok']) ? 'true' : 'false') . ';';
|
||||
echo 'if((hasEdit||hasDraft)&&editorModal&&(!hasResult||!resultOk)){editorModal.style.display="block";isDirty=false;}';
|
||||
|
|
@ -399,8 +398,7 @@ final class DataSourcesAdminPage
|
|||
|
||||
public function handleUiPreview(): void
|
||||
{
|
||||
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
|
||||
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
|
||||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
return;
|
||||
}
|
||||
$dsl = (string) ($this->wp->requestParam('dsl_text', '') ?? '');
|
||||
|
|
@ -422,8 +420,7 @@ final class DataSourcesAdminPage
|
|||
|
||||
public function handleUiDelete(): void
|
||||
{
|
||||
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
|
||||
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
|
||||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
return;
|
||||
}
|
||||
$id = (int) ($this->wp->requestParam('id', '0') ?? '0');
|
||||
|
|
@ -444,8 +441,7 @@ final class DataSourcesAdminPage
|
|||
|
||||
public function handleApi(): void
|
||||
{
|
||||
if (!$this->wp->currentUserCan(self::CAPABILITY)) {
|
||||
$this->wp->sendJson(['ok' => false, 'error' => 'Permission denied'], 403);
|
||||
if (!$this->enforceCapabilityOrJson(self::CAPABILITY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,36 +59,32 @@ final class DownloadPdfAdminPage
|
|||
}
|
||||
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
|
||||
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
|
||||
$buildStamp = gmdate('Y-m-d H:i:s', (int) @filemtime(__FILE__)) . ' UTC';
|
||||
$renderedAt = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
|
||||
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 '<div class="wrap feca-mailshots-admin"><h1>Download PDF</h1>';
|
||||
echo $this->renderAdminUiStyles();
|
||||
echo '<p>Run the selected mailshot without sending any email, and download generated PDF attachments.</p>';
|
||||
|
||||
if ($result !== null) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
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>' . ($ok ? 'PDF generation succeeded.' : 'PDF generation failed.') . '</strong>';
|
||||
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 {
|
||||
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)
|
||||
. ', Skipped: ' . (int) ($result['skipped_count'] ?? 0) . '</p>';
|
||||
}
|
||||
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 $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
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 style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
|
||||
echo '<label for="dp_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
echo '<label for="dp_mailshot_id"><strong>Mailshot</strong></label>';
|
||||
echo '<select id="dp_mailshot_id" name="mailshot_id">';
|
||||
foreach ($mailshots as $mailshot) {
|
||||
$id = (int) ($mailshot['id'] ?? 0);
|
||||
|
|
@ -100,11 +96,11 @@ final class DownloadPdfAdminPage
|
|||
echo '<option value="' . $id . '"' . $selected . '>' . htmlspecialchars($purpose) . '</option>';
|
||||
}
|
||||
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-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 '<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 '</div>';
|
||||
echo '</form>';
|
||||
|
|
@ -116,12 +112,18 @@ final class DownloadPdfAdminPage
|
|||
echo 'var zip=document.getElementById("feca-download-zip-btn");';
|
||||
echo 'var progress=document.getElementById("feca-download-progress");';
|
||||
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 'var submitter=ev&&ev.submitter?ev.submitter:null;';
|
||||
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 'window.setTimeout(clearUi,45000);';
|
||||
echo '});';
|
||||
echo 'window.addEventListener("focus",clearUi);';
|
||||
echo 'window.addEventListener("pageshow",clearUi);';
|
||||
echo 'document.addEventListener("visibilitychange",function(){if(document.visibilityState==="visible"){clearUi();}});';
|
||||
echo '})();';
|
||||
echo '</script>';
|
||||
echo '</div>';
|
||||
|
|
@ -139,54 +141,12 @@ final class DownloadPdfAdminPage
|
|||
$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());
|
||||
|
||||
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') {
|
||||
try {
|
||||
$this->downloadDebugLog('handleDownloadUi.zip.before_generate', ['mailshot_id' => $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) {
|
||||
$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->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
|
||||
return;
|
||||
|
|
@ -205,14 +165,9 @@ final class DownloadPdfAdminPage
|
|||
}
|
||||
|
||||
try {
|
||||
$this->downloadDebugLog('handleDownloadUi.zip.before_send', ['mailshot_id' => $mailshotId, 'zip_path' => $zipPath]);
|
||||
$this->sendFileDownload('mailshot_' . $mailshotId . '_pdfs.zip', 'application/zip', $zipPath);
|
||||
} catch (\Throwable $e) {
|
||||
@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->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
|
||||
}
|
||||
|
|
@ -220,21 +175,8 @@ final class DownloadPdfAdminPage
|
|||
}
|
||||
|
||||
try {
|
||||
$this->downloadDebugLog('handleDownloadUi.merged.before_generate', ['mailshot_id' => $mailshotId]);
|
||||
$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) {
|
||||
$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->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
|
||||
return;
|
||||
|
|
@ -252,13 +194,8 @@ final class DownloadPdfAdminPage
|
|||
return;
|
||||
}
|
||||
try {
|
||||
$this->downloadDebugLog('handleDownloadUi.merged.before_send', ['mailshot_id' => $mailshotId, 'bytes_len' => strlen($bytes)]);
|
||||
$this->sendBinaryDownload('mailshot_' . $mailshotId . '_merged.pdf', 'application/pdf', $bytes);
|
||||
} 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->redirectTo($this->wp->adminUrl('admin.php?page=' . self::PAGE_SLUG . '&mailshot_id=' . $mailshotId));
|
||||
return;
|
||||
|
|
@ -320,33 +257,6 @@ final class DownloadPdfAdminPage
|
|||
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
|
||||
{
|
||||
if (!function_exists('ini_get') || !function_exists('ini_set')) {
|
||||
|
|
@ -367,11 +277,9 @@ final class DownloadPdfAdminPage
|
|||
$old = $current;
|
||||
@ini_set('memory_limit', $target);
|
||||
$new = (string) ini_get('memory_limit');
|
||||
$this->downloadDebugLog('handleDownloadUi.memory_limit_adjust', [
|
||||
'old' => $old,
|
||||
'new' => $new,
|
||||
'target' => $target,
|
||||
]);
|
||||
if ($new === $old) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveDownloadMemoryLimitTarget(): string
|
||||
|
|
|
|||
|
|
@ -62,47 +62,49 @@ final class MailshotTestAdminPage
|
|||
$rows = is_array($preview['rows'] ?? null) ? $preview['rows'] : [];
|
||||
|
||||
$selectedRecipientIndex = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
if ($selectedRecipientIndex < -1) {
|
||||
$selectedRecipientIndex = 0;
|
||||
}
|
||||
$defaultEmail = $this->runService()->defaultTestEmail()['default_test_email'] ?? '';
|
||||
$testEmail = (string) ($this->wp->requestParam('test_email', (string) $defaultEmail) ?? $defaultEmail);
|
||||
$result = $this->result();
|
||||
|
||||
$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>';
|
||||
|
||||
if ($result !== null) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
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>' . ($ok ? 'Test action succeeded.' : 'Test action failed.') . '</strong>';
|
||||
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'])) {
|
||||
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'])) {
|
||||
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'])) {
|
||||
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)) {
|
||||
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 style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;">';
|
||||
echo '<div class="feca-panel">';
|
||||
echo '<h2 class="feca-section-title">1. Select Mailshot</h2>';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '">';
|
||||
echo '<input type="hidden" name="page" value="feca-mailshots-test">';
|
||||
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:360px;">';
|
||||
echo '<label for="mst_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
echo '<label for="mst_mailshot_id"><strong>Mailshot</strong></label>';
|
||||
echo '<select id="mst_mailshot_id" name="mailshot_id">';
|
||||
foreach ($mailshots as $m) {
|
||||
$id = (int) ($m['id'] ?? 0);
|
||||
|
|
@ -113,23 +115,27 @@ final class MailshotTestAdminPage
|
|||
}
|
||||
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> </label><button class="button" type="submit">Load recipients</button></div>';
|
||||
echo '</div>';
|
||||
echo '</form>';
|
||||
echo '</div>';
|
||||
|
||||
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 '<h2 style="margin-top:0;">2. Render / Send Test</h2>';
|
||||
echo '<form method="post" action="' . $action . '" class="feca-form">';
|
||||
echo '<h2 class="feca-section-title">2. Render / Send Test</h2>';
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
echo '<input type="hidden" name="mailshot_id" value="' . $selectedMailshotId . '">';
|
||||
|
||||
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:380px;">';
|
||||
echo '<label for="mst_recipient_index" style="white-space:nowrap;padding-left:4px;"><strong>Recipient row</strong></label>';
|
||||
echo '<select id="mst_recipient_index" name="recipient_index">';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
echo '<label for="mst_recipient_index"><strong>Recipient row</strong></label>';
|
||||
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) {
|
||||
$idx = (int) ($r['index'] ?? 0);
|
||||
$sel = $idx === $selectedRecipientIndex ? ' selected' : '';
|
||||
|
|
@ -142,23 +148,34 @@ final class MailshotTestAdminPage
|
|||
echo '<option value="' . $idx . '"' . $sel . '>' . htmlspecialchars($text) . '</option>';
|
||||
}
|
||||
echo '</select></div>';
|
||||
echo '</div>';
|
||||
|
||||
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 '<div class="feca-control feca-control-min-420">';
|
||||
echo '<label for="mst_test_email"><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 '</div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button" type="submit" name="action" value="feca_mailshots_test_render_ui">Render Test (No Send)</button></div>';
|
||||
echo '<div class="feca-control"><label> </label><button class="button button-primary" type="submit" name="action" value="feca_mailshots_test_send_ui" onclick="return window.fecaConfirmTestSend ? window.fecaConfirmTestSend() : confirm(\'Send one test email to the entered address?\');">Send Test Email</button></div>';
|
||||
echo '</div>';
|
||||
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 '<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 !== []) {
|
||||
$sampleColumns = $this->sampleColumns($rows, 4);
|
||||
echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;">';
|
||||
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 '<div class="feca-panel">';
|
||||
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) {
|
||||
echo '<th>' . htmlspecialchars($col) . '</th>';
|
||||
}
|
||||
|
|
@ -172,6 +189,7 @@ final class MailshotTestAdminPage
|
|||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '</div></div>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -189,16 +207,16 @@ final class MailshotTestAdminPage
|
|||
$messageHtml = (string) ($rendered['message'] ?? '');
|
||||
$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 style="max-width:1100px;margin:28px auto;background:#fff;padding:14px;max-height:90vh;overflow:auto;">';
|
||||
echo '<h2 style="margin-top:0;">Render Preview</h2>';
|
||||
echo '<p style="margin:0 0 8px 0;"><strong>Recipient:</strong> ' . htmlspecialchars($recipientText) . '</p>';
|
||||
echo '<p style="margin:0 0 10px 0;"><strong>Subject:</strong> ' . htmlspecialchars($subject) . '</p>';
|
||||
echo '<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">';
|
||||
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 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 id="ms-render-preview-modal" class="feca-modal-overlay-high">';
|
||||
echo '<div class="feca-modal-shell feca-modal-shell-wide">';
|
||||
echo '<h2 class="feca-modal-title">Render Preview</h2>';
|
||||
echo '<p class="feca-modal-subtitle"><strong>Recipient:</strong> ' . htmlspecialchars($recipientText) . '</p>';
|
||||
echo '<p class="feca-modal-subtitle"><strong>Subject:</strong> ' . htmlspecialchars($subject) . '</p>';
|
||||
echo '<div class="feca-preview-grid">';
|
||||
echo '<div><h3 class="feca-modal-subtitle">Message (HTML)</h3><iframe sandbox="" class="feca-preview-iframe" srcdoc="' . htmlspecialchars($messageHtml, ENT_QUOTES) . '"></iframe></div>';
|
||||
echo '<div><h3 class="feca-modal-subtitle">PDF Attachment (HTML)</h3><iframe sandbox="" class="feca-preview-iframe" srcdoc="' . htmlspecialchars($pdfHtml, ENT_QUOTES) . '"></iframe></div>';
|
||||
echo '</div>';
|
||||
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 '<script>(function(){';
|
||||
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');
|
||||
$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));
|
||||
return;
|
||||
}
|
||||
|
|
@ -250,6 +272,10 @@ final class MailshotTestAdminPage
|
|||
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
$to = (string) ($this->wp->requestParam('test_email', '') ?? '');
|
||||
if ($idx < 0) {
|
||||
$this->wp->sendJson($service->sendTestAll($mailshotId, $to));
|
||||
return;
|
||||
}
|
||||
$this->wp->sendJson($service->sendTest($mailshotId, $idx, $to));
|
||||
return;
|
||||
}
|
||||
|
|
@ -267,6 +293,13 @@ final class MailshotTestAdminPage
|
|||
}
|
||||
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
if ($idx < 0) {
|
||||
$result = ['ok' => false, 'errors' => ['Choose a specific recipient row for Render Test.']];
|
||||
$result['ui_action'] = 'render';
|
||||
$this->wp->updateOption(self::RESULT_OPTION_KEY, $result);
|
||||
$this->redirect($mailshotId, $idx, (string) ($this->wp->requestParam('test_email', '') ?? ''));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$result = $this->runService()->renderTest($mailshotId, $idx);
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -286,7 +319,11 @@ final class MailshotTestAdminPage
|
|||
$idx = (int) ($this->wp->requestParam('recipient_index', '0') ?? '0');
|
||||
$email = (string) ($this->wp->requestParam('test_email', '') ?? '');
|
||||
try {
|
||||
if ($idx < 0) {
|
||||
$result = $this->runService()->sendTestAll($mailshotId, $email);
|
||||
} else {
|
||||
$result = $this->runService()->sendTest($mailshotId, $idx, $email);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$result = ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
|
||||
}
|
||||
|
|
@ -337,16 +374,30 @@ final class MailshotTestAdminPage
|
|||
if ($firstRow === []) {
|
||||
return [];
|
||||
}
|
||||
$firstRecipientKey = trim((string) ($rows[0]['recipient_key'] ?? ''));
|
||||
$firstRecipientEmail = trim((string) ($rows[0]['recipient_email'] ?? ''));
|
||||
|
||||
$selected = [];
|
||||
$seenValues = [];
|
||||
foreach (array_keys($firstRow) as $key) {
|
||||
$name = (string) $key;
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
if ($this->isIdLikeField($name) || $this->isEmailLikeField($name)) {
|
||||
if ($this->isIdLikeField($name) || $this->isEmailLikeField($name) || $this->isKeyLikeField($name)) {
|
||||
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;
|
||||
if (count($selected) >= $maxColumns) {
|
||||
break;
|
||||
|
|
@ -371,6 +422,15 @@ final class MailshotTestAdminPage
|
|||
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 */
|
||||
private function displayCellValue($value): string
|
||||
{
|
||||
|
|
@ -403,14 +463,4 @@ final class MailshotTestAdminPage
|
|||
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 . '"}';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,9 +55,14 @@ final class MailshotsAdminPage
|
|||
|
||||
$draftRaw = $this->wp->getOption(self::DRAFT_OPTION_KEY, null);
|
||||
$draft = is_array($draftRaw) ? $draftRaw : null;
|
||||
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
|
||||
$editId = (int) $draft['id'];
|
||||
$requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
$draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
|
||||
$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;
|
||||
foreach ($items as $row) {
|
||||
|
|
@ -91,7 +96,7 @@ final class MailshotsAdminPage
|
|||
'RecipientEmailField' => (string) ($editItem['RecipientEmailField'] ?? ''),
|
||||
'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) {
|
||||
if (array_key_exists($key, $draft['form'])) {
|
||||
$form[$key] = (string) $draft['form'][$key];
|
||||
|
|
@ -116,29 +121,39 @@ final class MailshotsAdminPage
|
|||
|
||||
$result = $this->consumeOptionArray(self::RESULT_OPTION_KEY);
|
||||
$action = htmlspecialchars($this->wp->adminUrl('admin-post.php'));
|
||||
$buildStamp = gmdate('Y-m-d H:i:s', (int) @filemtime(__FILE__)) . ' UTC';
|
||||
$renderedAt = gmdate('Y-m-d H:i:s') . ' UTC';
|
||||
$pluginMainFile = dirname(__DIR__, 2) . '/feca_mailshots_plugin.php';
|
||||
$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 '<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 '<div class="wrap feca-mailshots-admin"><h1>Mailshots</h1>';
|
||||
echo $this->renderAdminUiStyles();
|
||||
$isSaveError = (($result['context'] ?? '') === 'mailshot_save') && empty($result['ok']);
|
||||
if ($result !== null && !$isSaveError && (!is_array($draft) || !empty($result['ok']))) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
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>' . ($ok ? 'Mailshot saved.' : 'Mailshot action failed.') . '</strong>';
|
||||
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 '<p><button type="button" class="button button-primary" id="ms-open-new">New Mailshot</button></p>';
|
||||
echo '<div id="ms-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:9998;">';
|
||||
echo '<div style="max-width:1100px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<form method="post" action="' . $action . '" id="mailshot-editor" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
|
||||
echo '<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" class="feca-modal-overlay">';
|
||||
echo '<div class="feca-modal-shell feca-modal-shell-wide">';
|
||||
echo '<form method="post" action="' . $action . '" id="mailshot-editor" class="feca-form">';
|
||||
echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit Mailshot' : 'New Mailshot') . '</h2>';
|
||||
echo $this->modalErrorHtml($result);
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_mailshots_ui_save">';
|
||||
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"><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 '<th scope="row">PDF Attachment</th><td><button type="button" class="button" id="ms-edit-pdf">Edit PDF Attachment</button> <span class="description" id="ms-pdf-meta"></span><textarea id="ms_pdfa" name="PDFAttachment" rows="8" class="large-text code" style="display:none;">' . htmlspecialchars($form['PDFAttachment']) . '</textarea><div id="ms-pdf-snippet" class="description" style="margin-top:8px;"></div></td></tr>';
|
||||
echo '<tr><th scope="row">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 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>';
|
||||
foreach ($pdfFieldOptions as $f) {
|
||||
|
|
@ -175,7 +190,7 @@ final class MailshotsAdminPage
|
|||
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 '<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 '<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 '</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) {
|
||||
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 '</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> ';
|
||||
if ($editId > 0) {
|
||||
echo '<a class="button" href="' . htmlspecialchars($this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots')) . '">Discard Changes</a> ';
|
||||
}
|
||||
echo '</p>';
|
||||
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-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 '<h3 id="ms-template-title" style="margin:0 0 8px 0;flex:0 0 auto;">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-token-controls" style="margin-bottom:10px;"></div>';
|
||||
echo '<div id="ms-template-pdf-assets" style="display:none;margin-bottom:10px;"><label>PDF Asset <select id="ms_pdf_asset_pick"><option value="">Select PDF asset</option></select></label> <button type="button" class="button button-small" id="ms_pdf_asset_insert">Insert Asset Token</button><p class="description">Inserts Twig helper token, example: {{ pdf_asset(\'logo_asset\') }}.</p></div>';
|
||||
echo '<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.css">';
|
||||
echo '<script src="https://cdn.jsdelivr.net/npm/jodit@4.7.9/es2021/jodit.min.js"></script>';
|
||||
echo '<script src="https://cdn.jsdelivr.net/npm/ace-builds@1.36.0/src-min-noconflict/ace.js"></script>';
|
||||
echo '<div id="ms-template-editor-wrap" style="flex:1 1 auto;min-height:320px;height:60vh;max-height:60vh;overflow:auto;">';
|
||||
echo '<textarea id="ms-template-jodit" rows="14" class="large-text code" style="height:100%;"></textarea>';
|
||||
echo '<div id="ms-template-modal" class="feca-modal-overlay-high">';
|
||||
echo '<div id="ms-template-panel" class="feca-template-panel">';
|
||||
echo '<h3 id="ms-template-title" class="feca-template-title">Template Editor</h3>';
|
||||
echo '<div id="ms-template-error" class="feca-template-error"></div>';
|
||||
echo '<div id="ms-template-token-controls" class="feca-template-token-controls"></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="' . htmlspecialchars($joditCssUrl, ENT_QUOTES) . '">';
|
||||
echo '<script src="' . htmlspecialchars($joditJsUrl, ENT_QUOTES) . '"></script>';
|
||||
echo '<script src="' . htmlspecialchars($aceJsUrl, ENT_QUOTES) . '"></script>';
|
||||
echo '<div id="ms-template-editor-wrap" class="feca-template-editor-wrap">';
|
||||
echo '<textarea id="ms-template-jodit" rows="14" class="large-text code feca-template-editor"></textarea>';
|
||||
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 '<style>
|
||||
#ms-template-panel .jodit-container { height: 100% !important; }
|
||||
</style>';
|
||||
|
||||
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>';
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$editUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-mailshots&edit_id=' . $id);
|
||||
echo '<tr>';
|
||||
echo '<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . 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 style="max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' . htmlspecialchars((string) ($row['Subject'] ?? '')) . '</td>';
|
||||
echo '<td class="feca-truncate-220">' . htmlspecialchars((string) ($row['Purpose'] ?? '')) . '</td>';
|
||||
echo '<td class="feca-truncate-220">' . htmlspecialchars((string) ($row['DataSource'] ?? '')) . '</td>';
|
||||
echo '<td class="feca-truncate-360">' . htmlspecialchars((string) ($row['Subject'] ?? '')) . '</td>';
|
||||
echo '<td><a class="button button-small" href="' . htmlspecialchars($editUrl) . '">Edit</a> ';
|
||||
echo '<form method="post" action="' . $action . '" 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 $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
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 '</tr>';
|
||||
}
|
||||
|
|
@ -246,6 +256,7 @@ final class MailshotsAdminPage
|
|||
echo '<tr><td colspan="4">No mailshots found.</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '</div></div>';
|
||||
|
||||
echo '<script>
|
||||
(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 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 lastTarget = null;
|
||||
var isDirty = false;
|
||||
|
|
@ -308,6 +320,12 @@ final class MailshotsAdminPage
|
|||
var pdfSnippet = document.getElementById("ms-pdf-snippet");
|
||||
var pdfMeta = document.getElementById("ms-pdf-meta");
|
||||
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 templateDirty = false;
|
||||
|
|
@ -766,7 +784,7 @@ final class MailshotsAdminPage
|
|||
setCleanState();
|
||||
|
||||
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 resultOk = ' . (!empty($result['ok']) ? 'true' : 'false') . ';
|
||||
if ((hasEdit || hasDraft) && editorModal && (!hasResult || !resultOk)) {
|
||||
|
|
|
|||
|
|
@ -48,9 +48,14 @@ final class PdfAssetsAdminPage
|
|||
}
|
||||
$items = $this->service()->list();
|
||||
$draft = $this->consumeOptionArray(self::DRAFT_OPTION_KEY);
|
||||
$editId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
if ($editId <= 0 && is_array($draft) && isset($draft['id'])) {
|
||||
$editId = (int) $draft['id'];
|
||||
$requestedEditId = (int) ($this->wp->requestParam('edit_id', '0') ?? '0');
|
||||
$draftId = is_array($draft) && isset($draft['id']) ? (int) $draft['id'] : 0;
|
||||
$useDraft = is_array($draft) && (
|
||||
$requestedEditId <= 0 || ($draftId > 0 && $draftId === $requestedEditId)
|
||||
);
|
||||
$editId = $requestedEditId;
|
||||
if ($editId <= 0 && $useDraft && $draftId > 0) {
|
||||
$editId = $draftId;
|
||||
}
|
||||
$editItem = null;
|
||||
foreach ($items as $row) {
|
||||
|
|
@ -67,7 +72,7 @@ final class PdfAssetsAdminPage
|
|||
$height = (string) ($editItem['height_mm'] ?? '297');
|
||||
$just = (string) ($editItem['justification'] ?? 'in-place');
|
||||
$base64 = '';
|
||||
if (is_array($draft)) {
|
||||
if ($useDraft && is_array($draft)) {
|
||||
$name = (string) ($draft['name'] ?? $name);
|
||||
$fileName = (string) ($draft['file_name'] ?? $fileName);
|
||||
$width = (string) ($draft['width_mm'] ?? $width);
|
||||
|
|
@ -76,26 +81,26 @@ final class PdfAssetsAdminPage
|
|||
$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) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
$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>';
|
||||
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 '<p><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 style="max-width:980px;margin:30px auto;background:#fff;padding:12px;max-height:88vh;overflow:auto;">';
|
||||
echo '<p style="text-align:right;margin:0;"><button type="button" class="button" id="pdf-close-editor">Close</button></p>';
|
||||
echo '<form method="post" enctype="multipart/form-data" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;" id="pdf-editor-form">';
|
||||
echo '<h2 style="margin-top:0;">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
|
||||
echo '<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" class="feca-modal-overlay">';
|
||||
echo '<div class="feca-modal-shell">';
|
||||
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 . '" class="feca-form" id="pdf-editor-form">';
|
||||
echo '<h2 class="feca-modal-title">' . ($editId > 0 ? 'Edit PDF Asset' : 'New PDF Asset') . '</h2>';
|
||||
echo $this->modalErrorHtml($result);
|
||||
echo '<input type="hidden" name="action" value="feca_mailshots_pdf_assets_ui_save">';
|
||||
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_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 '<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) {
|
||||
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 '<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>';
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
|
|
@ -138,11 +144,11 @@ final class PdfAssetsAdminPage
|
|||
echo '<td>' . htmlspecialchars((string) ($row['justification'] ?? '')) . '</td>';
|
||||
echo '<td>' . htmlspecialchars((string) ($row['byte_size'] ?? '')) . '</td>';
|
||||
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 $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
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 '</tr>';
|
||||
}
|
||||
|
|
@ -150,6 +156,7 @@ final class PdfAssetsAdminPage
|
|||
echo '<tr><td colspan="7">No PDF assets found.</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '</div></div>';
|
||||
echo $this->modalEditorScript(
|
||||
'pdf-editor-modal',
|
||||
'pdf-open-new',
|
||||
|
|
@ -158,7 +165,7 @@ final class PdfAssetsAdminPage
|
|||
'pdf-editor-id',
|
||||
['pdf_name', 'pdf_file_name', 'pdf_width', 'pdf_height', 'pdf_base64'],
|
||||
$editId > 0,
|
||||
is_array($draft),
|
||||
$useDraft,
|
||||
$result !== null,
|
||||
!empty($result['ok']),
|
||||
'pdf_just',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ final class ProfileAdminPage
|
|||
|
||||
private const CAPABILITY = 'edit_pages';
|
||||
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 WordPressFacade $wp;
|
||||
|
|
@ -48,22 +47,21 @@ final class ProfileAdminPage
|
|||
|
||||
$uid = $this->wp->currentUserId();
|
||||
$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.' : '';
|
||||
$test = $uid > 0 ? $this->testResult($uid) : null;
|
||||
$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 !== '') {
|
||||
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) {
|
||||
$ok = !empty($test['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
$kind = strtoupper((string) ($test['kind'] ?? 'CREDENTIAL'));
|
||||
$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>';
|
||||
if (!empty($test['messages']) && is_array($test['messages'])) {
|
||||
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><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 '<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 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>';
|
||||
|
|
@ -86,10 +84,11 @@ final class ProfileAdminPage
|
|||
echo '</ul>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<form method="post" action="' . $action . '">';
|
||||
echo '<form method="post" action="' . $action . '" class="feca-form">';
|
||||
echo $this->hiddenNonceField(self::NONCE_ACTION);
|
||||
|
||||
echo '<h2>SMTP</h2>';
|
||||
echo '<table class="form-table" role="presentation">';
|
||||
$this->field('SMTP Host', 'smtp_host', $saved['smtp_host'] ?? '');
|
||||
$this->field('SMTP Port', 'smtp_port', (string) ($saved['smtp_port'] ?? ''));
|
||||
$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'] ?? '');
|
||||
|
||||
$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 '<table class="form-table" role="presentation">';
|
||||
$this->field('IMAP Host', 'imap_host', $saved['imap_host'] ?? '');
|
||||
$this->field('IMAP Port', 'imap_port', (string) ($saved['imap_port'] ?? ''));
|
||||
$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 Sent Folder', 'imap_sent_folder', $saved['imap_sent_folder'] ?? '');
|
||||
$this->field('IMAP Mailbox Flags', 'imap_mailbox_flags', $saved['imap_mailbox_flags'] ?? '');
|
||||
echo '</table>';
|
||||
|
||||
echo '<h2>Operational Settings</h2>';
|
||||
$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 '<p class="feca-button-row">';
|
||||
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 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_mailbox_flags' => trim((string) ($this->wp->requestParam('imap_mailbox_flags', '') ?? '')),
|
||||
];
|
||||
$downloadMemoryLimit = trim((string) ($this->wp->requestParam('download_memory_limit', '') ?? ''));
|
||||
|
||||
$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) {
|
||||
if ($payload[$required] === '') {
|
||||
|
|
@ -165,16 +156,7 @@ final class ProfileAdminPage
|
|||
$this->wp->sendJson(['ok' => false, 'errors' => $errors], 400);
|
||||
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->wp->updateOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, $downloadMemoryLimit);
|
||||
|
||||
if (!headers_sent()) {
|
||||
$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
|
||||
{
|
||||
echo '<table class="form-table" role="presentation"><tr>';
|
||||
echo '<tr>';
|
||||
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) . '">';
|
||||
if ($hint !== '') {
|
||||
echo '<p class="description">' . htmlspecialchars($hint) . '</p>';
|
||||
}
|
||||
echo '</td></tr></table>';
|
||||
}
|
||||
|
||||
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;
|
||||
echo '</td></tr>';
|
||||
}
|
||||
|
||||
private function repo(): MailCredentialRepository
|
||||
|
|
|
|||
|
|
@ -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 '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
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:320px;">';
|
||||
echo '<label for="rr_data_source" style="white-space:nowrap;padding-left:4px;"><strong>Data Source</strong></label>';
|
||||
echo '<select id="rr_data_source" style="min-width:220px;">';
|
||||
echo '<div class="feca-panel">';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-320">';
|
||||
echo '<label for="rr_data_source"><strong>Data Source</strong></label>';
|
||||
echo '<select id="rr_data_source" class="feca-minw-220">';
|
||||
echo '<option value="">Select data source</option>';
|
||||
foreach ($sources as $source) {
|
||||
$name = trim((string) ($source['name'] ?? ''));
|
||||
|
|
@ -84,27 +85,27 @@ final class ReviewRecipientsAdminPage
|
|||
echo '<option value="' . htmlspecialchars($name, ENT_QUOTES) . '"' . $selected . '>' . htmlspecialchars($name) . '</option>';
|
||||
}
|
||||
echo '</select></div>';
|
||||
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
|
||||
echo '<label for="rr_filter" style="white-space:nowrap;padding-left:4px;"><strong>Filter by</strong></label>';
|
||||
echo '<input id="rr_filter" class="regular-text" type="text" placeholder="Matches any field" style="min-width:240px;">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
echo '<label for="rr_filter"><strong>Filter by</strong></label>';
|
||||
echo '<input id="rr_filter" class="regular-text feca-minw-240" type="text" placeholder="Matches any field">';
|
||||
echo '</div>';
|
||||
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:260px;">';
|
||||
echo '<label for="rr_sort_by" style="white-space:nowrap;padding-left:4px;"><strong>Sort by</strong></label>';
|
||||
echo '<select id="rr_sort_by" style="min-width:170px;"><option value="">(no fields)</option></select>';
|
||||
echo '<div class="feca-control feca-control-min-260">';
|
||||
echo '<label for="rr_sort_by"><strong>Sort by</strong></label>';
|
||||
echo '<select id="rr_sort_by" class="feca-minw-170"><option value="">(no fields)</option></select>';
|
||||
echo '</div>';
|
||||
echo '<div style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:280px;">';
|
||||
echo '<label for="rr_sort_direction" style="white-space:nowrap;padding-left:4px;"><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 '<div class="feca-control feca-control-min-280">';
|
||||
echo '<label for="rr_sort_direction"><strong>Sort Direction</strong></label>';
|
||||
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></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 '</div>';
|
||||
echo '<div id="rr_error" style="display:none;padding:10px;border:1px solid #ef9a9a;background:#ffebee;margin-bottom:12px;"></div>';
|
||||
echo '<div style="border:1px solid #dcdcde;background:#fff;max-width:calc(100vw - 80px);">';
|
||||
echo '<div id="rr_scroll" style="overflow:scroll;max-height:68vh;scrollbar-gutter:stable both-edges;">';
|
||||
echo '<table class="widefat striped" id="rr_table" style="min-width:100%;width:max-content;margin:0;border-collapse:separate;border-spacing:0;">';
|
||||
echo '<div id="rr_error" class="feca-banner feca-banner-error feca-hidden"></div>';
|
||||
echo '<div class="feca-scroll-frame">';
|
||||
echo '<div id="rr_scroll" class="feca-scroll-pane">';
|
||||
echo '<table class="widefat striped feca-table-wide" id="rr_table">';
|
||||
echo '<colgroup id="rr_cols"></colgroup>';
|
||||
echo '<thead><tr id="rr_head_row"><th>No recipients loaded.</th></tr></thead>';
|
||||
echo '<tbody id="rr_body"></tbody></table>';
|
||||
|
|
@ -148,30 +149,39 @@ final class ReviewRecipientsAdminPage
|
|||
echo 'var headRow=document.getElementById("rr_head_row");';
|
||||
echo 'var bodyEl=document.getElementById("rr_body");';
|
||||
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 clearErr(){errEl.style.display="none";errEl.textContent="";}';
|
||||
echo 'function showErr(msg){errEl.style.display="block";errEl.textContent=msg||"Unknown error";}';
|
||||
echo 'function clearErr(){errEl.classList.add("feca-hidden");errEl.textContent="";}';
|
||||
echo 'function showErr(msg){errEl.classList.remove("feca-hidden");errEl.textContent=msg||"Unknown error";}';
|
||||
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 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 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 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 '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 sourceStorageKey(src){return storagePrefix+String(src||"");}';
|
||||
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,""")+"\\""+(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 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 '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 '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 '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 'var startSource=String((cfg.selectedSource||sourceSel.value||"")).trim();state.source=startSource;';
|
||||
echo 'state.selectedKeys=loadSelection(startSource);';
|
||||
echo 'if(startSource){fetchSource(startSource);}else{renderTable();}';
|
||||
echo '})();';
|
||||
echo '</script>';
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ final class RunMailshotAdminPage
|
|||
$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_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_recipient_ui', [$this, 'handleRetryRecipientUi']);
|
||||
}
|
||||
|
|
@ -65,14 +66,14 @@ final class RunMailshotAdminPage
|
|||
$runCount = !empty($recipientSummary['ok']) ? (int) ($recipientSummary['count'] ?? 0) : 0;
|
||||
$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>';
|
||||
|
||||
if ($result !== null) {
|
||||
$ok = !empty($result['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
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>' . ($ok ? 'Run action completed.' : 'Run action failed.') . '</strong>';
|
||||
if ($ok) {
|
||||
echo '<p>attempted=' . (int) ($result['attempted'] ?? 0)
|
||||
|
|
@ -83,19 +84,18 @@ final class RunMailshotAdminPage
|
|||
. '</p>';
|
||||
}
|
||||
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 style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<h2 style="margin-top:0;">1. Select Mailshot</h2>';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" style="margin-bottom:0;" id="run-mailshot-picker">';
|
||||
echo '<div class="feca-panel">';
|
||||
echo '<h2 class="feca-section-title">1. Select Mailshot</h2>';
|
||||
echo '<form method="get" action="' . htmlspecialchars($this->wp->adminUrl('admin.php')) . '" id="run-mailshot-picker">';
|
||||
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 style="display:flex;align-items:center;gap:8px;padding-top:6px;min-width:360px;">';
|
||||
echo '<label for="run_mailshot_id" style="white-space:nowrap;padding-left:4px;"><strong>Mailshot</strong></label>';
|
||||
echo '<div class="feca-control-row">';
|
||||
echo '<div class="feca-control feca-control-min-360">';
|
||||
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();">';
|
||||
foreach ($mailshots as $m) {
|
||||
$id = (int) ($m['id'] ?? 0);
|
||||
|
|
@ -109,25 +109,49 @@ final class RunMailshotAdminPage
|
|||
echo '</select></div></div>';
|
||||
echo '</form>';
|
||||
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'])) {
|
||||
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 '<form method="post" action="' . $action . '" style="padding:12px;border:1px solid #dcdcde;background:#fff;margin-bottom:12px;">';
|
||||
echo '<h2 style="margin-top:0;">2. Run Actions</h2>';
|
||||
$reviewUrl = $this->wp->adminUrl('admin.php?page=feca-mailshots-review-recipients');
|
||||
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 '<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" 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 '</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 '<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 '<h2 style="margin-top:0;">3. Last Run Rows</h2>';
|
||||
echo '<div class="feca-panel">';
|
||||
echo '<h2 class="feca-section-title">3. Last Run Rows</h2>';
|
||||
if ($lastRunRows === []) {
|
||||
echo '<p>No rows in mailshot_last_run for this mailshot.</p>';
|
||||
} 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>';
|
||||
foreach ($lastRunRows as $row) {
|
||||
$recipientKey = (string) ($row['recipient_key'] ?? '');
|
||||
|
|
@ -153,6 +177,7 @@ final class RunMailshotAdminPage
|
|||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
echo '</div></div>';
|
||||
}
|
||||
echo '</div>';
|
||||
|
||||
|
|
@ -175,6 +200,13 @@ final class RunMailshotAdminPage
|
|||
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') {
|
||||
$mailshotId = (int) ($this->wp->requestParam('mailshot_id', '0') ?? '0');
|
||||
$this->wp->sendJson($service->retryFailed($mailshotId));
|
||||
|
|
@ -205,6 +237,18 @@ final class RunMailshotAdminPage
|
|||
$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
|
||||
{
|
||||
if (!$this->enforceMutationGuardOrJson(self::CAPABILITY, self::NONCE_ACTION)) {
|
||||
|
|
@ -254,4 +298,36 @@ final class RunMailshotAdminPage
|
|||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ final class SetupAdminPage
|
|||
|
||||
public const OPTION_KEY = 'feca_mailshots_db_settings';
|
||||
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 WordPressFacade $wp;
|
||||
|
|
@ -41,21 +42,22 @@ final class SetupAdminPage
|
|||
}
|
||||
|
||||
$saved = $this->settings();
|
||||
$downloadMemoryLimit = trim((string) ($this->wp->getOption(self::DOWNLOAD_MEMORY_LIMIT_OPTION, '')));
|
||||
$status = $this->wp->requestParam('saved', '') === '1' ? 'Settings saved.' : '';
|
||||
$test = $this->testResult();
|
||||
$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>';
|
||||
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) {
|
||||
$ok = !empty($test['ok']);
|
||||
$bg = $ok ? '#f1f8e9' : '#ffebee';
|
||||
$border = $ok ? '#8bc34a' : '#ef9a9a';
|
||||
$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>';
|
||||
if (!empty($test['messages']) && is_array($test['messages'])) {
|
||||
echo '<ul>';
|
||||
|
|
@ -67,17 +69,25 @@ final class SetupAdminPage
|
|||
echo '</div>';
|
||||
}
|
||||
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 '<table class="form-table" role="presentation">';
|
||||
$this->field('Host', 'db_host', $saved['db_host'] ?? '');
|
||||
$this->field('Port', 'db_port', $saved['db_port'] ?? '3306');
|
||||
$this->field('User', 'db_user', $saved['db_user'] ?? '');
|
||||
$this->field('Password', 'db_password', $saved['db_password'] ?? '', 'password');
|
||||
$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(
|
||||
'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" name="action" value="feca_mailshots_setup_test">Test Connection</button>';
|
||||
echo '</p>';
|
||||
|
|
@ -99,8 +109,17 @@ final class SetupAdminPage
|
|||
'mailshots_db_name' => trim((string) ($this->wp->requestParam('mailshots_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::DOWNLOAD_MEMORY_LIMIT_OPTION, $downloadMemoryLimit);
|
||||
|
||||
if (!headers_sent()) {
|
||||
$location = $this->wp->adminUrl('admin.php?page=feca-mailshots-setup&saved=1');
|
||||
|
|
@ -152,12 +171,44 @@ final class SetupAdminPage
|
|||
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 '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '"></td>';
|
||||
echo '</tr></table>';
|
||||
echo '<td><input class="regular-text" type="' . htmlspecialchars($type) . '" id="' . htmlspecialchars($name) . '" name="' . htmlspecialchars($name) . '" value="' . htmlspecialchars($value, ENT_QUOTES) . '">';
|
||||
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>} */
|
||||
|
|
|
|||
|
|
@ -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> */
|
||||
public function runMailshot(int $mailshotId): array
|
||||
{
|
||||
|
|
@ -208,6 +279,43 @@ final class MailshotRunService
|
|||
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> */
|
||||
public function retryFailed(int $mailshotId): array
|
||||
{
|
||||
|
|
@ -273,13 +381,6 @@ final class MailshotRunService
|
|||
/** @return array<string,mixed> */
|
||||
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) {
|
||||
return ['ok' => false, 'errors' => ['PDF generation requested no outputs.']];
|
||||
}
|
||||
|
|
@ -311,11 +412,6 @@ final class MailshotRunService
|
|||
if (!@mkdir($mergedPdfTmpDir, 0700, true) && !is_dir($mergedPdfTmpDir)) {
|
||||
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) {
|
||||
$tmpBasePath = tempnam(sys_get_temp_dir(), 'feca_mailshots_merged_html_');
|
||||
if (!is_string($tmpBasePath) || $tmpBasePath === '') {
|
||||
|
|
@ -384,17 +480,6 @@ final class MailshotRunService
|
|||
if (($index % 10) === 0 && function_exists('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)) {
|
||||
|
|
@ -429,42 +514,19 @@ final class MailshotRunService
|
|||
if ($includeMerged) {
|
||||
if ($useGhostscriptMerge) {
|
||||
try {
|
||||
$this->downloadDebugLog('generatePdfBatch.merged.before_ghostscript', [
|
||||
'mailshot_id' => $mailshotId,
|
||||
'parts' => count($mergedPdfPartPaths),
|
||||
]);
|
||||
$mergedPdfBytes = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths);
|
||||
} catch (\Throwable $e) {
|
||||
$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()]];
|
||||
}
|
||||
$this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
|
||||
} else {
|
||||
try {
|
||||
$this->downloadDebugLog('generatePdfBatch.merged.before_render', [
|
||||
'mailshot_id' => $mailshotId,
|
||||
'merged_html_path' => (string) $mergedHtmlTmpPath,
|
||||
'merged_sections' => $mergedSectionCount,
|
||||
]);
|
||||
$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) {
|
||||
if ($mergedHtmlTmpPath !== null) {
|
||||
@unlink($mergedHtmlTmpPath);
|
||||
}
|
||||
$this->downloadDebugLog('generatePdfBatch.merged.exception', [
|
||||
'mailshot_id' => $mailshotId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
|
||||
}
|
||||
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 [
|
||||
'ok' => true,
|
||||
'recipient_count' => count($rows),
|
||||
|
|
@ -495,11 +548,6 @@ final class MailshotRunService
|
|||
/** @return array<string,mixed> */
|
||||
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');
|
||||
$zipBinary = $this->findExecutableBinary('zip', ['/usr/bin/zip', '/bin/zip']);
|
||||
if (!$canUseZipArchive && $zipBinary === '') {
|
||||
|
|
@ -578,16 +626,6 @@ final class MailshotRunService
|
|||
if (($index % 5) === 0 && function_exists('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 !== []) {
|
||||
|
|
@ -605,11 +643,6 @@ final class MailshotRunService
|
|||
}
|
||||
|
||||
if ($canUseZipArchive) {
|
||||
$this->downloadDebugLog('generatePdfZipToTemp.ziparchive.begin', [
|
||||
'mailshot_id' => $mailshotId,
|
||||
'zip_path' => $tmpZipPath,
|
||||
'file_count' => count($generatedPdfNames),
|
||||
]);
|
||||
$zip = new \ZipArchive();
|
||||
$opened = $zip->open($tmpZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
|
||||
if ($opened !== true) {
|
||||
|
|
@ -622,11 +655,6 @@ final class MailshotRunService
|
|||
}
|
||||
$zip->close();
|
||||
} else {
|
||||
$this->downloadDebugLog('generatePdfZipToTemp.zipcli.begin', [
|
||||
'mailshot_id' => $mailshotId,
|
||||
'zip_path' => $tmpZipPath,
|
||||
'file_count' => count($generatedPdfNames),
|
||||
]);
|
||||
if ($zipBinary === '') {
|
||||
throw new \RuntimeException('zip CLI binary is not available.');
|
||||
}
|
||||
|
|
@ -670,12 +698,6 @@ final class MailshotRunService
|
|||
'zip_size' => $size,
|
||||
];
|
||||
} 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) {
|
||||
@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
|
||||
* @return list<array{filename:string,mime_type:string,content_bytes:string}>
|
||||
|
|
|
|||
|
|
@ -3,9 +3,15 @@
|
|||
declare(strict_types=1);
|
||||
|
||||
// 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)) {
|
||||
require_once $composerAutoload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
|
|
|
|||
|
|
@ -37,12 +37,12 @@ $dbConfig = [
|
|||
$wp = new ProductionWordPressFacade();
|
||||
$container = Plugin::buildContainer($dbConfig, $wp);
|
||||
$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\PdfAssetsAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\SetupAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\MailshotsAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\ReviewRecipientsAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\MailshotTestAdminPage::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\SetupAdminPage::class)->register();
|
||||
$container->get(FecaMailshots\Admin\ProfileAdminPage::class)->register();
|
||||
|
|
|
|||
|
|
@ -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 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
|
||||
|
||||
- The code must expose a WordPress-reportable semantic version number in `x.y.z` format.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* 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 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.
|
||||
* If query returns zero rows, display zero-result message (no error state).
|
||||
* 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 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.
|
||||
* 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.
|
||||
|
|
@ -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.
|
||||
* Use selected test row only for template rendering context.
|
||||
* 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`.
|
||||
* Display success/failure status and timestamp.
|
||||
* 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
|
||||
* copy successful sends to IMAP `Sent`
|
||||
* 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
|
||||
|
||||
|
|
|
|||
|
|
@ -47,8 +47,6 @@ For pages using info/statistics/actions/data regions:
|
|||
* Persist user-adjusted split ratios per page.
|
||||
* On narrow viewports, collapse split layouts to a vertical stack and disable drag interaction.
|
||||
* Keep each pane independently scrollable when content exceeds pane bounds.
|
||||
*
|
||||
*
|
||||
|
||||
## Common UX Rules
|
||||
|
||||
|
|
@ -57,8 +55,11 @@ For pages using info/statistics/actions/data regions:
|
|||
* Use red visual treatment for destructive actions.
|
||||
* Provide spacing between action buttons and panel edges.
|
||||
* 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.
|
||||
* 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
|
||||
|
||||
|
|
@ -69,7 +70,7 @@ For pages using info/statistics/actions/data regions:
|
|||
* a `Sort by` dropdown listing sortable columns
|
||||
* an adjacent direction dropdown with options `Ascending` and `Descending`
|
||||
* 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.
|
||||
|
||||
## Resize Stability Requirements
|
||||
|
|
|
|||
|
|
@ -115,8 +115,7 @@ if [[ "${REMOTE_DIR_RAW}" = /* ]]; then
|
|||
else
|
||||
REMOTE_DIR="/home/${SSH_USER}/${REMOTE_DIR_RAW}"
|
||||
fi
|
||||
REMOTE_PARENT_DIR="$(dirname "${REMOTE_DIR}")"
|
||||
REMOTE_VENDOR_DIR="${REMOTE_PARENT_DIR}/vendor"
|
||||
REMOTE_PLUGIN_VENDOR_DIR="${REMOTE_DIR}/vendor"
|
||||
|
||||
CURRENT_VERSION="$(awk '
|
||||
/Version:/ {
|
||||
|
|
@ -163,24 +162,16 @@ fi
|
|||
echo "Remote host: ${SSH_TARGET}:${SSH_PORT}"
|
||||
echo "Source dir: ${SOURCE_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 [[ ! -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
|
||||
else
|
||||
if command -v composer >/dev/null 2>&1; then
|
||||
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
|
||||
"${REPO_ROOT}/scripts/install_dependencies.sh" --skip-node
|
||||
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
|
||||
fi
|
||||
fi
|
||||
|
|
@ -190,7 +181,7 @@ ssh -p "${SSH_PORT}" \
|
|||
-o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
"${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_ARGS=(
|
||||
|
|
@ -210,10 +201,9 @@ rsync "${RSYNC_ARGS[@]}" \
|
|||
"${SOURCE_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=(
|
||||
-avz
|
||||
--delete
|
||||
)
|
||||
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||
VENDOR_RSYNC_ARGS+=(-n)
|
||||
|
|
@ -222,7 +212,7 @@ fi
|
|||
rsync "${VENDOR_RSYNC_ARGS[@]}" \
|
||||
-e "${RSYNC_SSH}" \
|
||||
"${LOCAL_VENDOR_DIR}/" \
|
||||
"${SSH_TARGET}:${REMOTE_VENDOR_DIR}/"
|
||||
"${SSH_TARGET}:${REMOTE_PLUGIN_VENDOR_DIR}/"
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||
echo "Dry-run complete. No remote files were changed."
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
@ -3,6 +3,7 @@ set -euo pipefail
|
|||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
INSTALL_SCRIPT="${REPO_ROOT}/scripts/install_dependencies.sh"
|
||||
|
||||
PLUGIN_SLUG="${PLUGIN_SLUG:-feca_mailshots_plugin}"
|
||||
SOURCE_DIR="${SOURCE_DIR:-${REPO_ROOT}/${PLUGIN_SLUG}}"
|
||||
|
|
@ -54,6 +55,11 @@ if [[ ! -f "${PLUGIN_MAIN_FILE}" ]]; then
|
|||
exit 1
|
||||
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
|
||||
echo "Error: 'zip' command is required but not installed." >&2
|
||||
exit 1
|
||||
|
|
@ -82,6 +88,13 @@ trap 'rm -rf "${TMP_DIR}"' EXIT
|
|||
STAGE_DIR="${TMP_DIR}/${PLUGIN_BASENAME}"
|
||||
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 \
|
||||
--delete \
|
||||
--exclude '.git/' \
|
||||
|
|
@ -90,8 +103,12 @@ rsync -a \
|
|||
--exclude '.idea/' \
|
||||
--exclude '.vscode/' \
|
||||
--exclude 'node_modules/' \
|
||||
--exclude 'vendor/' \
|
||||
"${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"
|
||||
rm -f "${ARCHIVE_PATH}"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { adminPath, apiList, apiPost, ensureDataSource, ensureMailshot, cleanupByNames } from './helpers.mjs';
|
||||
import { adminPath, apiList, 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 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 fallbackPdfTemplate = '<div>Download PDF test</div>';
|
||||
|
||||
test.describe('renewal dataset: download pdf e2e', () => {
|
||||
test.skip(!runEnabled, 'Enable with E2E_RENEWAL_ENABLE=1');
|
||||
|
|
@ -21,27 +15,13 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
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;
|
||||
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 {
|
||||
const critical = page.getByText('There has been a critical error on this website.');
|
||||
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.`);
|
||||
}
|
||||
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);
|
||||
|
|
@ -70,71 +59,43 @@ test.describe('renewal dataset: download pdf e2e', () => {
|
|||
const purpose = `e2e_renewal_download_${uniq}`;
|
||||
const dsCreatedName = `e2e_renewal_ds_${uniq}`;
|
||||
|
||||
let mailshotId = '';
|
||||
let effectiveDsName = dsName;
|
||||
let createdMailshot = false;
|
||||
if (!allowCreate) {
|
||||
const mailshotList = await apiList(request, 'feca_mailshots_mailshots_api');
|
||||
expect(mailshotList.ok).toBeTruthy();
|
||||
const existing = (mailshotList.items || []).find(
|
||||
(row) => String(row.Purpose || '') === mailshotPurpose
|
||||
);
|
||||
|
||||
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) {
|
||||
if (!existing) {
|
||||
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, {
|
||||
Purpose: purpose,
|
||||
DataSource: effectiveDsName,
|
||||
DataSource: dsCreatedName,
|
||||
CC: '',
|
||||
BCC: '',
|
||||
Subject: 'Renewal for {{ account_name }}',
|
||||
Subject: 'Renewal PDF e2e',
|
||||
Message: '',
|
||||
PDFAttachment: renewalsTemplate,
|
||||
PDFAttachment: fallbackPdfTemplate,
|
||||
AttachmentNames: '[]',
|
||||
PDFFilenameDerivedFrom: 'account_name',
|
||||
PDFFilenameDerivedFrom: '',
|
||||
RecipientEmailField: '',
|
||||
ReplyTo: ''
|
||||
});
|
||||
mailshotId = String(saved.id || '');
|
||||
createdMailshot = true;
|
||||
}
|
||||
let mailshotId = String(saved.id || '');
|
||||
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 {
|
||||
mailshotId,
|
||||
cleanup: async () => {
|
||||
await cleanupByNames(request, {
|
||||
dataSourceNames: [dsCreatedName],
|
||||
mailshotPurposes: createdMailshot ? [purpose] : []
|
||||
mailshotPurposes: [purpose]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { adminPath, apiList } from './helpers.mjs';
|
|||
|
||||
const runEnabled = process.env.E2E_RENEWAL_ENABLE === '1';
|
||||
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.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');
|
||||
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
|
||||
);
|
||||
expect(existing).toBeTruthy();
|
||||
const mailshotId = String(existing.id || '');
|
||||
const existingByDataSource = items.find(
|
||||
(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('');
|
||||
|
||||
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-merged-btn')).toBeDisabled();
|
||||
await expect(page.locator('#feca-download-zip-btn')).toBeEnabled();
|
||||
await expect(page.locator('#feca-download-zip-btn')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,43 @@ import { expect } from '@playwright/test';
|
|||
export const adminPath = (page) => `/wp-admin/admin.php?page=${page}`;
|
||||
export const postPath = (action, op = '') => `/wp-admin/admin-post.php?action=${encodeURIComponent(action)}${op ? `&op=${encodeURIComponent(op)}` : ''}`;
|
||||
|
||||
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 = {}) {
|
||||
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 raw = await res.text();
|
||||
let decoded;
|
||||
|
|
|
|||
|
|
@ -52,6 +52,34 @@ const setJoditMode = async (page, 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 }) => {
|
||||
const uniq = `${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms_purpose').fill(purpose);
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
await page.locator('#ms_purpose').fill(purpose);
|
||||
await page.locator('#ms_ds').selectOption({ label: dsName });
|
||||
await page.locator('#ms_subject').fill('No recipient email field');
|
||||
|
|
|
|||
|
|
@ -35,6 +35,34 @@ const setJoditHtml = async (page, 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 }) => {
|
||||
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>`;
|
||||
|
|
@ -42,8 +70,7 @@ test('regression: mailshot message modal saves and closes on first click', async
|
|||
await page.goto(adminPath('feca-mailshots-mailshots'));
|
||||
await expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms-edit-message').click();
|
||||
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 expect(page.locator('h1', { hasText: 'Mailshots' })).toBeVisible();
|
||||
|
||||
await page.locator('#ms-open-new').click();
|
||||
await expect(page.locator('#ms-editor-modal')).toBeVisible();
|
||||
await openNewMailshotEditor(page);
|
||||
|
||||
await page.locator('#ms_purpose').fill('validation draft mailshot');
|
||||
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_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 expect(page.locator('#ms-editor-modal')).toBeHidden();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
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', {
|
||||
mailshot_id: '1',
|
||||
|
|
|
|||
|
|
@ -6,8 +6,17 @@ require_once dirname(__DIR__, 2) . '/feca_mailshots_plugin/src/autoload.php';
|
|||
|
||||
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(
|
||||
static function (string $name): ?array {
|
||||
static function (string $name) use ($pngBytes): ?array {
|
||||
if (strtolower(trim($name)) !== 'logo_asset') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -15,7 +24,7 @@ $renderer = new TemplateRenderer(
|
|||
'name' => 'logo_asset',
|
||||
'file_name' => 'logo.png',
|
||||
'mime_type' => 'image/png',
|
||||
'file_bytes' => "PNG_BYTES",
|
||||
'file_bytes' => $pngBytes,
|
||||
'width_mm' => 20,
|
||||
'height_mm' => 10,
|
||||
'justification' => 'left',
|
||||
|
|
@ -31,8 +40,8 @@ $rendered = $renderer->render(
|
|||
);
|
||||
|
||||
$pdfHtml = (string) ($rendered['pdf_attachment'] ?? '');
|
||||
if (strpos($pdfHtml, 'data:image/png;base64,') === false) {
|
||||
fwrite(STDERR, "pdf_asset did not render data-uri image HTML\n");
|
||||
if (strpos($pdfHtml, 'data:image/png;base64,') === false && strpos($pdfHtml, 'data:image/jpeg;base64,') === false) {
|
||||
fwrite(STDERR, "pdf_asset did not render png/jpeg data-uri image HTML\n");
|
||||
exit(1);
|
||||
}
|
||||
if (strpos($pdfHtml, 'text-align:left') === false) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue