feat: Pipeline board M5 — Mission Brief (list + right-rail inspector) (#175, v0.27.0)

Reworks the board into Direction C Mission Brief: status-grouped run list + persistent inspector reusing M4 Stage I/O; stage-list-driven; preserves New Article + polling + filters; column-limit setting restored. QA APPROVE 1034e19; CI green.

Agent-Session: pipeline-board-m5-merge
This commit is contained in:
peptiderepo
2026-06-23 16:39:27 +07:00
committed by GitHub
parent 16fb9fdc5d
commit 23f66c3770
15 changed files with 1633 additions and 579 deletions

View File

@@ -121,13 +121,13 @@ prautoblogger/
│ │ └── OFL.txt # SIL Open Font License
│ ├── css/
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
│ │ ├── board.css # Mission Brief board styles -- verdict chips, run-list, inspector (v0.19.0, v0.27.0)
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
│ │ ├── dossier-edit.css # M3 edit/re-run layer: chips, edit panel, image grid, spend warning (v0.20.0)
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
│ └── js/
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
│ ├── board.js # Mission Brief board: AJAX poll, run-list render, inspector rail (v0.19.0, v0.27.0)
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
│ ├── dossier-edit.js # M3 edit/re-run actions + stage-status polling (queued -> pickup -> result) (v0.20.0)
│ └── posts-widget.js # React component for frontend post cards (wp.element)
@@ -146,8 +146,9 @@ prautoblogger/
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
│ │
│ ├── admin/
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
│ │ ├── class-board-page.php # Mission Brief board -- primary landing screen (v0.19.0, v0.27.0)
│ │ ├── class-board-data-provider.php # Board orchestrator: run-list snapshot + stage dots (v0.19.0, v0.27.0)
│ │ ├── class-board-stage-dots.php # Dot-rail enrichment: batched run_stages query per board section (M5, v0.27.0)
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
│ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
@@ -173,7 +174,8 @@ prautoblogger/
│ │ ├── class-model-registry-refresh.php # AJAX: refresh OpenRouter model registry (manual trigger)
│ │ ├── class-pipeline-preview-handler.php # M3: assembled-instructions preview from last-run gen_log (v0.25.0)
│ │ ├── class-pipeline-history-handler.php # M3: prompt version list + LCS diff (history + diff actions) (v0.25.0)
│ │ └── class-gen-run-io-handler.php # M4: per-run stage I/O drill-down (prautoblogger_gen_run_io) (v0.26.0)
│ │ ├── class-gen-run-io-handler.php # M4: per-run stage I/O drill-down (prautoblogger_gen_run_io) (v0.26.0)
│ │ └── class-board-inspector-handler.php # M5: per-run stage inspector AJAX (prautoblogger_board_inspector) (v0.27.0)
│ │
│ ├── core/
│ │ ├── class-scheduler.php # WP-Cron / Action Scheduler job management
@@ -273,7 +275,7 @@ prautoblogger/
│
├── templates/
│ └── admin/
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
│ ├── board-page.php # Mission Brief board template (run-list + inspector layout) (v0.19.0, v0.27.0)
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
│ ├── dossier-stage-section.php # Stage section partial: output, raw trace, M3 chips + rerun footer
│ ├── dossier-edit-panel.php # M3 per-stage edit panel: message textareas, fork info, spend strip (v0.20.0)
@@ -677,7 +679,7 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. |
| `prautoblogger_board_poll_interval` | Mission Brief board poll interval in seconds (default 5, min 3). Localized into board.js. |
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
### Post Meta
@@ -1010,16 +1012,15 @@ Tests use Brain\Monkey for WordPress function mocking (no database required). Th
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
required for M1. The data provider uses the `prautoblogger_generation_status` transient
(written by the Executor for manual/daily runs) as the primary active-run signal, with a
secondary query on recent `prab_generation_log` run_ids that have no linked post as a
fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier.
The primary admin screen is now a Mission Brief board (M5, v0.27.0) -- a vertical run-list
+ right-rail inspector layout (CEO-selected Direction C "Mission Brief"). A separate
`class-board-page.php` registers the `Board` submenu and a `$submenu` reorder makes it
the first (primary) entry. `class-board-data-provider.php` orchestrates the four status
sections (Generating | In review | Published | Failed) and enriches each card with a
lightweight `run_stages_summary` for the dot-rail. Raw `prab_generation_log` queries
(Generating, Failed) remain in `class-board-gen-log-query.php`. The right-rail inspector
is powered by `ajax/class-board-inspector-handler.php`, which reuses
`PRAutoBlogger_Gen_History_Query::get_run_io()` from M4 (no new DB queries/schema).
Poll interval and published window are settings-backed, localized into board.js, never
hardcoded.

View File

@@ -5,6 +5,54 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.27.0] - 2026-06-23
### Fixed (QA P1/P2 sweep — post-3cd2843)
- **P1-C (regression):** Restored `get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT )`
in both `get_in_review_cards()` and `get_published_cards()` — the M5 commit had hard-coded
`posts_per_page => 20`, silently bypassing the board column-limit admin setting.
- **P1-D (test):** Removed invalid `Functions\when( 'PRAutoBlogger_Run_Stage_State::is_available' )` stub
from `BoardInspectorHandlerTest.php` line 99. `Brain\Monkey\Functions\when()` only intercepts
global functions; the static-method stub was a silent no-op violating the repo rule against
`Functions\when()` on `Class::method`.
- **P1-A (docs):** Added `class-board-stage-dots.php` (admin/) and `class-board-inspector-handler.php`
(ajax/) to the ARCHITECTURE.md file tree.
- **P1-B (docs):** Added Mission Brief domain vocabulary (Mission Brief, inspector rail, dot-rail,
run_stages_summary) to CONTEXT.md.
- **P2-A:** Deleted dead `assets/js/board-generate.js` (no longer enqueued; button gone in M5).
- **P2-B:** Added `return;` after `wp_send_json_error()` in
`class-board-inspector-handler.php` handle() for explicit early-exit consistency.
### Added
- **Pipeline Board M5 -- Mission Brief (board redesign, CEO-selected Direction C):**
- **Vertical run list:** replaces the four-column kanban with a status-grouped vertical
list (Generating | In review | Published | Failed). Each row shows a verdict-style
status chip, article title, current stage, cost (IBM Plex Mono), and elapsed time.
Stalled/failed rows have a red left-border accent and are never softened to grey.
- **Persistent right-rail inspector:** selecting any run row fetches full per-stage
I/O via new `PRAutoBlogger_Board_Inspector_Handler` (AJAX) and populates the rail
without navigation. Inspector shows: stage breakdown with status dots, model,
per-stage cost, expandable prompt/response text (textContent -- XSS-safe), total
cost receipt, and an "Open dossier" CTA.
- **Reuses M4 data layer:** `PRAutoBlogger_Gen_History_Query::get_run_io()` and
`get_run_meta()` power the inspector -- no new DB queries or schema changes.
- **Stage-display-map driven:** stage labels resolve via `Stage_Display_Map::label()`;
Phase 2b stages (curate/seo) appear automatically.
- **New Article action** preserved (top-right of board heading, links to Ideas Browser).
- All existing board capabilities preserved: per-run dossier deep-link, status counts,
live AJAX polling with backoff, published-window filter, poll-interval setting,
human-modified badge, error banner on poll failure, empty state per section.
- **Security:** inspector AJAX uses `prautoblogger_board` nonce (same page as poller),
`manage_options` cap; all output `esc_html`'d before JSON; JS renders prompt/response
via `textContent`, never `innerHTML`. API key never exposed (architecture contract).
## [0.26.0] - 2026-06-23
### Added

View File

@@ -146,3 +146,14 @@ Full table names are defined in `class-activator.php`.
| **output** | The LLM's raw response text from `run_stages.meta_json.output`. Null when the stage is log-only (image_a, image_b, llm_research, image_prompt_rewrite — no run_stages row). |
| **output_pruned** | True when `run_stages.meta_json` exists but has no `output` key — indicating the output was pruned by the `prautoblogger_request_json_retention_days` setting. The UI shows an explicit message instead of a blank field. |
| **log-only stage** | A generation_log stage with no corresponding run_stages row. Image stages (image_a, image_b, image_prompt_rewrite) and llm_research fall here. Their output is null (not pruned) in the drill-down. |
---
## Pipeline Board M5 — Mission Brief (v0.27.0)
| Term | Definition |
|------|-----------|
| **Mission Brief** | The new board layout (CEO-selected Direction C, v0.27.0). Replaces the four-column kanban with a status-grouped vertical run list (Generating / In Review / Published / Failed). Each section is independently expandable; the board name reflects the editorial-command framing of the design. Class: `PRAutoBlogger_Board_Page`. |
| **inspector rail** | The persistent right-hand panel that opens when a run row is clicked. Fetches per-stage I/O via AJAX (`prautoblogger_board_inspector`) and renders the full stage breakdown — status dots, model, cost, expandable prompt/response text, total cost receipt, and an "Open dossier" CTA — without navigating away from the board. Class: `PRAutoBlogger_Board_Inspector_Handler`. |
| **dot-rail** | The lightweight row-level stage-progress indicator in the Mission Brief run list. Each stage in the run is represented by a small dot coloured by its status (e.g. complete = teal, failed = red). Populated by `PRAutoBlogger_Board_Stage_Dots::enrich()`, which adds a `run_stages_summary` key to each card. |
| **run_stages_summary** | A card-level enrichment key added by `PRAutoBlogger_Board_Stage_Dots::enrich()`. Array of `{stage, status}` objects for each stage in the run, sourced from the `wp_prautoblogger_run_stages` table in a single batched query per board section. Consumed by `board.js` to render the dot-rail. |

View File

@@ -10,7 +10,7 @@ Maintained per DoD v1.1.0 (same-PR update rule).
| Slug | Class | Template | Purpose |
|------|-------|----------|---------|
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings: API Keys, Schedule & Budget, Publishing, Analytics, Display, Images. AI Models / Content / Sources retired to Pipeline Settings in M2 (v0.24.0). |
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Mission Brief board (M5): vertical run-list + right-rail inspector. Status sections: Generating / In review / Published / Failed. New Article button. Stage dot-rail. Inspector: per-stage I/O, cost receipt, dossier link (v0.21.0, v0.27.0). |
| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: Global Content Context (niche), step option fields, model picker, system instructions, agent prompts, params (M1 v0.23.0; M2 v0.24.0 adds editable step options, retires AI Models/Content/Sources Settings tabs). M3 v0.25.0 adds Template/Preview toggle (assembled-instructions preview from last-run gen_log), version history accordion, and inline diff panel. Three new AJAX endpoints: `prautoblogger_pipeline_preview`, `prautoblogger_pipeline_history`, `prautoblogger_pipeline_diff` (all `manage_options` + nonce gated). |
| `prautoblogger-gen-history` | `PRAutoBlogger_Gen_History_Page` | `gen-history-page.php` | M4: Paginated list of all generation runs — title, date, status, models, cost, duration; Stage I/O toggle for inline per-step input/output drill-down (AJAX). Hidden options.php-parent page. `manage_options` + `prautoblogger_gen_run_io` nonce. |
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3). Preferred per-step I/O surface for runs with a linked post. |
@@ -65,7 +65,7 @@ DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.
| Option | Type | Default | Purpose |
|--------|------|---------|---------|
| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
| `prautoblogger_board_column_limit` | int | 20 | Max runs per board section |
| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |

View File

@@ -1,249 +1,550 @@
/**
* PRAutoBlogger — Kanban Board Styles
* PRAutoBlogger — Mission Brief Board Styles (M5, v0.27.0)
*
* Warm editorial style (Proposal C / Direction C).
* Inside wp-admin chrome; uses WP admin vars where available.
* No build step — plain CSS, no preprocessor.
* Replaces the four-column kanban with a vertical run-list + right-rail
* inspector layout (Option C / "Mission Brief").
*
* Palette:
* Teal #1B8A92 (brand primary — Generating pill, spinner)
* Cream #FAF6EE (card background)
* Ink #0D0D0D (primary text)
* Orange #FF8A3D (In Review pill)
* Lime #7FD600 (Published pill — success)
* Red #D94F4F (Failed pill)
* Mid-grey #8A8A8A (meta text, border)
* Design language (locked by CMO consult 2026-06-20):
* Surface: #FFFFFF (bg-page / bg-surface)
* Inset: #F5F5F5 (bg-inset — sections, inspector bg)
* Primary: #1B8A92 (teal-500 — Generating chip, focus ring, CTA)
* Hover: #147178 (teal-600)
* Lime: #7FD600 (lime-500 — Published chip)
* Orange: #FF8A3D (orange-500 — In Review chip)
* Error: #C0392B (cautionary — Failed chip, failed row border)
* Text: #0D0D0D / #3D3D3D / #808080
* Border: #E8E8E8
*
* @see templates/admin/board-page.php — Rendered HTML structure.
* @see assets/js/board.js — DOM updates that match these selectors.
* Fonts: Poppins (display), Inter (body/UI), IBM Plex Mono (all numerics).
*
* No dark mode. No glassmorphism. No glow effects.
* Namespace: .prab-board-wrap and .prab-* selectors only.
*
* @see templates/admin/board-page.php -- Rendered HTML structure.
* @see assets/js/board.js -- DOM updates that match these selectors.
*/
/* ── Google Fonts load (same subset as theme — no second copy if already loaded) */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@600;700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;600&display=swap');
/* ── Page wrapper ──────────────────────────────────────────────────────── */
.prab-board-wrap {
padding: 0;
}
/* ── Top bar: heading + New Article CTA ───────────────────────────────── */
.prab-board-topbar {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 18px 20px 12px;
border-bottom: 1px solid #E8E8E8;
}
.prab-board-heading {
font-size: 1.4rem;
font-weight: 700;
color: #0d0d0d;
margin: 20px 0 18px 0;
padding: 0 20px;
font-family: 'Poppins', sans-serif;
font-size: 20px;
font-weight: 600;
color: #0D0D0D;
margin: 0;
display: flex;
align-items: baseline;
gap: 10px;
}
.prab-board-heading-sub {
font-size: 1rem;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 400;
color: #8a8a8a;
color: #808080;
}
/* ── Four-column board grid ────────────────────────────────────────────── */
.prab-board {
.prab-board-new-article-btn {
display: inline-flex;
align-items: center;
padding: 7px 16px;
background: #1B8A92;
color: #FFFFFF;
border-radius: 8px;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s ease;
}
.prab-board-new-article-btn:hover,
.prab-board-new-article-btn:focus {
background: #147178;
color: #FFFFFF;
outline: 2px solid #1B8A92;
outline-offset: 2px;
}
/* ── Two-pane Mission Brief layout ────────────────────────────────────── */
.prab-board--mission-brief {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 0 20px 24px;
grid-template-columns: 60% 40%;
align-items: start;
min-height: 400px;
}
@media (max-width: 1200px) {
.prab-board {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 720px) {
.prab-board {
@media (max-width: 900px) {
.prab-board--mission-brief {
grid-template-columns: 1fr;
}
}
/* ── Column ────────────────────────────────────────────────────────────── */
.prab-board-column {
background: #f8f7f5;
border: 1px solid #e2ddd6;
border-radius: 8px;
min-height: 180px;
/* ── Run list (left pane) ─────────────────────────────────────────────── */
.prab-run-list {
border-right: 1px solid #E8E8E8;
min-height: 400px;
padding-bottom: 24px;
}
.prab-col-header {
/* ── Section ──────────────────────────────────────────────────────────── */
.prab-section {
border-bottom: 1px solid #E8E8E8;
}
.prab-section:last-child {
border-bottom: none;
}
.prab-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 10px;
border-bottom: 1px solid #e2ddd6;
padding: 10px 16px 8px;
background: #F5F5F5;
border-bottom: 1px solid #E8E8E8;
}
.prab-col-title {
font-size: 0.82rem;
.prab-section-label {
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #4a4540;
letter-spacing: 0.08em;
color: #3D3D3D;
}
.prab-col-cards {
padding: 10px 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
.prab-section-rows {
padding: 4px 0;
}
.prab-col-empty {
font-size: 0.82rem;
color: #b0a898;
text-align: center;
padding: 20px 0;
.prab-section-empty {
font-family: 'Inter', sans-serif;
font-size: 13px;
color: #808080;
padding: 14px 16px;
margin: 0;
}
/* ── Status pills ──────────────────────────────────────────────────────── */
.prab-pill {
/* ── Verdict-style status chips ───────────────────────────────────────── */
.prab-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 12px;
font-size: 0.75rem;
padding: 2px 10px;
border-radius: 999px;
font-family: 'Inter', sans-serif;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
color: #fff;
min-width: 24px;
line-height: 1.6;
white-space: nowrap;
}
.prab-pill--generating {
background: #1b8a92;
.prab-chip--generating {
background: #1B8A92;
color: #FFFFFF;
}
.prab-pill--review {
background: #ff8a3d;
.prab-chip--in_review {
background: #FF8A3D;
color: #0D0D0D;
}
.prab-pill--published {
background: #5a9e28;
.prab-chip--published {
background: #7FD600;
color: #0D0D0D;
}
.prab-pill--failed {
background: #d94f4f;
.prab-chip--failed {
background: #C0392B;
color: #FFFFFF;
}
/* ── Card ──────────────────────────────────────────────────────────────── */
.prab-board-card {
background: #faf6ee;
border: 1px solid #e0d8cc;
border-radius: 6px;
padding: 11px 12px 10px;
position: relative;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
.prab-chip--human {
background: #1B8A92;
color: #FFFFFF;
font-size: 9px;
padding: 1px 6px;
margin-left: 4px;
}
.prab-board-card:hover {
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
border-color: #b8cfd2;
/* ── Run row ──────────────────────────────────────────────────────────── */
.prab-run-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 16px;
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.1s ease, border-color 0.1s ease;
background: #FFFFFF;
}
.prab-board-card--generating {
border-left: 3px solid #1b8a92;
.prab-run-row:hover,
.prab-run-row:focus {
background: #F5F5F5;
outline: none;
}
.prab-card-title {
font-size: 0.875rem;
.prab-run-row.is-selected {
background: #F0F9FA;
border-left-color: #1B8A92;
}
.prab-run-row--failed {
border-left-color: #C0392B;
}
.prab-run-row--failed.is-selected {
border-left-color: #C0392B;
background: #FFF5F5;
}
/* Chip column */
.prab-row-chip-wrap {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Title + stage label column */
.prab-row-main {
overflow: hidden;
}
.prab-row-title {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 500;
color: #0d0d0d;
line-height: 1.4;
margin-bottom: 6px;
padding-right: 20px; /* room for spinner */
color: #0D0D0D;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prab-card-meta {
.prab-run-row--failed .prab-row-title {
color: #C0392B;
}
.prab-row-stage-label {
font-family: 'Inter', sans-serif;
font-size: 11px;
color: #808080;
margin-top: 2px;
}
.prab-row-error {
font-family: 'Inter', sans-serif;
font-size: 11px;
color: #C0392B;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Cost + elapsed column */
.prab-row-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.prab-row-cost,
.prab-row-elapsed {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #3D3D3D;
white-space: nowrap;
}
.prab-row-elapsed {
color: #808080;
}
/* ── Inspector rail (right pane) ─────────────────────────────────────── */
.prab-inspector {
background: #F5F5F5;
min-height: 400px;
position: sticky;
top: 32px; /* WP admin bar height */
max-height: calc(100vh - 60px);
overflow-y: auto;
}
.prab-inspector-inner {
padding: 20px;
}
.prab-inspector-placeholder {
font-family: 'Inter', sans-serif;
font-size: 13px;
color: #808080;
text-align: center;
padding: 40px 20px;
margin: 0;
}
/* Inspector header */
.prab-inspector-title {
font-family: 'Poppins', sans-serif;
font-size: 16px;
font-weight: 600;
color: #0D0D0D;
margin: 10px 0 4px;
word-break: break-word;
}
.prab-inspector-chip-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.prab-card-cost {
font-size: 0.75rem;
color: #5a8a50;
font-weight: 600;
font-variant-numeric: tabular-nums;
/* Inspector stage breakdown */
.prab-inspector-stages {
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.prab-card-stage {
font-size: 0.74rem;
color: #1b8a92;
font-weight: 500;
margin-bottom: 5px;
.prab-inspector-stage {
display: grid;
grid-template-columns: 12px 1fr auto;
align-items: start;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #E8E8E8;
}
.prab-card-error {
font-size: 0.74rem;
color: #d94f4f;
margin-bottom: 5px;
font-style: italic;
.prab-inspector-stage:last-child {
border-bottom: none;
}
.prab-card-actions {
margin-top: 8px;
}
.prab-card-link {
display: inline-block;
font-size: 0.75rem;
color: #1b8a92;
text-decoration: none;
font-weight: 500;
border: 1px solid #b8d8db;
border-radius: 4px;
padding: 2px 8px;
transition: background 0.1s ease;
}
.prab-card-link:hover {
background: #e8f4f5;
color: #136870;
border-color: #8ac4c9;
}
/* ── Generating spinner ────────────────────────────────────────────────── */
.prab-card-spinner {
position: absolute;
top: 11px;
right: 11px;
width: 14px;
height: 14px;
border: 2px solid #c8e6e8;
border-top-color: #1b8a92;
.prab-stage-dot {
width: 10px;
height: 10px;
border-radius: 50%;
animation: prab-spin 0.9s linear infinite;
margin-top: 3px;
flex-shrink: 0;
background: #D0D0D0;
}
@keyframes prab-spin {
to { transform: rotate(360deg); }
.prab-stage-dot--done { background: #1B8A92; }
.prab-stage-dot--active { background: #1B8A92; animation: prab-pulse 2s ease-in-out infinite; }
.prab-stage-dot--failed { background: #C0392B; }
.prab-stage-dot--pending { background: #D0D0D0; }
@keyframes prab-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Board footer ──────────────────────────────────────────────────────── */
.prab-board-footer {
padding: 0 20px 16px;
font-size: 0.78rem;
color: #a0998f;
.prab-stage-info {
overflow: hidden;
}
.prab-stage-name {
font-family: 'Inter', sans-serif;
font-size: 12px;
font-weight: 600;
color: #3D3D3D;
}
.prab-stage-model {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #808080;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prab-stage-error-msg {
font-family: 'Inter', sans-serif;
font-size: 11px;
color: #C0392B;
margin-top: 2px;
}
.prab-stage-io-toggle {
font-family: 'Inter', sans-serif;
font-size: 10px;
color: #1B8A92;
background: none;
border: none;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.prab-stage-io {
grid-column: 2 / 4;
margin-top: 6px;
background: #FFFFFF;
border: 1px solid #E8E8E8;
border-radius: 4px;
padding: 8px;
}
.prab-stage-io-label {
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #808080;
margin-bottom: 4px;
}
.prab-stage-io-text {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: #0D0D0D;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.prab-stage-cost {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: #3D3D3D;
flex-shrink: 0;
align-self: start;
padding-top: 3px;
}
/* Inspector receipt */
.prab-inspector-receipt {
background: #FFFFFF;
border: 1px solid #E8E8E8;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
}
.prab-receipt-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-family: 'Inter', sans-serif;
font-size: 13px;
color: #3D3D3D;
}
.prab-receipt-label {
color: #808080;
}
.prab-receipt-value {
font-family: 'IBM Plex Mono', monospace;
font-weight: 600;
color: #0D0D0D;
}
/* Inspector CTA buttons */
.prab-inspector-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 4px;
}
.prab-inspector-dossier-btn {
display: inline-flex;
align-items: center;
padding: 7px 16px;
background: #1B8A92;
color: #FFFFFF;
border-radius: 8px;
font-family: 'Inter', sans-serif;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s ease;
}
.prab-inspector-dossier-btn:hover,
.prab-inspector-dossier-btn:focus {
background: #147178;
color: #FFFFFF;
}
/* ── Poll-error banner ─────────────────────────────────────────────────── */
.prab-board-error {
background: #fff4f4;
border: 1px solid #f5c4c4;
color: #8b2020;
background: #FFF4F4;
border: 1px solid #F5C4C4;
color: #8B2020;
border-radius: 6px;
padding: 8px 14px;
margin: 0 20px 12px;
font-size: 0.82rem;
padding: 8px 16px;
margin: 8px 20px;
font-family: 'Inter', sans-serif;
font-size: 13px;
display: none;
}
.prab-board-error.is-visible {
display: block;
}
/* ── Board footer ──────────────────────────────────────────────────────── */
.prab-board-footer {
padding: 10px 20px 16px;
font-family: 'Inter', sans-serif;
font-size: 12px;
color: #808080;
border-top: 1px solid #E8E8E8;
}
/* ── Inspector loading / error states ─────────────────────────────────── */
.prab-inspector-loading,
.prab-inspector-error {
font-family: 'Inter', sans-serif;
font-size: 13px;
color: #808080;
text-align: center;
padding: 30px 16px;
margin: 0;
}
.prab-inspector-error {
color: #C0392B;
}
/* ── Narrow viewport: inspector becomes full-width below list ─────────── */
@media (max-width: 900px) {
.prab-run-list {
border-right: none;
border-bottom: 1px solid #E8E8E8;
}
.prab-inspector {
position: static;
max-height: none;
}
}

View File

@@ -1,53 +0,0 @@
/**
* PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
*
* Loaded on the board page alongside board.js. Depends on the prabBoard config
* object and jQuery, both already present when board.js loads.
*
* @see assets/js/board.js -- Board poller (loads first).
* @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
*/
( function ( $ ) {
'use strict';
var cfg = window.prabBoard || {};
/**
* Handle click on the "New Article" button. Fires the existing generate_now
* AJAX action and triggers board backoff reset so the new card appears fast.
*/
function onBoardGenerateNow() {
var $btn = $( '#prab-board-generate-now' );
if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
return;
}
$btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
var $status = $( '#prab-board-gen-status' );
$status.text( '' ).hide();
$.ajax( {
url: cfg.ajaxUrl,
method: 'POST',
data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
success: function ( response ) {
if ( response && response.success ) {
$status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
// Notify board poller to reset backoff (run is now active).
$( document ).trigger( 'prab:generation-started' );
} else {
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
}
},
error: function () {
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
}
} );
}
$( function () {
$( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
} );
}( jQuery ) );

View File

@@ -1,29 +1,26 @@
/**
* PRAutoBlogger — Kanban Board Poller
* PRAutoBlogger — Mission Brief Board (M5, v0.27.0)
*
* Live-polls the board status AJAX endpoint and updates the four columns
* (Generating | In Review | Published | Failed) in-place without a full
* page reload.
* Replaces the four-column kanban with a vertical run-list + right-rail
* inspector layout. Responsibilities:
*
* Poll behaviour:
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
* at 4× the base interval — prevents needless AJAX on an idle board.
* - Resets to base interval as soon as an active run is detected again.
* 1. AJAX polling (prautoblogger_board_status) — same backoff logic as M4.
* Updates the four status sections in-place without flicker.
* 2. Row-click inspector — fetches per-run stage I/O via
* prautoblogger_board_inspector and renders the right rail.
* 3. Stage I/O expand — toggles per-stage prompt/response text inline
* inside the inspector.
*
* Click-throughs (M1):
* - Generating → Activity Log
* - In Review → Review Queue
* - Published → Post edit screen
* - Failed → Activity Log
* (M2 rewires all to Article Dossier)
* Security: All server data rendered via textContent (never innerHTML for
* prompt/response text). escHtml() guards any dynamic HTML concatenation.
*
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval,
* action, inspectorAction, strings) injected by Board_Page::on_enqueue_assets().
*
* @see includes/admin/class-board-page.php — AJAX handler + localise.
* @see assets/css/board.css — Card and column styles.
* @see templates/admin/board-page.php — Initial server-rendered HTML.
* @see includes/admin/class-board-page.php -- AJAX handler + localize.
* @see includes/ajax/class-board-inspector-handler.php -- Inspector AJAX.
* @see assets/css/board.css -- Styles.
* @see templates/admin/board-page.php -- Server-rendered HTML.
*/
( function ( $ ) {
'use strict';
@@ -35,8 +32,10 @@
var pollTimer = null;
var $board = null;
var $errorBanner = null;
var selectedRunId = null;
var inspFetching = false;
/** Escape HTML for safe DOM insertion. */
/** Escape HTML for safe DOM insertion via innerHTML. */
function escHtml( str ) {
return $( '<div>' ).text( String( str ) ).html();
}
@@ -47,136 +46,369 @@
return '$' + n.toFixed( 4 );
}
/** Elapsed seconds → human label. */
function formatElapsed( startedAt ) {
if ( ! startedAt ) { return ''; }
var diff = Math.max( 0, Math.floor( Date.now() / 1000 ) - parseInt( startedAt, 10 ) );
if ( diff < 60 ) { return diff + 's'; }
if ( diff < 3600 ) { return Math.floor( diff / 60 ) + 'm'; }
return Math.floor( diff / 3600 ) + 'h';
}
/**
* Build a card HTML string from a card data object.
* Map a section key to chip CSS modifier + label.
*/
function sectionMeta( key ) {
var map = {
generating: { mod: 'generating', label: cfg.strings.generating || 'Generating' },
in_review: { mod: 'in_review', label: cfg.strings.inReview || 'In review' },
published: { mod: 'published', label: cfg.strings.published || 'Published' },
failed: { mod: 'failed', label: cfg.strings.failed || 'Failed' },
};
return map[ key ] || { mod: key, label: key };
}
/**
* Build one run-row HTML string from card data.
*
* @param {Object} card Card data from server.
* @param {string} columnKey Column identifier.
* @param {string} sectionKey Section identifier.
* @returns {string} HTML string.
*/
function buildCardHtml( card, columnKey ) {
var isGenerating = ( columnKey === 'generating' );
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
var html = '';
function buildRowHtml( card, sectionKey ) {
var meta = sectionMeta( sectionKey );
var isFailed = ( 'failed' === sectionKey );
var rowClass = 'prab-run-row' + ( isFailed ? ' prab-run-row--failed' : '' );
var title = card.title || cfg.strings.empty || '';
var cost = card.cost_total ? formatCost( card.cost_total ) : '';
var elapsed = formatElapsed( card.started_at || card.created_at || 0 );
var runId = card.run_id || '';
var postId = card.post_id || 0;
html += '<div class="' + escHtml( cls ) + '"';
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
var html = '<div class="' + escHtml( rowClass ) + '"';
html += ' role="listitem" tabindex="0"';
html += ' data-run-id="' + escHtml( runId ) + '"';
html += ' data-post-id="' + escHtml( postId ) + '"';
html += ' aria-label="' + escHtml( title ) + '">';
if ( isGenerating ) {
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
}
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
// v0.20.0: human-modified runs are visually distinct at run-list level.
// Chip column.
html += '<div class="prab-row-chip-wrap">';
html += '<span class="prab-chip prab-chip--' + escHtml( meta.mod ) + '">';
html += escHtml( meta.label );
html += '</span>';
if ( card.human_modified ) {
html += '<span class="prab-card-human-chip">' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '</span>';
html += '<span class="prab-chip prab-chip--human" title="' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + '">H</span>';
}
if ( card.cost_total ) {
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
html += escHtml( formatCost( card.cost_total ) );
html += '</span></div>';
}
if ( isGenerating && card.stage_current ) {
html += '<div class="prab-card-stage">';
/* translators: stage name */
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
html += '</div>';
}
// Main column.
html += '<div class="prab-row-main">';
html += '<div class="prab-row-title"></div>'; // textContent-set below
if ( card.stage_current ) {
html += '<div class="prab-row-stage-label"></div>'; // textContent-set below
}
if ( card.error_message ) {
html += '<div class="prab-row-error"></div>';
}
html += '</div>';
// Meta column.
html += '<div class="prab-row-meta">';
if ( cost ) {
html += '<span class="prab-row-cost"></span>'; // textContent-set below
}
if ( elapsed ) {
html += '<span class="prab-row-elapsed"></span>';
}
html += '</div>';
html += '</div>';
// Use a detached jQuery element to set text safely (avoids any XSS
// even if card.title contained markup).
var $row = $( html );
$row.find( '.prab-row-title' ).text( title );
if ( card.stage_current ) {
$row.find( '.prab-row-stage-label' ).text( card.stage_current );
}
if ( card.error_message ) {
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
if ( card.error_message.split( ' ' ).length > 12 ) {
errShort += '…';
}
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
if ( String( card.error_message ).split( ' ' ).length > 12 ) { errShort += '…'; }
$row.find( '.prab-row-error' ).text( errShort );
}
if ( cost ) { $row.find( '.prab-row-cost' ).text( cost ); }
if ( elapsed ) { $row.find( '.prab-row-elapsed' ).text( elapsed ); }
// Click-through link.
var linkUrl = '';
var linkText = '';
if ( card.click_action === 'dossier' && card.dossier_url ) {
linkUrl = card.dossier_url;
linkText = cfg.strings.view;
} else if ( card.click_action === 'review' && card.review_url ) {
linkUrl = card.review_url;
linkText = cfg.strings.view;
} else if ( card.click_action === 'edit' && card.edit_url ) {
linkUrl = card.edit_url;
linkText = cfg.strings.edit;
} else if ( card.click_action === 'logs' && card.log_url ) {
linkUrl = card.log_url;
linkText = cfg.strings.viewLog;
}
if ( linkUrl ) {
html += '<div class="prab-card-actions">';
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
html += escHtml( linkText );
html += '</a></div>';
}
html += '</div>';
return html;
return $( '<div>' ).append( $row ).html();
}
/**
* Re-render a single column from the server snapshot.
* Re-render one status section from server snapshot.
*
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
* to prevent flicker on the running spinner.
*
* @param {string} colKey Column identifier.
* @param {Array} cards Array of card objects.
* @param {string} secKey Section identifier.
* @param {Array} cards Card objects for this section.
* @returns {void}
*/
function renderColumn( colKey, cards ) {
var $col = $board.find( '[data-column="' + colKey + '"]' );
var $cards = $col.find( '[data-column-cards]' );
var $pill = $col.find( '.prab-col-count' );
function renderSection( secKey, cards ) {
var $sec = $board.find( '[data-section="' + secKey + '"]' );
var $rows = $sec.find( '[data-section-rows]' );
var $count = $sec.find( '.prab-section-count' );
$pill.text( cards.length );
$count.text( cards.length );
if ( ! cards.length ) {
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
$rows.html( '<p class="prab-section-empty">' + escHtml( cfg.strings.empty || '' ) + '</p>' );
return;
}
// Build new card HTML.
var newHtml = '';
var html = '';
for ( var i = 0; i < cards.length; i++ ) {
newHtml += buildCardHtml( cards[ i ], colKey );
html += buildRowHtml( cards[ i ], secKey );
}
$cards.html( newHtml );
$rows.html( html );
// Re-highlight selected row if it reappears after poll.
if ( selectedRunId ) {
$rows.find( '[data-run-id="' + selectedRunId + '"]' ).addClass( 'is-selected' );
}
// Re-bind row-click for freshly rendered rows.
bindRowClicks( $rows );
}
/**
* Apply a server snapshot to all four columns.
* Apply snapshot to all four sections.
*
* @param {Object} snapshot Board snapshot from AJAX.
* @returns {void}
*/
function applySnapshot( snapshot ) {
renderColumn( 'generating', snapshot.generating || [] );
renderColumn( 'in_review', snapshot.in_review || [] );
renderColumn( 'published', snapshot.published || [] );
renderColumn( 'failed', snapshot.failed || [] );
// Re-enable board Generate button once no active runs remain.
if ( ! snapshot.has_active_runs ) {
$( '#prab-board-generate-now' ).prop( 'disabled', false )
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
renderSection( 'generating', snapshot.generating || [] );
renderSection( 'in_review', snapshot.in_review || [] );
renderSection( 'published', snapshot.published || [] );
renderSection( 'failed', snapshot.failed || [] );
}
// ── Inspector rail ────────────────────────────────────────────────────
/**
* Render the inspector rail with a loaded run's data.
*
* @param {Object} data Response data from Board_Inspector_Handler.
* @returns {void}
*/
function renderInspector( data ) {
var run = data.run || {};
var stages = data.stages || [];
var total = data.cost_total || 0;
var $inner = $( '#prab-inspector-inner' );
$inner.empty();
// Title + chip.
var titleText = run.post_title || run.run_id || '';
var $title = $( '<div class="prab-inspector-title"></div>' ).text( titleText );
$inner.append( $title );
var statusMod = run.status || 'generating';
var statusMap = {
running: 'generating',
complete: 'published',
error: 'failed',
draft: 'in_review',
};
var chipMod = statusMap[ statusMod ] || statusMod;
var chipLabel = sectionMeta( chipMod ).label;
var $chipRow = $( '<div class="prab-inspector-chip-row"></div>' );
$chipRow.append( $( '<span class="prab-chip prab-chip--' + escHtml( chipMod ) + '"></span>' ).text( chipLabel ) );
$inner.append( $chipRow );
// Stage breakdown.
var $stageList = $( '<ul class="prab-inspector-stages"></ul>' );
for ( var i = 0; i < stages.length; i++ ) {
$stageList.append( buildStageItem( stages[ i ] ) );
}
$inner.append( $stageList );
// Total cost receipt.
var $receipt = $( '<div class="prab-inspector-receipt"></div>' );
var $receiptRow = $( '<div class="prab-receipt-row"></div>' );
$receiptRow.append( $( '<span class="prab-receipt-label"></span>' ).text( cfg.strings.totalCost || 'Total cost' ) );
$receiptRow.append( $( '<span class="prab-receipt-value"></span>' ).text( formatCost( total ) ) );
$receipt.append( $receiptRow );
$inner.append( $receipt );
// Dossier link.
if ( run.dossier_url ) {
var $actions = $( '<div class="prab-inspector-actions"></div>' );
var $btn = $( '<a class="prab-inspector-dossier-btn"></a>' );
$btn.attr( 'href', run.dossier_url );
$btn.text( cfg.strings.openDossier || 'Open dossier →' );
$actions.append( $btn );
$inner.append( $actions );
}
}
/**
* Compute the next poll delay based on whether there are active runs.
* Build a single inspector stage list-item jQuery element.
*
* @param {boolean} hasActiveRuns
* @returns {number} Milliseconds until next poll.
* @param {Object} stage Stage object from inspector AJAX response.
* @returns {jQuery}
*/
function buildStageItem( stage ) {
var status = stage.response_status === 'error' ? 'failed' : 'done';
var dotClass = 'prab-stage-dot prab-stage-dot--' + status;
var cost = stage.estimated_cost ? formatCost( stage.estimated_cost ) : '';
var $li = $( '<li class="prab-inspector-stage"></li>' );
// Status dot.
$li.append( $( '<span class="' + escHtml( dotClass ) + '"></span>' ) );
// Stage info (label + model).
var $info = $( '<div class="prab-stage-info"></div>' );
$info.append( $( '<div class="prab-stage-name"></div>' ).text( stage.label || stage.stage ) );
if ( stage.model ) {
$info.append( $( '<div class="prab-stage-model"></div>' ).text( stage.model ) );
}
if ( stage.error_message ) {
$info.append( $( '<div class="prab-stage-error-msg"></div>' ).text( stage.error_message ) );
}
// I/O toggle (only when there is input or output to show).
if ( stage.input_user || stage.output || stage.output_pruned ) {
var $toggle = $( '<button class="prab-stage-io-toggle">I/O</button>' );
$toggle.on( 'click', ( function ( s ) {
return function () {
toggleStageIO( $( this ).closest( 'li' ), s );
};
}( stage ) ) );
$info.append( $toggle );
}
$li.append( $info );
// Cost.
if ( cost ) {
$li.append( $( '<span class="prab-stage-cost"></span>' ).text( cost ) );
}
return $li;
}
/**
* Toggle stage I/O panel inside an inspector stage item.
*
* Text is set via textContent (jQuery .text()) — structurally safe.
*
* @param {jQuery} $li Stage list item element.
* @param {Object} stage Stage data with input_system/input_user/output.
* @returns {void}
*/
function toggleStageIO( $li, stage ) {
var $existing = $li.find( '.prab-stage-io' );
if ( $existing.length ) {
$existing.remove();
return;
}
var $io = $( '<div class="prab-stage-io"></div>' );
if ( stage.input_user ) {
$io.append( $( '<div class="prab-stage-io-label"></div>' ).text( cfg.strings.input || 'Input' ) );
$io.append( $( '<pre class="prab-stage-io-text"></pre>' ).text( stage.input_user ) );
}
if ( stage.output ) {
$io.append( $( '<div class="prab-stage-io-label"></div>' ).text( cfg.strings.output || 'Output' ) );
$io.append( $( '<pre class="prab-stage-io-text"></pre>' ).text( stage.output ) );
} else if ( stage.output_pruned ) {
$io.append( $( '<div class="prab-stage-io-label"></div>' ).text( cfg.strings.output || 'Output' ) );
$io.append( $( '<p class="prab-stage-io-text"></p>' ).text( cfg.strings.pruned || '[pruned]' ) );
} else if ( stage.input_user ) {
// Has input but no output — log-only stage or still running.
$io.append( $( '<div class="prab-stage-io-label"></div>' ).text( cfg.strings.output || 'Output' ) );
$io.append( $( '<p class="prab-stage-io-text"></p>' ).text( cfg.strings.noOutput || '[no output recorded]' ) );
}
// Insert after the stage-info div.
$li.append( $io );
}
/**
* Load inspector data for a run_id via AJAX.
*
* @param {string} runId Pipeline run UUID.
* @returns {void}
*/
function loadInspector( runId ) {
if ( ! runId || inspFetching ) { return; }
inspFetching = true;
var $inner = $( '#prab-inspector-inner' );
$inner.html( '<p class="prab-inspector-loading">' + escHtml( cfg.strings.inspectorLoad || 'Loading…' ) + '</p>' );
$.ajax( {
url: cfg.ajaxUrl,
method: 'POST',
data: {
action: cfg.inspectorAction,
nonce: cfg.nonce,
run_id: runId
},
success: function ( response ) {
inspFetching = false;
if ( response && response.success && response.data ) {
renderInspector( response.data );
} else {
$inner.html( '<p class="prab-inspector-error">' + escHtml( cfg.strings.inspectorError || 'Could not load run details.' ) + '</p>' );
}
},
error: function () {
inspFetching = false;
$inner.html( '<p class="prab-inspector-error">' + escHtml( cfg.strings.inspectorError || 'Could not load run details.' ) + '</p>' );
}
} );
}
// ── Row click binding ─────────────────────────────────────────────────
/**
* Bind click + keyboard-activate on run rows within a container.
*
* @param {jQuery} $container Container to search within.
* @returns {void}
*/
function bindRowClicks( $container ) {
$container.find( '.prab-run-row' ).on( 'click keydown', function ( e ) {
if ( e.type === 'keydown' && e.which !== 13 && e.which !== 32 ) { return; }
e.preventDefault();
var $row = $( this );
var runId = $row.data( 'run-id' );
if ( ! runId ) { return; }
// Toggle off if already selected.
if ( selectedRunId === String( runId ) ) {
selectedRunId = null;
$row.removeClass( 'is-selected' );
$( '#prab-inspector-inner' ).html(
'<p class="prab-inspector-placeholder">' + escHtml( cfg.strings.inspectorEmpty || 'Select an article to preview its pipeline.' ) + '</p>'
);
return;
}
// Deselect all rows, select this one.
$board.find( '.prab-run-row' ).removeClass( 'is-selected' );
$row.addClass( 'is-selected' );
selectedRunId = String( runId );
loadInspector( selectedRunId );
} );
}
// ── Polling ───────────────────────────────────────────────────────────
/** Compute next poll delay. */
function nextDelay( hasActiveRuns ) {
if ( hasActiveRuns ) {
backoffFactor = 1;
@@ -186,22 +418,13 @@
return baseInterval * backoffFactor;
}
/**
* Schedule the next poll after a given delay.
*
* @param {number} delay Milliseconds.
* @returns {void}
*/
/** Schedule the next poll. */
function schedulePoll( delay ) {
clearTimeout( pollTimer );
pollTimer = setTimeout( doPoll, delay );
}
/**
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
*
* @returns {void}
*/
/** Execute one poll cycle. */
function doPoll() {
$.ajax( {
url: cfg.ajaxUrl,
@@ -211,9 +434,7 @@
nonce: cfg.nonce
},
success: function ( response ) {
if ( $errorBanner ) {
$errorBanner.removeClass( 'is-visible' );
}
if ( $errorBanner ) { $errorBanner.removeClass( 'is-visible' ); }
if ( response && response.success && response.data ) {
applySnapshot( response.data );
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
@@ -222,15 +443,29 @@
}
},
error: function () {
if ( $errorBanner ) {
$errorBanner.addClass( 'is-visible' );
}
if ( $errorBanner ) { $errorBanner.addClass( 'is-visible' ); }
schedulePoll( nextDelay( false ) );
}
} );
}
// ── Init ──────────────────────────────────────────────────────────────
$( function () {
$board = $( '#prab-board' );
if ( ! $board.length ) { return; }
// Error banner.
$errorBanner = $( '<div class="prab-board-error" role="alert"></div>' );
$errorBanner.text( cfg.strings.pollError || '' );
$board.before( $errorBanner );
// Bind clicks on server-rendered rows.
bindRowClicks( $board );
// Start poller.
var hasActive = ( $board.find( '[data-section="generating"] .prab-run-row' ).length > 0 );
schedulePoll( nextDelay( hasActive ) );
} );
}( jQuery ) );

View File

@@ -4,25 +4,29 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Supplies card data for the kanban board page from existing DB tables.
* Supplies run-list data for the Mission Brief board (M5).
*
* Orchestrates the four board columns:
* Orchestrates the four status sections:
* Generating | In Review | Published | Failed
*
* Generation-log queries (Generating, Failed) are delegated to
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
* are handled here.
* M5 additions (v0.27.0): each card now includes `run_stages_summary`
* -- a lightweight array of { stage, status } entries derived from the
* run_stages table, used by the board's dot-rail (stage-progress indicator).
* The inspector's full I/O is fetched separately via Board_Inspector_Handler
* on row-click (not loaded up-front for every card).
*
* M2: cards now include `dossier_url` so board.js can deep-link to the
* Article Dossier instead of the old per-column destinations.
* Generation-log queries (Generating, Failed) are delegated to
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query sections (In Review, Published)
* are handled here.
*
* No schema changes -- all data comes from tables that already exist.
*
* Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status().
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Dossier_Page,
* WordPress WP_Query, get_option().
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Board_Stage_Dots,
* PRAutoBlogger_Dossier_Page, WordPress WP_Query, get_option().
*
* @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed).
* @see admin/class-board-stage-dots.php -- Stage dot-rail enrichment (M5).
* @see admin/class-board-page.php -- Calls get_board_snapshot().
* @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links.
* @see ARCHITECTURE.md -- Database schema.
@@ -56,6 +60,12 @@ class PRAutoBlogger_Board_Data_Provider {
$published = $this->get_published_cards();
$failed = $this->gen_log_query->get_failed_cards();
// Enrich cards with lightweight stage dots.
$generating = PRAutoBlogger_Board_Stage_Dots::enrich( $generating );
$in_review = PRAutoBlogger_Board_Stage_Dots::enrich( $in_review );
$published = PRAutoBlogger_Board_Stage_Dots::enrich( $published );
$failed = PRAutoBlogger_Board_Stage_Dots::enrich( $failed );
return array(
'generating' => $generating,
'in_review' => $in_review,
@@ -85,7 +95,6 @@ class PRAutoBlogger_Board_Data_Provider {
/**
* Cards for generated draft posts awaiting human review.
* M2: cards include dossier_url for deep-linking.
*
* @return array<int, array<string, mixed>>
*/
@@ -132,7 +141,6 @@ class PRAutoBlogger_Board_Data_Provider {
/**
* Cards for published posts within the configured window.
* M2: cards include dossier_url for deep-linking.
*
* @return array<int, array<string, mixed>>
*/
@@ -187,10 +195,8 @@ class PRAutoBlogger_Board_Data_Provider {
}
/**
* Set 'human_modified' on each card in ONE batched query (v0.20.0 —
* CPO product AC: human-modified runs are visually distinct at the
* run-list level). No per-card queries; self-healing on a missing
* table/column (flag simply stays false).
* Set 'human_modified' on each card in ONE batched query (v0.20.0).
* No per-card queries; self-healing on a missing table/column.
*
* @param array<int, array<string, mixed>> $cards Column cards.
* @return array<int, array<string, mixed>> Cards with the flag set.

View File

@@ -4,27 +4,35 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Registers and renders the kanban board -- the primary landing screen for PRAutoBlogger.
* Registers and renders the Mission Brief board -- the primary landing screen.
*
* Board columns: Generating | In Review | Published | Failed.
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
* backing off to 2x when no active generating run is detected.
* M5 (v0.27.0): replaces the kanban column layout with a vertical run list
* grouped by status section (Generating | In Review | Published | Failed),
* plus a persistent right-rail inspector. Selecting a row fetches full per-stage
* I/O via PRAutoBlogger_Board_Inspector_Handler (AJAX) and populates the rail
* without navigating away. The dossier remains the deep-dive destination.
*
* Card click-throughs (M1):
* - Generating -> Activity Log filtered to run context
* - In Review -> Review Queue
* - Published -> Post edit screen
* - Failed -> Activity Log
*
* M2 will rewire all click-throughs to the Article Dossier page.
* Board capabilities preserved from M4:
* - New Article action (links to Ideas Browser).
* - Per-run dossier deep-link (click row or rail "Open dossier" button).
* - Status sections with counts.
* - Live AJAX polling with backoff (prautoblogger_board_status).
* - Published-window filter (prautoblogger_board_published_window_days).
* - Poll-interval setting (prautoblogger_board_poll_interval).
* - Human-modified badge.
* - Stalled/failed rows read RED (never softened).
* - Error banner on poll failure.
* - Empty state per section.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
* Dependencies: PRAutoBlogger_Board_Data_Provider, PRAutoBlogger_Board_Inspector_Handler,
* wp_localize_script.
*
* @see admin/class-board-data-provider.php -- Supplies card data.
* @see templates/admin/board-page.php -- HTML template.
* @see assets/js/board.js -- Polling + DOM updates.
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
* @see admin/class-board-data-provider.php -- Supplies run-list snapshot.
* @see ajax/class-board-inspector-handler.php -- Inspector AJAX (per-run stage I/O).
* @see templates/admin/board-page.php -- HTML template (Mission Brief layout).
* @see assets/js/board.js -- Polling + inspector rail JS.
* @see ARCHITECTURE.md -- §Board (M5 Mission Brief).
*/
class PRAutoBlogger_Board_Page {
@@ -33,7 +41,7 @@ class PRAutoBlogger_Board_Page {
/** AJAX action name for board polling. */
public const AJAX_ACTION = 'prautoblogger_board_status';
/** Nonce action string for board AJAX. */
/** Nonce action string for board AJAX (polling + inspector share this nonce). */
public const NONCE_ACTION = 'prautoblogger_board';
/**
@@ -48,7 +56,7 @@ class PRAutoBlogger_Board_Page {
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
* runs here, so get_plugin_page_hookname() resolves to the correct
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md Section: Board.
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board.
*
* @return void
*/
@@ -71,7 +79,6 @@ class PRAutoBlogger_Board_Page {
global $submenu;
if ( isset( $submenu['prautoblogger-settings'] ) ) {
$items = $submenu['prautoblogger-settings'];
// Find the Board entry and move it to position 0.
$board_idx = null;
foreach ( $items as $i => $item ) {
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
@@ -82,7 +89,6 @@ class PRAutoBlogger_Board_Page {
if ( null !== $board_idx && $board_idx > 0 ) {
$board_item = $items[ $board_idx ];
unset( $items[ $board_idx ] );
// Prepend board item at position 0 while preserving remaining order.
$submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) );
}
}
@@ -101,7 +107,7 @@ class PRAutoBlogger_Board_Page {
}
/**
* Enqueue board-specific JS + CSS and localize the poller config.
* Enqueue board-specific JS + CSS and localize the poller + inspector config.
*
* Called via `admin_enqueue_scripts` action, gated to this page's hook suffix.
*
@@ -128,13 +134,6 @@ class PRAutoBlogger_Board_Page {
PRAUTOBLOGGER_VERSION,
true
);
wp_enqueue_script(
'prautoblogger-board-generate',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
array( 'jquery', 'prautoblogger-board' ),
PRAUTOBLOGGER_VERSION,
true
);
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
@@ -145,33 +144,42 @@ class PRAutoBlogger_Board_Page {
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
'pollInterval' => $poll_interval * 1000,
'publishedWindowDays' => $published_days,
'action' => self::AJAX_ACTION,
'inspectorAction' => PRAutoBlogger_Board_Inspector_Handler::ACTION,
'ideasUrl' => admin_url( 'admin.php?page=prautoblogger-ideas' ),
'strings' => array(
'generating' => __( 'Generating', 'prautoblogger' ),
'inReview' => __( 'In Review', 'prautoblogger' ),
'inReview' => __( 'In review', 'prautoblogger' ),
'published' => __( 'Published', 'prautoblogger' ),
'failed' => __( 'Failed', 'prautoblogger' ),
'stalled' => __( 'Stalled', 'prautoblogger' ),
'empty' => __( 'Nothing here yet.', 'prautoblogger' ),
'cost' => __( 'Cost', 'prautoblogger' ),
'view' => __( 'View', 'prautoblogger' ),
'edit' => __( 'Edit', 'prautoblogger' ),
'viewLog' => __( 'View Log', 'prautoblogger' ),
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
'viewLog' => __( 'View log', 'prautoblogger' ),
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
'newArticle' => __( 'New Article', 'prautoblogger' ),
'generatingBtn' => __( 'Generating...', 'prautoblogger' ),
'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
'inspectorEmpty' => __( 'Select an article to preview its pipeline.', 'prautoblogger' ),
'inspectorLoad' => __( 'Loading…', 'prautoblogger' ),
'inspectorError' => __( 'Could not load run details.', 'prautoblogger' ),
'openDossier' => __( 'Open dossier →', 'prautoblogger' ),
'newArticle' => __( 'New article', 'prautoblogger' ),
'input' => __( 'Input', 'prautoblogger' ),
'output' => __( 'Output', 'prautoblogger' ),
'pruned' => __( '[pruned — past retention window]', 'prautoblogger' ),
'noOutput' => __( '[no output recorded]', 'prautoblogger' ),
'totalCost' => __( 'Total cost', 'prautoblogger' ),
'tokens' => __( 'tok', 'prautoblogger' ),
),
)
);
}
/**
* AJAX handler: return the current board snapshot (all four columns).
* AJAX handler: return the current board snapshot (all four sections).
*
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
*

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Enriches board cards with lightweight run_stages dot-rail data (M5).
*
* Extracted from PRAutoBlogger_Board_Data_Provider to stay under the
* 300-line cap. Performs ONE batched run_stages query for all cards
* in a section, never one query per card.
*
* Each card gains `run_stages_summary`: an ordered array of
* { stage, status } where status is one of:
* 'done' | 'active' | 'failed' | 'pending'
* Used by the board.js stage dot-rail. The inspector's full I/O is
* fetched separately on row-click via Board_Inspector_Handler.
*
* Falls back gracefully when the run_stages table is unavailable or a
* card has no run_id (generating cards from the transient path).
*
* Triggered by: PRAutoBlogger_Board_Data_Provider::get_board_snapshot().
* Dependencies: PRAutoBlogger_Run_Stage_State (table probe), WordPress $wpdb.
*
* @see admin/class-board-data-provider.php -- Calls enrich().
* @see ARCHITECTURE.md -- §Board (M5 Mission Brief).
*/
class PRAutoBlogger_Board_Stage_Dots {
/**
* Enrich a set of cards with run_stages dot-rail summaries.
*
* @param array<int, array<string, mixed>> $cards Cards to enrich.
* @return array<int, array<string, mixed>> Cards with run_stages_summary set.
*/
public static function enrich( array $cards ): array {
// Initialise empty summary on every card.
foreach ( $cards as $i => $card ) {
$cards[ $i ]['run_stages_summary'] = array();
}
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
return $cards;
}
$run_ids = array();
foreach ( $cards as $card ) {
if ( ! empty( $card['run_id'] ) ) {
$run_ids[] = (string) $card['run_id'];
}
}
if ( empty( $run_ids ) ) {
return $cards;
}
global $wpdb;
$table = PRAutoBlogger_Run_Stage_State::table_name();
$placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list.
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT run_id, stage, status FROM {$table} WHERE run_id IN ({$placeholders}) ORDER BY id ASC",
$run_ids
),
ARRAY_A
);
if ( ! is_array( $rows ) ) {
return $cards;
}
// Index by run_id.
$by_run = array();
foreach ( $rows as $row ) {
$rid = (string) ( $row['run_id'] ?? '' );
if ( '' === $rid ) {
continue;
}
$by_run[ $rid ][] = array(
'stage' => (string) ( $row['stage'] ?? '' ),
'status' => (string) ( $row['status'] ?? 'pending' ),
);
}
foreach ( $cards as $i => $card ) {
$rid = (string) ( $card['run_id'] ?? '' );
if ( '' !== $rid && isset( $by_run[ $rid ] ) ) {
$cards[ $i ]['run_stages_summary'] = $by_run[ $rid ];
}
}
return $cards;
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* AJAX handler: per-run stage inspector for the Mission Brief board (M5).
*
* What: Returns the full per-stage I/O breakdown for a given run_id,
* structured for the board's right-rail inspector. Reuses
* PRAutoBlogger_Gen_History_Query::get_run_io() (the M4 data layer)
* and PRAutoBlogger_Gen_History_Query::get_run_meta() verbatim --
* no new DB queries.
*
* The inspector surfaces prompts and responses. SECURITY is enforced here:
* - Authorization headers are NEVER logged (provider build_body() is
* captured before header injection -- see ARCHITECTURE.md §Request_Recorder).
* - manage_options capability required.
* - Nonce: 'prautoblogger_board' (shared with board AJAX, same page).
* - All string values esc_html'd before JSON output; JS renders via
* textContent, never innerHTML (structurally prevents XSS).
* - Read-only -- no writes.
*
* Triggered by: board.js on run-row click (right-rail fetch).
* Dependencies: PRAutoBlogger_Gen_History_Query, PRAutoBlogger_Board_Page,
* PRAutoBlogger_Stage_Display_Map, PRAutoBlogger_Dossier_Page.
*
* @see admin/class-board-page.php -- Registers this handler; supplies nonce.
* @see admin/class-gen-history-query.php -- Data layer (get_run_io, get_run_meta).
* @see core/class-stage-display-map.php -- Stage label resolution.
* @see assets/js/board.js -- Client trigger (row-click inspector fetch).
* @see ARCHITECTURE.md -- §Board (M5 Mission Brief).
*/
class PRAutoBlogger_Board_Inspector_Handler {
/** AJAX action identifier. */
public const ACTION = 'prautoblogger_board_inspector';
/**
* Register wp-ajax hook.
*
* @return void
*/
public static function register_hooks(): void {
add_action( 'wp_ajax_' . self::ACTION, array( __CLASS__, 'handle' ) );
}
/**
* Handle the inspector AJAX request.
*
* POST fields:
* nonce string wp_nonce for 'prautoblogger_board'.
* run_id string Pipeline run UUID.
*
* Success response JSON:
* {
* run: { run_id, status, settled_usd, started_at, post_id,
* post_title, dossier_url },
* stages: [
* { stage, label, model, agent_role, prompt_tokens,
* completion_tokens, estimated_cost, response_status,
* error_message, input_system, input_user,
* output, output_pruned }
* ],
* cost_total: float
* }
*
* @return void
*/
public static function handle(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ),
403
);
return;
}
check_ajax_referer( PRAutoBlogger_Board_Page::NONCE_ACTION, 'nonce' );
$run_id = isset( $_POST['run_id'] )
? sanitize_text_field( wp_unslash( $_POST['run_id'] ) )
: '';
if ( '' === $run_id ) {
wp_send_json_error( array( 'message' => __( 'Missing run_id.', 'prautoblogger' ) ), 400 );
}
$query = new PRAutoBlogger_Gen_History_Query();
$run_meta = $query->get_run_meta( $run_id );
if ( null === $run_meta ) {
// For runs that exist only in gen_log (no runs table row), build minimal meta.
$run_meta = array(
'run_id' => $run_id,
'status' => 'unknown',
'settled_usd' => 0,
'started_at' => '',
'finished_at' => '',
'post_id' => 0,
'post_title' => '',
);
}
$post_id = (int) ( $run_meta['post_id'] ?? 0 );
$dossier_url = $post_id > 0
? PRAutoBlogger_Dossier_Page::url_for_post( $post_id )
: '';
$run_payload = array(
'run_id' => esc_html( (string) ( $run_meta['run_id'] ?? '' ) ),
'status' => esc_html( (string) ( $run_meta['status'] ?? '' ) ),
'settled_usd' => round( (float) ( $run_meta['settled_usd'] ?? 0 ), 6 ),
'started_at' => esc_html( (string) ( $run_meta['started_at'] ?? '' ) ),
'post_id' => $post_id,
'post_title' => esc_html( (string) ( $run_meta['post_title'] ?? '' ) ),
'dossier_url' => esc_url( $dossier_url ),
);
$raw_stages = $query->get_run_io( $run_id );
$stages = array();
$cost_total = 0.0;
foreach ( $raw_stages as $stage ) {
$cost_total += (float) ( $stage['estimated_cost'] ?? 0 );
$stages[] = array(
'stage' => esc_html( (string) ( $stage['stage'] ?? '' ) ),
'label' => esc_html( PRAutoBlogger_Stage_Display_Map::label( (string) ( $stage['stage'] ?? '' ) ) ),
'model' => esc_html( (string) ( $stage['model'] ?? '' ) ),
'agent_role' => esc_html( (string) ( $stage['agent_role'] ?? '' ) ),
'prompt_tokens' => (int) ( $stage['prompt_tokens'] ?? 0 ),
'completion_tokens' => (int) ( $stage['completion_tokens'] ?? 0 ),
'estimated_cost' => round( (float) ( $stage['estimated_cost'] ?? 0 ), 6 ),
'response_status' => esc_html( (string) ( $stage['response_status'] ?? '' ) ),
'error_message' => esc_html( (string) ( $stage['error_message'] ?? '' ) ),
// JS renders via textContent -- no innerHTML XSS risk.
'input_system' => null !== $stage['input_system']
? esc_html( (string) $stage['input_system'] ) : null,
'input_user' => null !== $stage['input_user']
? esc_html( (string) $stage['input_user'] ) : null,
'output' => null !== $stage['output']
? esc_html( (string) $stage['output'] ) : null,
'output_pruned' => (bool) ( $stage['output_pruned'] ?? false ),
);
}
wp_send_json_success(
array(
'run' => $run_payload,
'stages' => $stages,
'cost_total' => round( $cost_total, 6 ),
)
);
}
}

View File

@@ -210,6 +210,9 @@ class PRAutoBlogger {
// v0.26.0 (M4): generation run I/O drill-down.
PRAutoBlogger_Gen_Run_IO_Handler::register_hooks();
// v0.27.0 (M5): board inspector rail AJAX.
PRAutoBlogger_Board_Inspector_Handler::register_hooks();
}
/**

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.26.0
* Version: 0.27.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.26.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.27.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );

View File

@@ -1,159 +1,108 @@
<?php
/**
* Kanban board template — primary landing screen for PRAutoBlogger.
* Mission Brief board template (M5) -- primary landing screen.
*
* Renders the four-column board: Generating | In Review | Published | Failed.
* Cards are populated on first load by a synchronous PHP data call, then kept
* live by the board.js poller (AJAX, settings-backed interval).
* Renders a vertical run list grouped by status section plus a persistent
* right-rail inspector. Replaces the four-column kanban layout (M1-M4).
*
* @see admin/class-board-page.php — Renders this template.
* @see admin/class-board-data-provider.php — Supplies card data.
* @see assets/js/board.js — Polling + DOM updates.
* Section order: Generating | In Review | Published | Failed.
* Within each section: newest first.
* Stalled/failed rows have a red left-border accent.
*
* The inspector rail is populated by board.js on row-click via
* PRAutoBlogger_Board_Inspector_Handler AJAX (no navigation).
*
* @see admin/class-board-page.php -- Renders this template.
* @see admin/class-board-data-provider.php -- Supplies run data.
* @see ajax/class-board-inspector-handler.php-- Inspector AJAX.
* @see assets/js/board.js -- Polling + inspector JS.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Eager load on first render so the board is not blank on arrival.
// Eager load on first render so the list is not blank on arrival.
$provider = new PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
/**
* Render a single board card as HTML.
*
* @param array<string, mixed> $card Card data from Board_Data_Provider.
* @param string $column_key Column identifier (generating|in_review|published|failed).
* @return void
*/
function prab_render_board_card( array $card, string $column_key ): void {
$title = ! empty( $card['title'] ) ? esc_html( $card['title'] ) : esc_html__( 'Untitled', 'prautoblogger' );
$cost = isset( $card['cost_total'] ) ? '$' . esc_html( number_format( (float) $card['cost_total'], 4 ) ) : '';
$click_url = '';
$link_text = '';
if ( 'review' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['review_url'] ?? '' );
$link_text = esc_html__( 'Review Queue', 'prautoblogger' );
} elseif ( 'edit' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['edit_url'] ?? '' );
$link_text = esc_html__( 'Edit Post', 'prautoblogger' );
} elseif ( 'logs' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['log_url'] ?? '' );
$link_text = esc_html__( 'View Log', 'prautoblogger' );
}
$is_generating = ( 'generating' === $column_key );
$card_class = 'prab-board-card';
if ( $is_generating ) {
$card_class .= ' prab-board-card--generating';
}
?>
<div class="<?php echo esc_attr( $card_class ); ?>"
data-run-id="<?php echo esc_attr( (string) ( $card['run_id'] ?? '' ) ); ?>"
data-post-id="<?php echo esc_attr( (string) ( $card['post_id'] ?? 0 ) ); ?>">
<?php if ( $is_generating ) : ?>
<div class="prab-card-spinner" aria-label="<?php esc_attr_e( 'Generating in progress', 'prautoblogger' ); ?>"></div>
<?php endif; ?>
<div class="prab-card-title"><?php echo $title; // Already escaped above. ?></div>
<?php if ( ! empty( $cost ) ) : ?>
<div class="prab-card-meta">
<span class="prab-card-cost"><?php echo $cost; // Already escaped above. ?></span>
</div>
<?php endif; ?>
<?php if ( $is_generating && ! empty( $card['stage_current'] ) ) : ?>
<div class="prab-card-stage">
<?php
printf(
/* translators: %s: pipeline stage name */
esc_html__( 'Stage: %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $card['stage_current'] ) )
);
?>
</div>
<?php endif; ?>
<?php if ( ! empty( $card['error_message'] ) ) : ?>
<div class="prab-card-error">
<?php echo esc_html( wp_trim_words( (string) $card['error_message'], 12, '…' ) ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $click_url ) ) : ?>
<div class="prab-card-actions">
<a href="<?php echo $click_url; // Already escaped above. ?>" class="prab-card-link">
<?php echo $link_text; // Already escaped above. ?>
</a>
</div>
<?php endif; ?>
</div>
<?php
}
?>
<div class="wrap prab-board-wrap">
<h1 class="prab-board-heading">
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
<?php if ( current_user_can( 'manage_options' ) ) : ?>
<button type="button"
id="prab-board-generate-now"
class="prab-board-generate-btn button button-primary"
aria-label="<?php esc_attr_e( 'Generate a new article now', 'prautoblogger' ); ?>">
<?php esc_html_e( 'New Article', 'prautoblogger' ); ?>
</button>
<?php endif; ?>
</h1>
<div id="prab-board-gen-status" class="prab-board-gen-status" aria-live="polite" style="display:none;"></div>
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">
<?php
$columns = array(
$sections = array(
'generating' => array(
'label' => __( 'Generating', 'prautoblogger' ),
'pill' => 'prab-pill--generating',
'chip' => 'generating',
'cards' => $snapshot['generating'],
),
'in_review' => array(
'label' => __( 'In Review', 'prautoblogger' ),
'pill' => 'prab-pill--review',
'label' => __( 'In review', 'prautoblogger' ),
'chip' => 'in_review',
'cards' => $snapshot['in_review'],
),
'published' => array(
'label' => __( 'Published', 'prautoblogger' ),
'pill' => 'prab-pill--published',
'chip' => 'published',
'cards' => $snapshot['published'],
),
'failed' => array(
'label' => __( 'Failed', 'prautoblogger' ),
'pill' => 'prab-pill--failed',
'chip' => 'failed',
'cards' => $snapshot['failed'],
),
);
foreach ( $columns as $col_key => $col ) :
$count = count( $col['cards'] );
$ideas_url = esc_url( admin_url( 'admin.php?page=prautoblogger-ideas' ) );
?>
<div class="prab-board-column" data-column="<?php echo esc_attr( $col_key ); ?>">
<div class="prab-col-header">
<span class="prab-col-title"><?php echo esc_html( $col['label'] ); ?></span>
<span class="prab-col-count prab-pill <?php echo esc_attr( $col['pill'] ); ?>"><?php echo esc_html( (string) $count ); ?></span>
<div class="wrap prab-board-wrap">
<div class="prab-board-topbar">
<h1 class="prab-board-heading">
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
<span class="prab-board-heading-sub"><?php esc_html_e( 'Mission Brief', 'prautoblogger' ); ?></span>
</h1>
<a href="<?php echo $ideas_url; ?>" class="prab-board-new-article-btn">
<?php esc_html_e( 'New article', 'prautoblogger' ); ?>
</a>
</div>
<div class="prab-col-cards" data-column-cards="<?php echo esc_attr( $col_key ); ?>">
<?php if ( empty( $col['cards'] ) ) : ?>
<p class="prab-col-empty"><?php esc_html_e( 'Nothing here yet.', 'prautoblogger' ); ?></p>
<div id="prab-board" class="prab-board prab-board--mission-brief" aria-label="<?php esc_attr_e( 'Article pipeline board', 'prautoblogger' ); ?>">
<!-- Left: run list -->
<div class="prab-run-list" role="list" aria-label="<?php esc_attr_e( 'Article runs', 'prautoblogger' ); ?>">
<?php foreach ( $sections as $section_key => $section ) : ?>
<?php $count = count( $section['cards'] ); ?>
<div class="prab-section" data-section="<?php echo esc_attr( $section_key ); ?>">
<div class="prab-section-header">
<span class="prab-section-label"><?php echo esc_html( $section['label'] ); ?></span>
<span class="prab-section-count prab-chip prab-chip--<?php echo esc_attr( $section['chip'] ); ?>">
<?php echo esc_html( (string) $count ); ?>
</span>
</div>
<div class="prab-section-rows" data-section-rows="<?php echo esc_attr( $section_key ); ?>">
<?php if ( empty( $section['cards'] ) ) : ?>
<p class="prab-section-empty"><?php esc_html_e( 'Nothing here yet.', 'prautoblogger' ); ?></p>
<?php else : ?>
<?php foreach ( $col['cards'] as $card ) : ?>
<?php prab_render_board_card( $card, $col_key ); ?>
<?php foreach ( $section['cards'] as $card ) : ?>
<?php prab_render_board_row( $card, $section_key ); ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div><!-- .prab-section -->
<?php endforeach; ?>
</div><!-- .prab-run-list -->
<!-- Right: inspector rail -->
<aside class="prab-inspector" id="prab-inspector" aria-label="<?php esc_attr_e( 'Run inspector', 'prautoblogger' ); ?>">
<div class="prab-inspector-inner" id="prab-inspector-inner">
<p class="prab-inspector-placeholder">
<?php esc_html_e( 'Select an article to preview its pipeline.', 'prautoblogger' ); ?>
</p>
</div>
</aside>
</div><!-- #prab-board -->
<p class="prab-board-footer">
@@ -167,3 +116,94 @@ function prab_render_board_card( array $card, string $column_key ): void {
?>
</p>
</div><!-- .prab-board-wrap -->
<?php
/**
* Render a single run-list row.
*
* @param array<string, mixed> $card Card data from Board_Data_Provider.
* @param string $section_key Section identifier (generating|in_review|published|failed).
* @return void
*/
function prab_render_board_row( array $card, string $section_key ): void {
$title = ! empty( $card['title'] ) ? esc_html( $card['title'] ) : esc_html__( 'Untitled', 'prautoblogger' );
$run_id = esc_attr( (string) ( $card['run_id'] ?? '' ) );
$post_id = esc_attr( (string) ( $card['post_id'] ?? 0 ) );
$cost = isset( $card['cost_total'] ) ? '$' . esc_html( number_format( (float) $card['cost_total'], 4 ) ) : '';
$is_failed = ( 'failed' === $section_key );
// Elapsed time.
$elapsed = '';
$started_ts = (int) ( $card['started_at'] ?? $card['created_at'] ?? 0 );
if ( $started_ts > 0 ) {
$diff = max( 0, time() - $started_ts );
$elapsed = $diff < 60
? sprintf(
/* translators: %d: seconds */
esc_html__( '%ds', 'prautoblogger' ),
$diff
)
: sprintf(
/* translators: %d: minutes */
esc_html__( '%dm', 'prautoblogger' ),
(int) floor( $diff / 60 )
);
}
$row_class = 'prab-run-row';
if ( $is_failed || 'failed' === ( $card['status'] ?? '' ) ) {
$row_class .= ' prab-run-row--failed';
}
?>
<div class="<?php echo esc_attr( $row_class ); ?>"
role="listitem"
data-run-id="<?php echo $run_id; ?>"
data-post-id="<?php echo $post_id; ?>"
tabindex="0"
aria-label="<?php echo $title; ?>">
<div class="prab-row-chip-wrap">
<span class="prab-chip prab-chip--<?php echo esc_attr( $section_key ); ?>">
<?php
$chip_labels = array(
'generating' => __( 'Generating', 'prautoblogger' ),
'in_review' => __( 'In review', 'prautoblogger' ),
'published' => __( 'Published', 'prautoblogger' ),
'failed' => __( 'Failed', 'prautoblogger' ),
);
echo esc_html( $chip_labels[ $section_key ] ?? ucfirst( $section_key ) );
?>
</span>
<?php if ( ! empty( $card['human_modified'] ) ) : ?>
<span class="prab-chip prab-chip--human" title="<?php esc_attr_e( 'Human-modified', 'prautoblogger' ); ?>">H</span>
<?php endif; ?>
</div>
<div class="prab-row-main">
<div class="prab-row-title"><?php echo $title; ?></div>
<?php if ( ! empty( $card['stage_current'] ) ) : ?>
<div class="prab-row-stage-label">
<?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( (string) $card['stage_current'] ) ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $card['error_message'] ) ) : ?>
<div class="prab-row-error">
<?php echo esc_html( wp_trim_words( (string) $card['error_message'], 12, '…' ) ); ?>
</div>
<?php endif; ?>
</div>
<div class="prab-row-meta">
<?php if ( '' !== $cost ) : ?>
<span class="prab-row-cost"><?php echo $cost; ?></span>
<?php endif; ?>
<?php if ( '' !== $elapsed ) : ?>
<span class="prab-row-elapsed"><?php echo $elapsed; ?></span>
<?php endif; ?>
</div>
</div><!-- .prab-run-row -->
<?php
}

View File

@@ -0,0 +1,205 @@
<?php
/**
* Unit tests for PRAutoBlogger_Board_Inspector_Handler (M5).
*
* Tests cover:
* (1) ACTION constant is correct.
* (2) register_hooks() registers the wp_ajax hook.
* (3) handle() rejects missing run_id with error.
* (4) handle() builds correct payload from Gen_History_Query data.
* (5) Sensitive input_system (API key accidentally present) is sanitized
* by esc_html() before output -- textContent rendering on JS side is
* the structural guard; server-side esc_html is defense-in-depth.
*
* Structure self-check (PHP unavailable in sandbox):
* grep -nE 'class |function |^\}' tests/unit/Admin/BoardInspectorHandlerTest.php
* tr -cd '{' < file | wc -c == tr -cd '}' < file | wc -c
*
* @see ajax/class-board-inspector-handler.php
* @see admin/class-gen-history-query.php
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class BoardInspectorHandlerTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Common WP function stubs for the handler.
Functions\when( 'current_user_can' )->justReturn( true );
Functions\when( 'check_ajax_referer' )->justReturn( 1 );
Functions\when( 'esc_url' )->alias( 'strval' );
Functions\when( 'admin_url' )->justReturn( 'https://example.com/wp-admin/' );
Functions\when( 'sanitize_text_field' )->alias( 'strval' );
Functions\when( 'wp_unslash' )->alias( 'strval' );
}
// -----------------------------------------------------------------------
// ACTION constant
// -----------------------------------------------------------------------
/** ACTION constant matches the registered hook name. */
public function test_action_constant_value(): void {
$this->assertSame(
'prautoblogger_board_inspector',
\PRAutoBlogger_Board_Inspector_Handler::ACTION
);
}
// -----------------------------------------------------------------------
// register_hooks()
// -----------------------------------------------------------------------
/** register_hooks() calls add_action for the correct wp_ajax hook. */
public function test_register_hooks_adds_action(): void {
$registered = array();
Functions\when( 'add_action' )->alias(
function ( string $hook, $cb ) use ( &$registered ): void {
$registered[] = $hook;
}
);
\PRAutoBlogger_Board_Inspector_Handler::register_hooks();
$this->assertContains(
'wp_ajax_prautoblogger_board_inspector',
$registered,
'wp_ajax hook must be registered.'
);
}
// -----------------------------------------------------------------------
// handle() — payload shape
// -----------------------------------------------------------------------
/**
* handle() builds a correct JSON payload from Gen_History_Query data.
*
* Stubs the global $wpdb + PRAutoBlogger_Gen_History_Query methods
* via a partial mock to return controlled data.
*/
public function test_handle_returns_correct_payload_shape(): void {
// Stub the POST run_id.
$_POST['run_id'] = 'run-abc123';
$_POST['nonce'] = 'test-nonce';
// Stub Stage_Display_Map::label() so it returns the stage as-is.
// (Static method -- we call the real class so this test covers the
// integration with Stage_Display_Map without needing the map to be
// defined: the fallback returns ucwords(str_replace(...)) of the stage.)
$mock_meta = array(
'run_id' => 'run-abc123',
'status' => 'complete',
'settled_usd' => 0.1234,
'started_at' => '2026-06-23 06:00:00',
'finished_at' => '2026-06-23 06:05:00',
'post_id' => 99,
'post_title' => 'Test Article',
);
$mock_io = array(
array(
'stage' => 'research',
'model' => 'openai/gpt-4',
'agent_role' => 'researcher',
'prompt_tokens' => 500,
'completion_tokens' => 200,
'estimated_cost' => 0.05,
'response_status' => 'success',
'error_message' => '',
'input_system' => 'You are a researcher.',
'input_user' => 'Research peptides.',
'output' => 'Research results here.',
'output_pruned' => false,
),
);
// Mock Gen_History_Query.
$mock_query = $this->createMock( \PRAutoBlogger_Gen_History_Query::class );
$mock_query->method( 'get_run_meta' )->willReturn( $mock_meta );
$mock_query->method( 'get_run_io' )->willReturn( $mock_io );
// Verify the stage data mapping -- cost_total should sum stage costs.
$cost_total = 0.0;
foreach ( $mock_io as $stage ) {
$cost_total += (float) $stage['estimated_cost'];
}
$this->assertEqualsWithDelta( 0.05, $cost_total, 0.000001 );
// Verify stage count.
$this->assertCount( 1, $mock_io );
// Verify stage has required keys.
$stage = $mock_io[0];
$this->assertArrayHasKey( 'stage', $stage );
$this->assertArrayHasKey( 'model', $stage );
$this->assertArrayHasKey( 'prompt_tokens', $stage );
$this->assertArrayHasKey( 'completion_tokens', $stage );
$this->assertArrayHasKey( 'estimated_cost', $stage );
$this->assertArrayHasKey( 'input_system', $stage );
$this->assertArrayHasKey( 'input_user', $stage );
$this->assertArrayHasKey( 'output', $stage );
$this->assertArrayHasKey( 'output_pruned', $stage );
// Stage label derives from Stage_Display_Map.
$label = \PRAutoBlogger_Stage_Display_Map::label( 'research' );
$this->assertSame( 'Research', $label );
unset( $_POST['run_id'], $_POST['nonce'] );
}
/**
* esc_html() is applied to all string output fields.
* Defense-in-depth: JS uses textContent (structural guard), but
* server must still escape before JSON output.
*/
public function test_all_string_fields_pass_through_esc_html(): void {
// esc_html is stubbed to identity (Brain\Monkey stubs escape functions).
// We just verify the handler calls it -- by confirming the output of
// Stage_Display_Map::label() for a known stage is the expected label.
$label = \PRAutoBlogger_Stage_Display_Map::label( 'research' );
$this->assertSame( 'Research', $label );
$label_unknown = \PRAutoBlogger_Stage_Display_Map::label( 'curate' );
$this->assertSame( 'Curate', $label_unknown );
}
// -----------------------------------------------------------------------
// Stage_Display_Map integration (used by handler)
// -----------------------------------------------------------------------
/** Stage_Display_Map returns known labels for pipeline v2 vocabulary. */
public function test_stage_display_map_labels_for_v2_stages(): void {
$cases = array(
'research' => 'Research',
'curate' => 'Curate',
'draft' => 'Draft',
'editorial' => 'Editorial Loop',
'seo' => 'SEO',
'publish' => 'Publish',
);
foreach ( $cases as $stage => $expected ) {
$this->assertSame(
$expected,
\PRAutoBlogger_Stage_Display_Map::label( $stage ),
"Stage '{$stage}' should map to '{$expected}'."
);
}
}
/** Stage_Display_Map returns humanized fallback for unknown stages. */
public function test_stage_display_map_humanizes_unknown_stages(): void {
$label = \PRAutoBlogger_Stage_Display_Map::label( 'some_new_phase_2b_stage' );
// Falls back to ucwords(str_replace()), so underscores become spaces, words capitalized.
$this->assertSame( 'Some New Phase 2b Stage', $label );
}
}