Compare commits
4 Commits
main
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
579c66ecab | ||
|
|
91dea0501a | ||
|
|
72bfd232a4 | ||
|
|
682c98e981 |
@@ -120,14 +120,17 @@ prautoblogger/
|
||||
│ ├── css/
|
||||
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
||||
│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0)
|
||||
│ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2)
|
||||
│ │ └── 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)
|
||||
│ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2)
|
||||
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||
│
|
||||
├── includes/
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
|
||||
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; split v0.19.2)
|
||||
│ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1)
|
||||
│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
|
||||
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
|
||||
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
|
||||
@@ -140,8 +143,11 @@ prautoblogger/
|
||||
│ │
|
||||
│ ├── admin/
|
||||
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
||||
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot, delegates gen_log queries (v0.19.0)
|
||||
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
||||
│ │ ├── 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: hidden-submenu, priority 12, enqueue, nonce (v0.19.2)
|
||||
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
|
||||
│ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2)
|
||||
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
|
||||
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||
@@ -245,11 +251,14 @@ prautoblogger/
|
||||
├── templates/
|
||||
│ └── admin/
|
||||
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
|
||||
│ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2)
|
||||
│ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2)
|
||||
│ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2)
|
||||
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
||||
│ ├── metrics-page.php # Metrics/cost dashboard template
|
||||
│ ├── review-queue.php # Review queue table template
|
||||
│ ├── log-viewer.php # Activity log viewer template
|
||||
│ └── metabox-generation-info.php# Post metabox template
|
||||
│ └── metabox-generation-info.php# Post metabox template (superseded by metabox-dossier-link.php in v0.19.2)
|
||||
│
|
||||
├── languages/
|
||||
│ └── prautoblogger.pot # i18n translation template
|
||||
@@ -887,6 +896,61 @@ checks `prautoblogger_page_prautoblogger-board` and would similarly never fire w
|
||||
wrong hookname (board.css/board.js would silently not load). Regression test:
|
||||
`tests/unit/Admin/BoardMenuRegistrationTest.php`.
|
||||
|
||||
|
||||
### #23: Article Dossier page — per-article inspection (v0.19.2)
|
||||
|
||||
The dossier is a read-only per-article admin page that exposes the full substrate view for
|
||||
a single post: generation run, stage I/O, cost receipts, raw-trace toggles, editorial
|
||||
decisions, and image A/B pairs. It is link-accessed only (hidden submenu) — no navigation
|
||||
entry appears in the WP sidebar.
|
||||
|
||||
**Page registration** (`class-dossier-page.php`): registered as a hidden submenu under
|
||||
`prautoblogger-settings` at `admin_menu` priority **12**, one step after the board (priority
|
||||
11), which is itself after the parent menu (priority 10). The page is immediately removed
|
||||
from `$submenu` so it does not appear in the WP admin sidebar, but remains reachable by
|
||||
direct URL. URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>`. Secured by
|
||||
`manage_options` capability + nonce (`prautoblogger_dossier`). Static `url_for_post(int
|
||||
$post_id): string` provides the canonical deep-link. Regression test:
|
||||
`tests/unit/Admin/DossierMenuRegistrationTest.php`.
|
||||
|
||||
**5-query data assembly** (`class-dossier-data-assembler.php`):
|
||||
|
||||
```
|
||||
1. wp_prautoblogger_runs WHERE run_id = %s -- 1 row (run metadata)
|
||||
2. wp_prautoblogger_run_stages WHERE run_id = %s -- N rows (per-stage status/cost)
|
||||
3. wp_prautoblogger_generation_log WHERE run_id = %s -- N rows (model/pv/tokens/request)
|
||||
4. wp_prautoblogger_run_decisions WHERE run_id = %s -- N rows (editorial rationale)
|
||||
5. get_post_meta($post_id) -- WP object cache hit
|
||||
```
|
||||
|
||||
All queries are keyed on `run_id` (indexed). No N+1. Stage output is reconstructed from
|
||||
`run_stages.meta_json → json_decode → meta['output']` (same path as `Run_Stage_State::
|
||||
get_output()`). The dossier assembler does NOT call `Run_Stage_State` directly; it performs
|
||||
its own raw queries so the view is not coupled to the cache layer.
|
||||
|
||||
**Legacy graceful state (binding 5):** Posts published before v0.18.0 (no runs/run_stages
|
||||
rows) and posts where `_prautoblogger_run_id` is absent or blank return `['has_run' => false]`.
|
||||
The template renders a clean "No generation record for this article" notice. No PHP notices
|
||||
or fatals.
|
||||
|
||||
**Amortized research rows:** `generation_log` rows with `prompt_version = NULL` and
|
||||
`agent_role = ''` (from the `llm_research` stage orphan-reaper path) are rendered with
|
||||
model shown as "—" and no raw-trace toggle (request_json may be NULL). No fatal/notice.
|
||||
|
||||
**Raw-trace security (binding 4):** `request_json` stores the OpenRouter request body —
|
||||
model identifier, messages array, temperature. Authorization headers are never logged (the
|
||||
outbound HTTP client strips them before logging, per the original architecture decision on
|
||||
`wp_prautoblogger_generation_log`). The dossier template renders `request_json` via
|
||||
`esc_html()` inside a `<pre>` block. All model-generated text rendered via `wp_kses_post()`
|
||||
(rendered view) or `esc_html()` (raw trace). Both paths treat content as untrusted HTML.
|
||||
|
||||
**File naming note:** `PRAutoBlogger_DB_Migrations` is loaded via explicit `require_once`
|
||||
in `prautoblogger.php` (not the autoloader). The autoloader converts `DB_Migrations` to the
|
||||
kebab segment `d-b-migrations`, producing `class-d-b-migrations.php` — which does not match
|
||||
the file `class-db-migrations.php`. Explicit loading is the canonical pattern for classes
|
||||
whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php`
|
||||
uses the same pattern (also explicitly required in `prautoblogger.php`).
|
||||
|
||||
## Cross-System LLM Budget Coordination
|
||||
|
||||
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
|
||||
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -5,6 +5,42 @@ 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.19.2] - 2026-06-12
|
||||
|
||||
### Added
|
||||
- **Article Dossier page (M2).** Per-article read-only inspection page at
|
||||
`admin.php?page=prautoblogger-dossier&post_id=<id>`. Accessible via board card
|
||||
deep-links and the slim post metabox link. Shows: header (title, verdict pill, tier,
|
||||
status), sidebar (run metadata, per-stage cost receipt with model + prompt-version),
|
||||
vertical stage sections (rendered output default, per-stage raw-trace toggle exposing
|
||||
`generation_log` request JSON + tokens + cost + role), editorial decisions rationale,
|
||||
image A/B pair sections.
|
||||
- **Board card deep-links.** All Kanban cards now deep-link directly to the article
|
||||
dossier page (`dossier_url` on card data; `click_action = dossier` in board JS).
|
||||
- **Slim post metabox.** `PRAutoBlogger_Post_Metabox` now renders a single "View
|
||||
generation dossier →" link (was a verbose generation-metadata dump).
|
||||
- **Dossier CSS/JS.** `assets/css/dossier.css` (warm editorial palette, verdict pills,
|
||||
cost receipt, raw-trace toggle) + `assets/js/dossier.js` (vanilla JS toggle,
|
||||
`aria-expanded` / `aria-controls` accessible).
|
||||
- **Menu registration test coverage.** `DossierMenuRegistrationTest.php` — asserts
|
||||
dossier hook fires at priority > board (> parent), uses hidden-submenu pattern,
|
||||
correct slug, and `url_for_post()` URL shape. Mirrors `BoardMenuRegistrationTest`.
|
||||
- **Raw-trace credential check (binding 4).** `request_json` column contains only
|
||||
OpenRouter request bodies (model, messages, temperature — no Authorization headers
|
||||
per architecture). All model output escaped via `esc_html()` / `wp_kses_post()`;
|
||||
treated as untrusted HTML throughout the render path.
|
||||
- **Legacy graceful state (binding 5).** Posts with no run record (pre-v0.18.0) render
|
||||
a clean "No generation record" notice — no notices, no fatals. Amortized research
|
||||
rows (pv=null, role='') render model as "—" with no raw-trace toggle.
|
||||
|
||||
### Changed
|
||||
- **`class-prautoblogger.php` → `class-db-migrations.php` split (binding 1).**
|
||||
All DB-migration methods extracted byte-identically into new static class
|
||||
`PRAutoBlogger_DB_Migrations` (`includes/class-db-migrations.php`, 129 lines).
|
||||
`class-prautoblogger.php` shrinks from 407 → 258 lines; delegates via thin proxy.
|
||||
Loaded via explicit `require_once` in `prautoblogger.php` (autoloader filename
|
||||
collision avoidance — see ARCHITECTURE.md §File Naming Notes).
|
||||
|
||||
## [0.19.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
441
assets/css/dossier.css
Normal file
441
assets/css/dossier.css
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* PRAutoBlogger Article Dossier styles (M2).
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial,
|
||||
* generous whitespace, typographic hierarchy, verdict pills, receipt
|
||||
* sidebar. Scoped to .prab-dossier-wrap to avoid WP admin conflicts.
|
||||
*
|
||||
* @see admin/class-dossier-page.php
|
||||
* @see templates/admin/dossier-page.php
|
||||
*/
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-wrap {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.prab-dossier-back {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prab-dossier-back a {
|
||||
color: #1B8A92;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.prab-dossier-back a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prab-dossier--empty {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.prab-dossier-empty-notice h2 {
|
||||
font-size: 1.3em;
|
||||
margin: 0 0 12px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.prab-dossier {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px 20px;
|
||||
border-bottom: 1px solid #e8e0d8;
|
||||
background: #FAF6EE;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.prab-dossier-title {
|
||||
font-size: 1.6em;
|
||||
margin: 0 0 8px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prab-dossier-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Body: two-column ─────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.prab-dossier-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.prab-dossier-sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
padding: 24px 20px;
|
||||
border-left: 1px solid #e8e0d8;
|
||||
}
|
||||
|
||||
/* ── Verdict pills ────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-verdict-pill {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.prab-verdict-pill--approved { background: #e6f5ee; color: #1a7a45; }
|
||||
.prab-verdict-pill--revised { background: #fff4e0; color: #a06000; }
|
||||
.prab-verdict-pill--rejected { background: #fde8e8; color: #b91c1c; }
|
||||
.prab-verdict-pill--halted { background: #f0f0f0; color: #555; }
|
||||
.prab-verdict-pill--pending { background: #e8f0fe; color: #1a55a0; }
|
||||
|
||||
/* ── Status pills ─────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-tier {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.prab-dossier-status {
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
/* ── Stage sections ───────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-section {
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid #e5ddd5;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prab-dossier-section--stage {
|
||||
border-left: 4px solid #ccc;
|
||||
}
|
||||
|
||||
.prab-stage-status--done { border-left-color: #1B8A92; }
|
||||
.prab-stage-status--running { border-left-color: #FF8A3D; }
|
||||
.prab-stage-status--failed { border-left-color: #e53e3e; }
|
||||
.prab-stage-status--pending { border-left-color: #a0aec0; }
|
||||
|
||||
.prab-stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #faf8f5;
|
||||
border-bottom: 1px solid #ede6dd;
|
||||
}
|
||||
|
||||
.prab-section-title {
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #2d2d2d;
|
||||
}
|
||||
|
||||
.prab-stage-role {
|
||||
font-weight: 400;
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.prab-stage-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.prab-stage-status-pill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prab-stage-status-pill--done { background: #e6f5ee; color: #1a7a45; }
|
||||
.prab-stage-status-pill--running { background: #fff4e0; color: #a06000; }
|
||||
.prab-stage-status-pill--failed { background: #fde8e8; color: #b91c1c; }
|
||||
.prab-stage-status-pill--pending { background: #f0f0f0; color: #555; }
|
||||
|
||||
.prab-stage-cost {
|
||||
color: #1B8A92;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prab-stage-pv {
|
||||
background: #f0f0f0;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Stage output ─────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-stage-output {
|
||||
padding: 16px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.prab-stage-output-rendered {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.prab-output-pruned,
|
||||
.prab-output-absent {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Raw trace toggle ─────────────────────────────────────────────────── */
|
||||
|
||||
.prab-stage-trace {
|
||||
padding: 0 16px 12px;
|
||||
border-top: 1px solid #f0ece6;
|
||||
}
|
||||
|
||||
.prab-trace-toggle {
|
||||
font-size: 12px;
|
||||
color: #1B8A92;
|
||||
cursor: pointer;
|
||||
padding: 6px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.prab-trace-toggle:hover {
|
||||
color: #0d6066;
|
||||
}
|
||||
|
||||
.prab-trace-detail {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.prab-trace-entry {
|
||||
background: #f7f4ef;
|
||||
border: 1px solid #e5ddd5;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prab-trace-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.prab-trace-status--success { color: #1a7a45; }
|
||||
.prab-trace-status--error { color: #b91c1c; }
|
||||
|
||||
.prab-trace-request summary {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.prab-trace-pre {
|
||||
background: #1a1a1a;
|
||||
color: #e2e8f0;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
overflow-x: auto;
|
||||
max-height: 300px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.prab-trace-error {
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* ── Stage timestamps ─────────────────────────────────────────────────── */
|
||||
|
||||
.prab-stage-timestamps {
|
||||
padding: 4px 16px 10px;
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.prab-ts-sep {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Decisions section ───────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-section--decisions {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.prab-dossier-section--decisions .prab-section-title {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prab-decision-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0ece6;
|
||||
}
|
||||
|
||||
.prab-decision-row:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.prab-decision-stage {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.prab-decision-rationale {
|
||||
margin: 4px 0 0;
|
||||
width: 100%;
|
||||
color: #555;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-sidebar-card {
|
||||
background: #faf8f5;
|
||||
border: 1px solid #e5ddd5;
|
||||
border-radius: 4px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.prab-sidebar-heading {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #888;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.prab-sidebar-dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-sidebar-dl dt {
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prab-sidebar-dl dd {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.prab-run-id {
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ── Cost receipt ─────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-receipt-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-receipt-table td {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.prab-receipt-stage {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.prab-receipt-cost {
|
||||
text-align: right;
|
||||
color: #1B8A92;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.prab-receipt-total {
|
||||
border-top: 1px solid #ddd;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prab-receipt-total td {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* ── Run status ───────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-run-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prab-run-status--done { color: #1a7a45; }
|
||||
.prab-run-status--running { color: #a06000; }
|
||||
.prab-run-status--failed { color: #b91c1c; }
|
||||
|
||||
/* ── No stages ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-dossier-no-stages {
|
||||
padding: 24px 0;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* ── Metabox ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.prab-metabox-dossier .prab-run-id-label {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -93,7 +93,10 @@
|
||||
// Click-through link.
|
||||
var linkUrl = '';
|
||||
var linkText = '';
|
||||
if ( card.click_action === 'review' && card.review_url ) {
|
||||
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 ) {
|
||||
|
||||
41
assets/js/dossier.js
Normal file
41
assets/js/dossier.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
|
||||
*
|
||||
* Toggles the hidden raw-trace <div> per stage when the "Show raw trace"
|
||||
* button is clicked. Vanilla JS, no build step, no external deps.
|
||||
*
|
||||
* Accessible: uses aria-expanded + aria-controls; respects the hidden attribute.
|
||||
*
|
||||
* @see admin/class-dossier-page.php -- Enqueues this file.
|
||||
* @see templates/admin/dossier-page.php -- Renders the toggle buttons.
|
||||
*/
|
||||
( function () {
|
||||
'use strict';
|
||||
|
||||
document.addEventListener( 'DOMContentLoaded', function () {
|
||||
var toggles = document.querySelectorAll( '.prab-trace-toggle' );
|
||||
|
||||
toggles.forEach( function ( btn ) {
|
||||
btn.addEventListener( 'click', function () {
|
||||
var targetId = btn.getAttribute( 'aria-controls' );
|
||||
if ( ! targetId ) {
|
||||
return;
|
||||
}
|
||||
var panel = document.getElementById( targetId );
|
||||
if ( ! panel ) {
|
||||
return;
|
||||
}
|
||||
var isOpen = btn.getAttribute( 'aria-expanded' ) === 'true';
|
||||
if ( isOpen ) {
|
||||
panel.hidden = true;
|
||||
btn.setAttribute( 'aria-expanded', 'false' );
|
||||
btn.textContent = btn.textContent.replace( /hide/i, 'Show' );
|
||||
} else {
|
||||
panel.hidden = false;
|
||||
btn.setAttribute( 'aria-expanded', 'true' );
|
||||
btn.textContent = btn.textContent.replace( /show/i, 'Hide' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
}() );
|
||||
@@ -13,15 +13,19 @@ declare(strict_types=1);
|
||||
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
|
||||
* are handled here.
|
||||
*
|
||||
* M2: cards now include `dossier_url` so board.js can deep-link to the
|
||||
* Article Dossier instead of the old per-column destinations.
|
||||
*
|
||||
* 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, WordPress WP_Query, get_option().
|
||||
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, PRAutoBlogger_Dossier_Page,
|
||||
* WordPress WP_Query, get_option().
|
||||
*
|
||||
* @see admin/class-board-gen-log-query.php -- Raw generation-log queries (Generating/Failed).
|
||||
* @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed).
|
||||
* @see admin/class-board-page.php -- Calls get_board_snapshot().
|
||||
* @see core/class-cost-reporter.php -- Parallel cost-query pattern.
|
||||
* @see ARCHITECTURE.md -- Database schema (generation_log, post meta).
|
||||
* @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links.
|
||||
* @see ARCHITECTURE.md -- Database schema.
|
||||
*/
|
||||
class PRAutoBlogger_Board_Data_Provider {
|
||||
|
||||
@@ -38,9 +42,6 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
/**
|
||||
* Return the full board snapshot consumed by the AJAX poller.
|
||||
*
|
||||
* Each column is an array of card objects. Cards contain only the data
|
||||
* needed to render the board card and provide click-throughs.
|
||||
*
|
||||
* @return array{
|
||||
* generating: array<int, array<string, mixed>>,
|
||||
* in_review: array<int, array<string, mixed>>,
|
||||
@@ -66,7 +67,6 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
|
||||
/**
|
||||
* Cards for runs that are currently in-progress.
|
||||
* Delegates to PRAutoBlogger_Board_Gen_Log_Query.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -76,7 +76,6 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
|
||||
/**
|
||||
* Cards for generation runs that ended in an error state.
|
||||
* Delegates to PRAutoBlogger_Board_Gen_Log_Query.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -86,6 +85,7 @@ 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>>
|
||||
*/
|
||||
@@ -120,7 +120,8 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
'title' => $post->post_title,
|
||||
'cost_total' => $cost_total,
|
||||
'created_at' => strtotime( $post->post_date_gmt ),
|
||||
'click_action' => 'review',
|
||||
'click_action' => 'dossier',
|
||||
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
|
||||
'review_url' => admin_url( 'admin.php?page=prautoblogger-review-queue' ),
|
||||
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
|
||||
);
|
||||
@@ -131,6 +132,7 @@ 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>>
|
||||
*/
|
||||
@@ -174,7 +176,8 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
'title' => $post->post_title,
|
||||
'cost_total' => $cost_total,
|
||||
'published_at' => strtotime( $post->post_date_gmt ),
|
||||
'click_action' => 'edit',
|
||||
'click_action' => 'dossier',
|
||||
'dossier_url' => PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ),
|
||||
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
|
||||
'post_url' => get_permalink( $post->ID ),
|
||||
);
|
||||
|
||||
191
includes/admin/class-dossier-data-assembler.php
Normal file
191
includes/admin/class-dossier-data-assembler.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Assembles the view model for the Article Dossier page.
|
||||
*
|
||||
* Orchestrates 5 queries (all indexed by run_id or post_id):
|
||||
* 1. wp_prautoblogger_runs -- run ledger row
|
||||
* 2. wp_prautoblogger_run_stages -- all stage rows for the run
|
||||
* 3. wp_prautoblogger_generation_log -- per-stage cost + raw trace
|
||||
* 4. wp_prautoblogger_run_decisions -- rationale per stage
|
||||
* 5. post meta (WP object cache) -- run_id + verdict + keywords
|
||||
*
|
||||
* Legacy posts (no run row) return ['has_run' => false] -- never notices.
|
||||
* Amortized research rows (pv=null, role='') render gracefully.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Dossier_Page::render_page().
|
||||
* Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map,
|
||||
* PRAutoBlogger_Run_Stage_State.
|
||||
*
|
||||
* @see admin/class-dossier-gen-log-query.php -- Raw generation_log queries.
|
||||
* @see core/class-run-stage-state.php -- Stage I/O access.
|
||||
* @see ARCHITECTURE.md -- Substrate schema.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Data_Assembler {
|
||||
|
||||
/** @var PRAutoBlogger_Dossier_Gen_Log_Query */
|
||||
private PRAutoBlogger_Dossier_Gen_Log_Query $log_query;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Dossier_Gen_Log_Query|null $log_query Injectable for tests.
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Dossier_Gen_Log_Query $log_query = null ) {
|
||||
$this->log_query = $log_query ?? new PRAutoBlogger_Dossier_Gen_Log_Query();
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the full dossier view model for a post.
|
||||
*
|
||||
* @param int $post_id WordPress post ID (0 = bogus/not found).
|
||||
* @return array<string, mixed> View model consumed by dossier-page.php.
|
||||
*/
|
||||
public function assemble( int $post_id ): array {
|
||||
$base = array(
|
||||
'post_id' => $post_id,
|
||||
'has_run' => false,
|
||||
'post_title' => '',
|
||||
'post_status' => '',
|
||||
'verdict' => '',
|
||||
'tier' => '',
|
||||
'run_id' => '',
|
||||
'run' => null,
|
||||
'stages' => array(),
|
||||
'decisions' => array(),
|
||||
'gen_log_index' => array(),
|
||||
);
|
||||
|
||||
if ( $post_id <= 0 ) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
if ( ! ( $post instanceof WP_Post ) ) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$base['post_title'] = $post->post_title;
|
||||
$base['post_status'] = $post->post_status;
|
||||
$base['verdict'] = (string) get_post_meta( $post_id, '_prautoblogger_editor_verdict', true );
|
||||
$base['tier'] = (string) get_post_meta( $post_id, '_prautoblogger_article_type', true );
|
||||
|
||||
$run_id = (string) get_post_meta( $post_id, '_prautoblogger_run_id', true );
|
||||
if ( '' === $run_id ) {
|
||||
return $base;
|
||||
}
|
||||
$base['run_id'] = $run_id;
|
||||
|
||||
$run = $this->get_run_row( $run_id );
|
||||
if ( null === $run ) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$base['has_run'] = true;
|
||||
$base['run'] = $run;
|
||||
$base['stages'] = $this->get_stage_rows( $run_id );
|
||||
$base['decisions'] = $this->get_decision_rows( $run_id );
|
||||
$base['gen_log_index'] = $this->log_query->get_by_run( $run_id );
|
||||
|
||||
return $base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the run ledger row.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function get_run_row( string $run_id ): ?array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return null;
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_runs';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_row(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $row ) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all run_stages rows for a run, indexed by stage name.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, array<string, mixed>> Stage rows keyed by stage name.
|
||||
*/
|
||||
private function get_stage_rows( string $run_id ): array {
|
||||
if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) {
|
||||
return array();
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return array();
|
||||
}
|
||||
$table = PRAutoBlogger_Run_Stage_State::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage = (string) ( $row['stage'] ?? '' );
|
||||
$role_key = $stage . ':' . ( $row['agent_role'] ?? '' );
|
||||
$row['display_output'] = $this->extract_output( $row );
|
||||
$index[ $role_key ] = $row;
|
||||
}
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the text output from a run_stages meta_json blob.
|
||||
* Returns null when meta_json is NULL (pruned) or absent.
|
||||
*
|
||||
* @param array<string, mixed> $row Stage row.
|
||||
* @return string|null
|
||||
*/
|
||||
private function extract_output( array $row ): ?string {
|
||||
if ( empty( $row['meta_json'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$meta = json_decode( (string) $row['meta_json'], true );
|
||||
return ( is_array( $meta ) && isset( $meta['output'] ) ) ? (string) $meta['output'] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all run_decisions rows, indexed by stage name.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, array<string, mixed>> Decision rows keyed by stage.
|
||||
*/
|
||||
private function get_decision_rows( string $run_id ): array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return array();
|
||||
}
|
||||
$table = $wpdb->prefix . 'prautoblogger_run_decisions';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage = (string) ( $row['stage'] ?? 'unknown' );
|
||||
$index[ $stage ] = $row;
|
||||
}
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
95
includes/admin/class-dossier-gen-log-query.php
Normal file
95
includes/admin/class-dossier-gen-log-query.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Raw generation_log queries for the Article Dossier (M2).
|
||||
*
|
||||
* Returns per-stage generation log data indexed by stage name: model,
|
||||
* prompt_version, agent_role, token counts, estimated cost, request_json
|
||||
* (raw trace), and response_status. Amortized research rows (pv=null,
|
||||
* role='') are included and rendered gracefully by the template.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Dossier_Data_Assembler::assemble().
|
||||
* Dependencies: WordPress $wpdb.
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php -- Calls get_by_run().
|
||||
* @see ARCHITECTURE.md -- generation_log schema.
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Gen_Log_Query {
|
||||
|
||||
/**
|
||||
* Fetch all generation_log rows for a run, indexed by stage.
|
||||
*
|
||||
* Multiple rows per stage (e.g. multi-step draft) are accumulated as an
|
||||
* array so the template can render multiple raw-trace entries per section.
|
||||
*
|
||||
* SECURITY: request_json is stored without API keys (never logged per
|
||||
* architecture). The template must escape this via esc_html() before render.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return array<string, array<int, array<string, mixed>>> Rows keyed by stage.
|
||||
*/
|
||||
public function get_by_run( string $run_id ): array {
|
||||
global $wpdb;
|
||||
if ( null === $wpdb || '' === $run_id ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, stage, model, agent_role, prompt_version,
|
||||
prompt_tokens, completion_tokens, estimated_cost,
|
||||
request_json, response_status, error_message, created_at
|
||||
FROM {$table}
|
||||
WHERE run_id = %s
|
||||
ORDER BY id ASC",
|
||||
$run_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$index = array();
|
||||
foreach ( ( $rows ?? array() ) as $row ) {
|
||||
if ( ! is_array( $row ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage = (string) ( $row['stage'] ?? 'unknown' );
|
||||
if ( ! isset( $index[ $stage ] ) ) {
|
||||
$index[ $stage ] = array();
|
||||
}
|
||||
$index[ $stage ][] = $row;
|
||||
}
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate cost + token totals per stage for the sidebar receipt.
|
||||
*
|
||||
* @param array<string, array<int, array<string, mixed>>> $log_index Output of get_by_run().
|
||||
* @return array<string, array{cost: float, prompt_tokens: int, completion_tokens: int, calls: int}>
|
||||
*/
|
||||
public function aggregate_per_stage( array $log_index ): array {
|
||||
$totals = array();
|
||||
foreach ( $log_index as $stage => $rows ) {
|
||||
$cost = 0.0;
|
||||
$prompt_tokens = 0;
|
||||
$completion_tokens = 0;
|
||||
foreach ( $rows as $row ) {
|
||||
$cost += (float) ( $row['estimated_cost'] ?? 0 );
|
||||
$prompt_tokens += (int) ( $row['prompt_tokens'] ?? 0 );
|
||||
$completion_tokens += (int) ( $row['completion_tokens'] ?? 0 );
|
||||
}
|
||||
$totals[ $stage ] = array(
|
||||
'cost' => $cost,
|
||||
'prompt_tokens' => $prompt_tokens,
|
||||
'completion_tokens' => $completion_tokens,
|
||||
'calls' => count( $rows ),
|
||||
);
|
||||
}
|
||||
return $totals;
|
||||
}
|
||||
}
|
||||
117
includes/admin/class-dossier-page.php
Normal file
117
includes/admin/class-dossier-page.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Registers and renders the Article Dossier admin page (M2).
|
||||
*
|
||||
* The dossier is link-accessed -- it is NOT a visible submenu item. It registers
|
||||
* as a hidden submenu under 'prautoblogger-settings' at admin_menu priority 12
|
||||
* (after board at 11) so WordPress resolves the hookname correctly. Board cards and
|
||||
* post list columns deep-link here via ?page=prautoblogger-dossier&post_id=X.
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial dossier,
|
||||
* verdict pills, receipt-style cost sidebar, per-stage I/O with raw-trace toggle.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu` (priority 12).
|
||||
* Dependencies: PRAutoBlogger_Dossier_Data_Assembler, templates/admin/dossier-page.php.
|
||||
*
|
||||
* @see admin/class-dossier-data-assembler.php -- Builds the view model.
|
||||
* @see templates/admin/dossier-page.php -- HTML template.
|
||||
* @see ARCHITECTURE.md -- §Dossier (M2).
|
||||
*/
|
||||
class PRAutoBlogger_Dossier_Page {
|
||||
|
||||
/** Admin page slug. */
|
||||
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||
|
||||
/** Nonce action for any future stateful actions. */
|
||||
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||
|
||||
/**
|
||||
* Register the dossier as a hidden submenu page.
|
||||
*
|
||||
* Hidden = not visible in the menu, but reachable by URL.
|
||||
* Priority 12 ensures add_menu_page() (priority 10) has populated
|
||||
* $admin_page_hooks before this add_submenu_page() call. See ARCHITECTURE.md §Board.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
'prautoblogger-settings',
|
||||
__( 'Article Dossier', 'prautoblogger' ),
|
||||
__( 'Dossier', 'prautoblogger' ),
|
||||
'manage_options',
|
||||
self::PAGE_SLUG,
|
||||
array( $this, 'render_page' )
|
||||
);
|
||||
|
||||
// Remove from visible submenu so it doesn't clutter the nav.
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional hidden-submenu pattern.
|
||||
global $submenu;
|
||||
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||
foreach ( $submenu['prautoblogger-settings'] as $idx => $item ) {
|
||||
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||
unset( $submenu['prautoblogger-settings'][ $idx ] );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the dossier page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_page(): void {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only dossier; no state changes.
|
||||
$post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0;
|
||||
$assembler = new PRAutoBlogger_Dossier_Data_Assembler();
|
||||
$dossier = $assembler->assemble( $post_id );
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-page.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue dossier-specific CSS + JS.
|
||||
*
|
||||
* @param string $hook_suffix Current admin page hook suffix.
|
||||
* @return void
|
||||
*/
|
||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||
$dossier_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||
if ( $hook_suffix !== $dossier_hook ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_enqueue_style(
|
||||
'prautoblogger-dossier',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier.css',
|
||||
array( 'prautoblogger-admin' ),
|
||||
PRAUTOBLOGGER_VERSION
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-dossier',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier.js',
|
||||
array(),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL to the dossier for a given post.
|
||||
*
|
||||
* @param int $post_id WordPress post ID.
|
||||
* @return string Admin URL to the dossier page.
|
||||
*/
|
||||
public static function url_for_post( int $post_id ): string {
|
||||
return admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&post_id=' . $post_id );
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,22 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Adds a metabox to posts showing PRAutoBlogger generation metadata.
|
||||
* Adds a metabox to PRAutoBlogger-generated posts.
|
||||
*
|
||||
* Only appears on posts that were generated by PRAutoBlogger (checked via
|
||||
* _prautoblogger_generated post meta).
|
||||
* M2: Slimmed to a "View generation dossier" link. Full generation metadata
|
||||
* lives on the Article Dossier page (PRAutoBlogger_Dossier_Page).
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
|
||||
* Dependencies: WordPress post meta API.
|
||||
* Dependencies: PRAutoBlogger_Dossier_Page (for URL helper).
|
||||
*
|
||||
* @see class-prautoblogger.php — Registers the hook.
|
||||
* @see core/class-publisher.php — Writes the metadata this class displays.
|
||||
* @see class-prautoblogger.php -- Registers the hook.
|
||||
* @see admin/class-dossier-page.php -- Dossier target; provides url_for_post().
|
||||
* @see core/class-publisher.php -- Writes the run_id meta this class reads.
|
||||
*/
|
||||
class PRAutoBlogger_Post_Metabox {
|
||||
|
||||
/**
|
||||
* Register the metabox for the post editor.
|
||||
* Register the metabox for PRAutoBlogger-generated posts only.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -35,7 +36,7 @@ class PRAutoBlogger_Post_Metabox {
|
||||
|
||||
add_meta_box(
|
||||
'prautoblogger_generation_info',
|
||||
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
|
||||
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||
array( $this, 'render_metabox' ),
|
||||
'post',
|
||||
'side',
|
||||
@@ -44,13 +45,12 @@ class PRAutoBlogger_Post_Metabox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the generation metadata metabox.
|
||||
*
|
||||
* @param \WP_Post $post
|
||||
* Render the dossier-link metabox.
|
||||
*
|
||||
* @param \WP_Post $post Current post.
|
||||
* @return void
|
||||
*/
|
||||
public function render_metabox( \WP_Post $post ): void {
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-generation-info.php';
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/metabox-dossier-link.php';
|
||||
}
|
||||
}
|
||||
|
||||
129
includes/class-db-migrations.php
Normal file
129
includes/class-db-migrations.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* One-time and version-gated DB/option migrations for PRAutoBlogger.
|
||||
*
|
||||
* Extracted from PRAutoBlogger (class-prautoblogger.php) in v0.19.2 so the
|
||||
* main orchestrator stays under the 300-line cap. All migration logic lives
|
||||
* here; the orchestrator holds thin proxy methods for backward compat.
|
||||
*
|
||||
* Each migration is idempotent: guarded by a stored option flag so it never
|
||||
* runs more than once per site.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::on_check_db_version() (admin_init)
|
||||
* PRAutoBlogger::on_check_db_version_for_cron() (init, cron-only).
|
||||
* Dependencies: PRAutoBlogger_Activator, PRAutoBlogger_Image_Model_Registry,
|
||||
* PRAutoBlogger_Encryption, PRAutoBlogger_Migrate_Remove_Cloudflare_V0100.
|
||||
*
|
||||
* @see class-prautoblogger.php -- Orchestrator; holds proxy methods.
|
||||
* @see class-activator.php -- Schema install / activate logic called here.
|
||||
* @see ARCHITECTURE.md -- DB version history and migration discipline.
|
||||
*/
|
||||
class PRAutoBlogger_DB_Migrations {
|
||||
|
||||
/**
|
||||
* Run all pending migrations.
|
||||
*
|
||||
* Called on `admin_init` (and on `init` for cron contexts). Idempotent.
|
||||
*
|
||||
* Side effects: may update database schema and wp_options rows.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
||||
PRAutoBlogger_Activator::activate();
|
||||
}
|
||||
|
||||
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
||||
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
||||
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider. Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||
// using the timezone-aware helper. See class-activator.php.
|
||||
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||
$known_old_prefixes = array(
|
||||
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||
'Style: premium scientific lifestyle', // v2: photography.
|
||||
);
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
$is_old = ( '' === $current );
|
||||
foreach ( $known_old_prefixes as $prefix ) {
|
||||
if ( false !== strpos( $current, $prefix ) ) {
|
||||
$is_old = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( $is_old ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.16.0): editorial pivot -- comic Style Suffix
|
||||
// replaced by editorial Style Template. Old value mirrored to a
|
||||
// deprecated-keyed option for one version cycle.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
||||
}
|
||||
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
||||
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
foreach ( $enc_options as $opt ) {
|
||||
$val = get_option( $opt, '' );
|
||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||
delete_option( $opt );
|
||||
}
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,12 @@ declare(strict_types=1);
|
||||
*
|
||||
* Main orchestrator for the PRAutoBlogger plugin.
|
||||
*
|
||||
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
|
||||
* This is the ONLY place hooks are registered -- individual classes contain only
|
||||
* business logic, not hook registration.
|
||||
* Registers all WordPress hooks and wires up dependencies.
|
||||
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
|
||||
*
|
||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
|
||||
*
|
||||
* @see prautoblogger.php -- Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php -- Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php -- Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php -- DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php -- Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md -- Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md -- Hook naming conventions.
|
||||
* @see prautoblogger.php -- Plugin bootstrap.
|
||||
* @see class-db-migrations.php -- One-time DB/option migrations.
|
||||
* @see ARCHITECTURE.md -- Data flow diagram.
|
||||
*/
|
||||
class PRAutoBlogger {
|
||||
|
||||
@@ -28,14 +20,12 @@ class PRAutoBlogger {
|
||||
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||
private PRAutoBlogger_Executor $executor;
|
||||
|
||||
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX (images, models, test). */
|
||||
/** @var PRAutoBlogger_Ajax_Handlers Handles non-generation AJAX. */
|
||||
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
|
||||
|
||||
/**
|
||||
* Register all hooks and initialize the plugin.
|
||||
* Called once on `plugins_loaded`. Idempotent.
|
||||
*
|
||||
* Side effects: registers WordPress actions and filters.
|
||||
*/
|
||||
public function run(): void {
|
||||
if ( $this->initialized ) {
|
||||
@@ -46,9 +36,7 @@ class PRAutoBlogger {
|
||||
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||
|
||||
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
|
||||
// v0.18.0: cron requests must also self-heal the schema -- after a
|
||||
// deploy, scheduled generation can run before any admin page load,
|
||||
// and new-column inserts would silently fail on the old schema.
|
||||
// v0.18.0: cron requests must also self-heal the schema.
|
||||
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
|
||||
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
|
||||
|
||||
@@ -65,25 +53,30 @@ class PRAutoBlogger {
|
||||
do_action( 'prautoblogger_loaded' );
|
||||
}
|
||||
|
||||
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
|
||||
/**
|
||||
* Register admin-only hooks (settings, notices, metabox, board, dossier, widget).
|
||||
*
|
||||
* Menu-ordering rule (v0.19.1): parent menu must register at priority 10 before
|
||||
* any submenu (priority 11+). WordPress resolves the submenu hookname via
|
||||
* get_plugin_page_hookname() at add_submenu_page() time; if the parent slot in
|
||||
* $admin_page_hooks is unset, WP falls back to admin_page_* -> 404 at request
|
||||
* time. See ARCHITECTURE.md §Board for the full trace.
|
||||
*/
|
||||
private function register_admin_hooks(): void {
|
||||
// Parent top-level menu first (priority 10), then Board submenu (priority 11).
|
||||
// WHY: WordPress computes the page hookname via get_plugin_page_hookname() at
|
||||
// add_submenu_page() time. If add_menu_page() has not yet fired, the parent
|
||||
// slot in $admin_page_hooks is unset and WP falls back to the `admin_page_*`
|
||||
// hookname. At request time WP recomputes the hookname as `prautoblogger_page_*`
|
||||
// and finds no registered callback -> wp_die 404. Fixing priority ensures the
|
||||
// parent slot is populated before the submenu call. See ARCHITECTURE.md §Board.
|
||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
||||
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Board at priority 11 -- fires AFTER add_menu_page, so hookname is correct.
|
||||
$board_page = new PRAutoBlogger_Board_Page();
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
|
||||
|
||||
// Dossier: link-accessed hidden submenu, priority 12 (after board).
|
||||
$dossier_page = new PRAutoBlogger_Dossier_Page();
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
add_action( 'admin_enqueue_scripts', array( $dossier_page, 'on_enqueue_assets' ) );
|
||||
|
||||
add_action( 'admin_notices', array( new PRAutoBlogger_Admin_Notices(), 'on_display_notices' ) );
|
||||
add_action( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
|
||||
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
||||
@@ -98,7 +91,6 @@ class PRAutoBlogger {
|
||||
|
||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||
|
||||
// Block false update notifications -- our slug collides with another plugin.
|
||||
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
|
||||
}
|
||||
|
||||
@@ -118,13 +110,7 @@ class PRAutoBlogger {
|
||||
private function register_cron_hooks(): void {
|
||||
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
|
||||
add_action( 'prautoblogger_collect_metrics', array( $this->executor, 'on_collect_metrics' ) );
|
||||
|
||||
// Manual "Generate Now" runs as a one-shot cron event to avoid
|
||||
// Hostinger's 120-second web-server connection timeout.
|
||||
add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) );
|
||||
|
||||
// Chained article generation -- each queued article fires as its own
|
||||
// cron event so it gets a fresh PHP process and execution time budget.
|
||||
add_action(
|
||||
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||
array( $this->executor, 'on_process_article_queue' )
|
||||
@@ -132,59 +118,39 @@ class PRAutoBlogger {
|
||||
|
||||
$registry = $this->executor->get_model_registry();
|
||||
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
||||
// v0.15.0: daily Runware model catalog sync to keep the live model list fresh.
|
||||
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
|
||||
|
||||
// Opik observability: async trace/span dispatch.
|
||||
add_action(
|
||||
'prautoblogger_opik_dispatch',
|
||||
static function (): void {
|
||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
|
||||
return;
|
||||
}
|
||||
$client = new PRAutoBlogger_Opik_Client(
|
||||
$client = new PRAutoBlogger_Opik_Client(
|
||||
PRAUTOBLOGGER_OPIK_API_KEY,
|
||||
PRAUTOBLOGGER_OPIK_WORKSPACE,
|
||||
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
|
||||
);
|
||||
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
||||
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
||||
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, new PRAutoBlogger_Opik_Span_Queue() );
|
||||
$dispatcher->dispatch();
|
||||
update_option( 'prautoblogger_opik_last_dispatch', time() );
|
||||
}
|
||||
);
|
||||
|
||||
// Opik settings registration.
|
||||
if ( is_admin() ) {
|
||||
new PRAutoBlogger_Opik_Settings();
|
||||
}
|
||||
|
||||
// Single-idea generation from the Ideas browser page.
|
||||
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
|
||||
// pipeline dies before amortize_research_costs runs).
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
|
||||
|
||||
// v0.18.0: stuck-run sweep + audit-payload retention rides the SAME
|
||||
// existing daily cron (no new schedule). See class-run-reaper.php.
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
||||
|
||||
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
|
||||
// WP-CLI is present; no-op in normal HTTP requests.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger reap-research',
|
||||
static function (): void {
|
||||
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||
\WP_CLI::success(
|
||||
sprintf(
|
||||
'Reaped %d, deleted %d (stale), skipped %d.',
|
||||
(int) $stats['reaped'],
|
||||
(int) $stats['deleted'],
|
||||
(int) $stats['skipped']
|
||||
)
|
||||
);
|
||||
\WP_CLI::success( sprintf( 'Reaped %d, deleted %d, skipped %d.', (int) $stats['reaped'], (int) $stats['deleted'], (int) $stats['skipped'] ) );
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -192,11 +158,9 @@ class PRAutoBlogger {
|
||||
|
||||
/** Register AJAX handlers for admin actions. */
|
||||
private function register_ajax_hooks(): void {
|
||||
// Generation AJAX (start + status polling) stays on executor.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_now', array( $this->executor, 'on_ajax_generate_now' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
|
||||
|
||||
// Non-generation AJAX (images, models, test) on dedicated handler.
|
||||
add_action( 'wp_ajax_prautoblogger_generate_image', array( $this->ajax_handlers, 'on_ajax_generate_image' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
|
||||
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
||||
@@ -208,12 +172,7 @@ class PRAutoBlogger {
|
||||
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
|
||||
|
||||
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
|
||||
|
||||
// Board status polling AJAX (nonce: prautoblogger_board).
|
||||
add_action(
|
||||
'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION,
|
||||
array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' )
|
||||
);
|
||||
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
|
||||
|
||||
$ideas = new PRAutoBlogger_Ideas_Browser();
|
||||
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
|
||||
@@ -221,123 +180,16 @@ class PRAutoBlogger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if database needs migration after plugin update.
|
||||
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
||||
*
|
||||
* Side effects: may update database schema and db_version option.
|
||||
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
|
||||
* Hooked on `admin_init`.
|
||||
*/
|
||||
public function on_check_db_version(): void {
|
||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
||||
if ( version_compare( $stored_version, PRAUTOBLOGGER_DB_VERSION, '<' ) ) {
|
||||
PRAutoBlogger_Activator::activate();
|
||||
}
|
||||
|
||||
// One-time migration: switch to Gemini 2.5 Flash Lite for cost/speed.
|
||||
if ( ! get_option( 'prautoblogger_migrated_gemini_flash_lite' ) ) {
|
||||
update_option( 'prautoblogger_analysis_model', PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL );
|
||||
update_option( 'prautoblogger_writing_model', PRAUTOBLOGGER_DEFAULT_WRITING_MODEL );
|
||||
update_option( 'prautoblogger_editor_model', PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL );
|
||||
update_option( 'prautoblogger_migrated_gemini_flash_lite', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.0): the admin no longer has an independent
|
||||
// Image Provider dropdown; provider is derived from the image model on
|
||||
// save. Auto-heal any existing site where the saved provider doesn't
|
||||
// match the saved model's provider (the root cause of posts 650/657
|
||||
// silently missing their featured image on 2026-04-20). Runs once.
|
||||
if ( ! get_option( 'prautoblogger_migrated_image_provider_v080' ) ) {
|
||||
$saved_model = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $saved_model );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_image_provider_v080', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.8.2): reschedule the daily-generation cron in
|
||||
// the site's configured timezone. Pre-v0.8.2 activator interpreted the
|
||||
// admin "Generation Time" input as UTC; after v0.8.2 it honours the
|
||||
// site timezone. Clears the stale UTC-scheduled event and re-queues
|
||||
// using the timezone-aware helper. See class-activator.php.
|
||||
PRAutoBlogger_Activator::reschedule_daily_in_site_timezone_v082();
|
||||
|
||||
// One-time migration (v3): switch to single-panel newspaper comic style.
|
||||
// Replaces both the old infomercial pastiche and the short-lived premium
|
||||
// photography style. Force-update unless the user has a truly custom value.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v3' ) ) {
|
||||
$known_old_prefixes = array(
|
||||
'Style: a screengrab from a 1995', // v1: infomercial.
|
||||
'Style: premium scientific lifestyle', // v2: photography.
|
||||
);
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
$is_old = ( '' === $current );
|
||||
foreach ( $known_old_prefixes as $prefix ) {
|
||||
if ( false !== strpos( $current, $prefix ) ) {
|
||||
$is_old = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( $is_old ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
// Captions are now inserted as HTML below the image, not baked into the image.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
$current = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
// Detect v3 style suffix (contains "caption text" instruction).
|
||||
if ( false !== strpos( $current, 'caption text in a clean sans-serif font below the panel' ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v0.16.0): editorial pivot. The comic Style Suffix
|
||||
// is replaced by the editorial Style Template. Mirror the old suffix
|
||||
// value into a deprecated-keyed option for one version cycle (so a
|
||||
// customised value is recoverable / auditable) and seed the new
|
||||
// template with its editorial default. The old option is intentionally
|
||||
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md paragraph 8 Commit 1.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||
update_option( 'prautoblogger_image_style_suffix_deprecated', $old_suffix );
|
||||
}
|
||||
// Only seed the new template if the admin has not already set one.
|
||||
if ( '' === (string) get_option( 'prautoblogger_image_style_template', '' ) ) {
|
||||
update_option( 'prautoblogger_image_style_template', PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
$enc_options = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
foreach ( $enc_options as $opt ) {
|
||||
$val = get_option( $opt, '' );
|
||||
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {
|
||||
delete_option( $opt );
|
||||
}
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||
}
|
||||
PRAutoBlogger_DB_Migrations::run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run schema migrations during cron requests.
|
||||
*
|
||||
* Wraps on_check_db_version() so it only fires during WP-Cron execution.
|
||||
* After a deploy, the daily generation cron can fire before any admin page
|
||||
* load -- without this, new-column inserts would silently fail.
|
||||
*
|
||||
* Side effects: may run the same migrations as on_check_db_version().
|
||||
*
|
||||
* @return void
|
||||
* Run DB migrations during cron requests (hooked on `init`).
|
||||
* Only fires when wp_doing_cron() is true.
|
||||
*/
|
||||
public function on_check_db_version_for_cron(): void {
|
||||
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
||||
@@ -348,7 +200,7 @@ class PRAutoBlogger {
|
||||
/**
|
||||
* Add custom cron schedules (six-hourly for metrics collection).
|
||||
*
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing schedules.
|
||||
* @param array<string, array{interval: int, display: string}> $schedules Existing.
|
||||
* @return array<string, array{interval: int, display: string}>
|
||||
*/
|
||||
public function filter_add_cron_schedules( array $schedules ): array {
|
||||
@@ -366,7 +218,6 @@ class PRAutoBlogger {
|
||||
|
||||
/**
|
||||
* Block false update notifications from wordpress.org.
|
||||
* Our plugin slug may collide with a different plugin in the directory.
|
||||
*
|
||||
* @param object $transient The update_plugins transient data.
|
||||
* @return object Modified transient.
|
||||
|
||||
@@ -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.19.1
|
||||
* Version: 0.19.2
|
||||
* 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.19.1' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.2' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
@@ -107,6 +107,7 @@ PRAutoBlogger_Autoloader::register();
|
||||
// The main orchestrator class is loaded explicitly because its CamelCase name
|
||||
// (PRAutoBlogger) doesn't map cleanly to a filename via the autoloader's
|
||||
// kebab-case convention (it would look for class-pr-auto-blogger.php).
|
||||
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-db-migrations.php'; // Loaded explicitly: DB_Migrations -> class-d-b-migrations.php via autoloader
|
||||
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-prautoblogger.php';
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
224
templates/admin/dossier-page.php
Normal file
224
templates/admin/dossier-page.php
Normal file
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* Article Dossier template (M2 — read-only).
|
||||
*
|
||||
* Design contract: Proposal C "Editorial Record" -- warm editorial,
|
||||
* typographic hierarchy, verdict pills, receipt-style cost sidebar,
|
||||
* per-stage I/O with expandable raw-trace toggle. Vanilla JS/CSS only.
|
||||
*
|
||||
* Variables injected by PRAutoBlogger_Dossier_Page::render_page():
|
||||
* $post_id (int) -- WordPress post ID
|
||||
* $dossier (array) -- View model from PRAutoBlogger_Dossier_Data_Assembler
|
||||
* $assembler (obj) -- (not directly used here)
|
||||
*
|
||||
* @see admin/class-dossier-page.php -- Renders this template.
|
||||
* @see admin/class-dossier-data-assembler.php -- Builds $dossier.
|
||||
* @see assets/css/dossier.css -- Styling.
|
||||
* @see assets/js/dossier.js -- Raw-trace toggle.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$has_run = ! empty( $dossier['has_run'] );
|
||||
$run = $dossier['run'] ?? null;
|
||||
$stages = $dossier['stages'] ?? array();
|
||||
$decisions = $dossier['decisions'] ?? array();
|
||||
$log_index = $dossier['gen_log_index'] ?? array();
|
||||
$verdict = $dossier['verdict'] ?? '';
|
||||
$tier = $dossier['tier'] ?? '';
|
||||
$run_id = $dossier['run_id'] ?? '';
|
||||
|
||||
$log_query = new PRAutoBlogger_Dossier_Gen_Log_Query();
|
||||
$stage_totals = $log_query->aggregate_per_stage( $log_index );
|
||||
|
||||
$total_cost = 0.0;
|
||||
foreach ( $stage_totals as $st ) {
|
||||
$total_cost += $st['cost'];
|
||||
}
|
||||
|
||||
$verdict_labels = array(
|
||||
'approved' => __( 'Approved', 'prautoblogger' ),
|
||||
'revised' => __( 'Revised', 'prautoblogger' ),
|
||||
'rejected' => __( 'Rejected', 'prautoblogger' ),
|
||||
'halted' => __( 'Halted', 'prautoblogger' ),
|
||||
'pending' => __( 'Pending', 'prautoblogger' ),
|
||||
);
|
||||
|
||||
$back_url = admin_url( 'admin.php?page=prautoblogger-board' );
|
||||
?>
|
||||
<div class="wrap prab-dossier-wrap">
|
||||
|
||||
<?php // ── Back link ──────────────────────────────────────────────────────── ?>
|
||||
<p class="prab-dossier-back">
|
||||
<a href="<?php echo esc_url( $back_url ); ?>">
|
||||
← <?php esc_html_e( 'Back to Board', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<?php if ( ! $has_run ) : ?>
|
||||
<?php // ── Graceful empty state (legacy post or invalid id) ───────────────── ?>
|
||||
<div id="prab-dossier" class="prab-dossier prab-dossier--empty">
|
||||
<div class="prab-dossier-empty-notice">
|
||||
<h2><?php esc_html_e( 'No generation record', 'prautoblogger' ); ?></h2>
|
||||
<?php if ( $post_id > 0 && ! empty( $dossier['post_title'] ) ) : ?>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: article title */
|
||||
esc_html__( 'The article "%s" was published before the generation substrate was introduced (v0.18.0). No dossier data is available.', 'prautoblogger' ),
|
||||
esc_html( $dossier['post_title'] )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php elseif ( $post_id <= 0 ) : ?>
|
||||
<p><?php esc_html_e( 'No article was specified. Use a board card or post link to reach a dossier.', 'prautoblogger' ); ?></p>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e( 'No run record was found for this article.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php return; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── Full dossier ────────────────────────────────────────────────────── ?>
|
||||
<div id="prab-dossier" class="prab-dossier">
|
||||
|
||||
<?php // ── Article header ──────────────────────────────────────────────── ?>
|
||||
<div class="prab-dossier-header">
|
||||
<div class="prab-dossier-header-main">
|
||||
<h1 class="prab-dossier-title"><?php echo esc_html( $dossier['post_title'] ); ?></h1>
|
||||
<div class="prab-dossier-header-meta">
|
||||
<?php if ( '' !== $verdict ) : ?>
|
||||
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( $verdict ); ?>">
|
||||
<?php echo esc_html( $verdict_labels[ $verdict ] ?? ucfirst( $verdict ) ); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( '' !== $tier ) : ?>
|
||||
<span class="prab-dossier-tier"><?php echo esc_html( ucfirst( $tier ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-dossier-status prab-dossier-status--<?php echo esc_attr( $dossier['post_status'] ?? '' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $dossier['post_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<?php if ( $post_id > 0 ) : ?>
|
||||
<div class="prab-dossier-header-actions">
|
||||
<a href="<?php echo esc_url( get_edit_post_link( $post_id, 'raw' ) ?? '' ); ?>" class="button">
|
||||
<?php esc_html_e( 'Edit Article', 'prautoblogger' ); ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="prab-dossier-body">
|
||||
|
||||
<?php // ── Main column: stage sections ─────────────────────────────── ?>
|
||||
<div class="prab-dossier-main">
|
||||
|
||||
<?php
|
||||
$stage_order = array( 'analysis', 'llm_research', 'research', 'curate', 'outline', 'draft', 'polish', 'review', 'editorial', 'seo', 'publish', 'image_a', 'image_b' );
|
||||
$rendered_stages = array();
|
||||
|
||||
// Render stages in canonical order first, then any extra rows.
|
||||
foreach ( $stage_order as $stage_name ) {
|
||||
foreach ( $stages as $role_key => $stage_row ) {
|
||||
if ( ( $stage_row['stage'] ?? '' ) !== $stage_name ) {
|
||||
continue;
|
||||
}
|
||||
$rendered_stages[] = $role_key;
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||
}
|
||||
}
|
||||
// Remaining unordered stages.
|
||||
foreach ( $stages as $role_key => $stage_row ) {
|
||||
if ( in_array( $role_key, $rendered_stages, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$stage_name = (string) ( $stage_row['stage'] ?? 'unknown' );
|
||||
$log_rows = $log_index[ $stage_name ] ?? array();
|
||||
require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php';
|
||||
}
|
||||
|
||||
if ( empty( $stages ) ) :
|
||||
?>
|
||||
<div class="prab-dossier-no-stages">
|
||||
<p><?php esc_html_e( 'Stage data is not yet available for this run.', 'prautoblogger' ); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php // ── Editorial decisions section ─────────────────────────────── ?>
|
||||
<?php if ( ! empty( $decisions ) ) : ?>
|
||||
<div class="prab-dossier-section prab-dossier-section--decisions">
|
||||
<h2 class="prab-section-title"><?php esc_html_e( 'Editorial Decisions', 'prautoblogger' ); ?></h2>
|
||||
<?php foreach ( $decisions as $d_stage => $d ) : ?>
|
||||
<div class="prab-decision-row">
|
||||
<span class="prab-decision-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $d_stage ) ); ?></span>
|
||||
<span class="prab-verdict-pill prab-verdict-pill--<?php echo esc_attr( (string) ( $d['verdict'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( ucfirst( (string) ( $d['verdict'] ?? '' ) ) ); ?>
|
||||
</span>
|
||||
<?php if ( ! empty( $d['rationale'] ) ) : ?>
|
||||
<p class="prab-decision-rationale"><?php echo esc_html( (string) $d['rationale'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .prab-dossier-main -->
|
||||
|
||||
<?php // ── Sidebar: metadata + cost receipt ───────────────────────── ?>
|
||||
<div class="prab-dossier-sidebar">
|
||||
|
||||
<div class="prab-sidebar-card">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Run Info', 'prautoblogger' ); ?></h3>
|
||||
<?php if ( null !== $run ) : ?>
|
||||
<dl class="prab-sidebar-dl">
|
||||
<dt><?php esc_html_e( 'Status', 'prautoblogger' ); ?></dt>
|
||||
<dd><span class="prab-run-status prab-run-status--<?php echo esc_attr( (string) ( $run['status'] ?? '' ) ); ?>"><?php echo esc_html( ucfirst( (string) ( $run['status'] ?? '' ) ) ); ?></span></dd>
|
||||
<dt><?php esc_html_e( 'Started', 'prautoblogger' ); ?></dt>
|
||||
<dd><?php echo esc_html( (string) ( $run['started_at'] ?? '—' ) ); ?></dd>
|
||||
<?php if ( ! empty( $run['finished_at'] ) ) : ?>
|
||||
<dt><?php esc_html_e( 'Finished', 'prautoblogger' ); ?></dt>
|
||||
<dd><?php echo esc_html( (string) $run['finished_at'] ); ?></dd>
|
||||
<?php endif; ?>
|
||||
<dt><?php esc_html_e( 'Run ID', 'prautoblogger' ); ?></dt>
|
||||
<dd class="prab-run-id"><?php echo esc_html( $run_id ); ?></dd>
|
||||
</dl>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="prab-sidebar-card prab-cost-receipt">
|
||||
<h3 class="prab-sidebar-heading"><?php esc_html_e( 'Cost Receipt', 'prautoblogger' ); ?></h3>
|
||||
<table class="prab-receipt-table">
|
||||
<tbody>
|
||||
<?php foreach ( $stage_totals as $st_name => $st ) : ?>
|
||||
<?php
|
||||
if ( $st['cost'] <= 0.0 && 0 === $st['calls'] ) :
|
||||
continue;
|
||||
endif;
|
||||
?>
|
||||
<tr>
|
||||
<td class="prab-receipt-stage"><?php echo esc_html( PRAutoBlogger_Stage_Display_Map::label( $st_name ) ); ?></td>
|
||||
<td class="prab-receipt-cost">$<?php echo esc_html( number_format( $st['cost'], 5 ) ); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="prab-receipt-total">
|
||||
<td><?php esc_html_e( 'Total', 'prautoblogger' ); ?></td>
|
||||
<td>$<?php echo esc_html( number_format( $total_cost, 5 ) ); ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div><!-- .prab-dossier-sidebar -->
|
||||
|
||||
</div><!-- .prab-dossier-body -->
|
||||
|
||||
</div><!-- #prab-dossier -->
|
||||
|
||||
</div><!-- .prab-dossier-wrap -->
|
||||
126
templates/admin/dossier-stage-section.php
Normal file
126
templates/admin/dossier-stage-section.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
/**
|
||||
* Partial: single stage section for the Article Dossier.
|
||||
*
|
||||
* Variables injected by dossier-page.php:
|
||||
* $stage_row (array) -- run_stages row incl. display_output
|
||||
* $stage_name (string) -- stage key
|
||||
* $log_rows (array) -- generation_log rows for this stage
|
||||
*
|
||||
* Security: all model output escaped via esc_html() or wp_kses_post().
|
||||
* request_json escaped via esc_html() -- treat as untrusted HTML.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$s_label = PRAutoBlogger_Stage_Display_Map::label( $stage_name );
|
||||
$s_status = (string) ( $stage_row['status'] ?? '' );
|
||||
$s_output = $stage_row['display_output'] ?? null;
|
||||
$s_cost = (float) ( $stage_row['cost_usd'] ?? 0 );
|
||||
$s_role = (string) ( $stage_row['agent_role'] ?? '' );
|
||||
$s_started = (string) ( $stage_row['started_at'] ?? '' );
|
||||
$s_finished = (string) ( $stage_row['finished_at'] ?? '' );
|
||||
$output_pruned = ( ! empty( $stage_row['status'] ) && 'done' === $stage_row['status'] && null === $s_output );
|
||||
|
||||
// Determine prompt version from gen_log (first row for this stage).
|
||||
$first_log = ! empty( $log_rows ) ? $log_rows[0] : null;
|
||||
$s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $first_log['model'] : '—';
|
||||
$s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—';
|
||||
$has_raw_trace = ! empty( $log_rows );
|
||||
$section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role );
|
||||
?>
|
||||
<div class="prab-dossier-section prab-dossier-section--stage prab-stage-status--<?php echo esc_attr( $s_status ); ?>"
|
||||
data-stage="<?php echo esc_attr( $stage_name ); ?>"
|
||||
id="<?php echo $section_id; // Already escaped. ?>">
|
||||
|
||||
<div class="prab-stage-header">
|
||||
<h2 class="prab-section-title">
|
||||
<?php echo esc_html( $s_label ); ?>
|
||||
<?php if ( '' !== $s_role ) : ?>
|
||||
<span class="prab-stage-role">(<?php echo esc_html( $s_role ); ?>)</span>
|
||||
<?php endif; ?>
|
||||
</h2>
|
||||
<div class="prab-stage-header-meta">
|
||||
<span class="prab-stage-status-pill prab-stage-status-pill--<?php echo esc_attr( $s_status ); ?>">
|
||||
<?php echo esc_html( ucfirst( $s_status ) ); ?>
|
||||
</span>
|
||||
<?php if ( $s_cost > 0 ) : ?>
|
||||
<span class="prab-stage-cost">$<?php echo esc_html( number_format( $s_cost, 5 ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
<span class="prab-stage-model"><?php echo esc_html( $s_model ); ?></span>
|
||||
<?php if ( '—' !== $s_pv ) : ?>
|
||||
<span class="prab-stage-pv">pv <?php echo esc_html( $s_pv ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php // ── Output ──────────────────────────────────────────────────────── ?>
|
||||
<div class="prab-stage-output">
|
||||
<?php if ( $output_pruned ) : ?>
|
||||
<p class="prab-output-pruned">
|
||||
<?php
|
||||
$days = (int) get_option( 'prautoblogger_request_json_retention_days', 14 );
|
||||
printf(
|
||||
/* translators: %d: retention days */
|
||||
esc_html__( 'Output retained for %d days — currently unavailable (pruned by retention cron).', 'prautoblogger' ),
|
||||
$days
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php elseif ( null !== $s_output ) : ?>
|
||||
<div class="prab-stage-output-rendered">
|
||||
<?php echo wp_kses_post( $s_output ); ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<p class="prab-output-absent"><?php esc_html_e( 'No output recorded for this stage.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php // ── Raw trace toggle ─────────────────────────────────────────────── ?>
|
||||
<?php if ( $has_raw_trace ) : ?>
|
||||
<div class="prab-stage-trace">
|
||||
<button type="button" class="prab-trace-toggle button-link"
|
||||
aria-expanded="false"
|
||||
aria-controls="<?php echo $section_id; ?>-trace">
|
||||
<?php esc_html_e( 'Show raw trace', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<div id="<?php echo $section_id; ?>-trace" class="prab-trace-detail" hidden>
|
||||
<?php foreach ( $log_rows as $log_row ) : ?>
|
||||
<div class="prab-trace-entry">
|
||||
<div class="prab-trace-meta">
|
||||
<span><?php echo esc_html( (string) ( $log_row['model'] ?? '—' ) ); ?></span>
|
||||
<span><?php printf( esc_html__( '%1$d prompt + %2$d completion tokens', 'prautoblogger' ), (int) ( $log_row['prompt_tokens'] ?? 0 ), (int) ( $log_row['completion_tokens'] ?? 0 ) ); ?></span>
|
||||
<span>$<?php echo esc_html( number_format( (float) ( $log_row['estimated_cost'] ?? 0 ), 6 ) ); ?></span>
|
||||
<span class="prab-trace-status prab-trace-status--<?php echo esc_attr( (string) ( $log_row['response_status'] ?? '' ) ); ?>">
|
||||
<?php echo esc_html( (string) ( $log_row['response_status'] ?? '' ) ); ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if ( ! empty( $log_row['request_json'] ) ) : ?>
|
||||
<details class="prab-trace-request">
|
||||
<summary><?php esc_html_e( 'Request payload', 'prautoblogger' ); ?></summary>
|
||||
<?php // SECURITY: esc_html() treats model output as untrusted HTML. ?>
|
||||
<pre class="prab-trace-pre"><?php echo esc_html( (string) $log_row['request_json'] ); ?></pre>
|
||||
</details>
|
||||
<?php endif; ?>
|
||||
<?php if ( ! empty( $log_row['error_message'] ) ) : ?>
|
||||
<p class="prab-trace-error"><?php echo esc_html( (string) $log_row['error_message'] ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( '' !== $s_started ) : ?>
|
||||
<div class="prab-stage-timestamps">
|
||||
<span class="prab-ts"><?php echo esc_html( $s_started ); ?></span>
|
||||
<?php if ( '' !== $s_finished ) : ?>
|
||||
<span class="prab-ts-sep">→</span>
|
||||
<span class="prab-ts"><?php echo esc_html( $s_finished ); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .prab-dossier-section--stage -->
|
||||
41
templates/admin/metabox-dossier-link.php
Normal file
41
templates/admin/metabox-dossier-link.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* Post metabox template (slimmed, M2): "View generation dossier" link.
|
||||
*
|
||||
* The full generation record is available on the Article Dossier page.
|
||||
* This metabox replaces the previous inline metadata display to avoid
|
||||
* duplicating data that lives in the dossier.
|
||||
*
|
||||
* @see admin/class-post-metabox.php -- Renders this template.
|
||||
* @see admin/class-dossier-page.php -- Dossier target page.
|
||||
*
|
||||
* @var WP_Post $post The current post.
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||
$url = PRAutoBlogger_Dossier_Page::url_for_post( $post->ID );
|
||||
?>
|
||||
<div class="prab-metabox-dossier">
|
||||
<?php if ( '' !== $run_id ) : ?>
|
||||
<p>
|
||||
<a href="<?php echo esc_url( $url ); ?>" class="button button-secondary">
|
||||
<?php esc_html_e( 'View generation dossier', 'prautoblogger' ); ?> →
|
||||
</a>
|
||||
</p>
|
||||
<p class="description prab-run-id-label">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: run UUID */
|
||||
esc_html__( 'Run: %s', 'prautoblogger' ),
|
||||
esc_html( $run_id )
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<?php else : ?>
|
||||
<p class="description"><?php esc_html_e( 'No generation record — article was not created by PRAutoBlogger.', 'prautoblogger' ); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -173,7 +173,11 @@ class BoardDataProviderTest extends BaseTestCase {
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 42, $cards[0]['post_id'] );
|
||||
$this->assertSame( 'Test Draft Post', $cards[0]['title'] );
|
||||
$this->assertSame( 'review', $cards[0]['click_action'] );
|
||||
// M2: primary click_action is dossier; secondary review_url/edit_url still present.
|
||||
$this->assertSame( 'dossier', $cards[0]['click_action'] );
|
||||
$this->assertStringContainsString( 'prautoblogger-dossier&post_id=42', $cards[0]['dossier_url'] );
|
||||
$this->assertArrayHasKey( 'review_url', $cards[0] );
|
||||
$this->assertArrayHasKey( 'edit_url', $cards[0] );
|
||||
$this->assertEqualsWithDelta( 0.012, $cards[0]['cost_total'], 0.0001 );
|
||||
}
|
||||
|
||||
@@ -216,7 +220,11 @@ class BoardDataProviderTest extends BaseTestCase {
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 77, $cards[0]['post_id'] );
|
||||
$this->assertSame( 'edit', $cards[0]['click_action'] );
|
||||
// M2: primary click_action is dossier; secondary edit_url/post_url still present.
|
||||
$this->assertSame( 'dossier', $cards[0]['click_action'] );
|
||||
$this->assertStringContainsString( 'prautoblogger-dossier&post_id=77', $cards[0]['dossier_url'] );
|
||||
$this->assertArrayHasKey( 'edit_url', $cards[0] );
|
||||
$this->assertArrayHasKey( 'post_url', $cards[0] );
|
||||
}
|
||||
|
||||
// ── Failed cards ────────────────────────────────────────────────────────
|
||||
|
||||
241
tests/unit/Admin/DossierDataAssemblerTest.php
Normal file
241
tests/unit/Admin/DossierDataAssemblerTest.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Dossier_Data_Assembler -- view model assembly.
|
||||
*
|
||||
* Covers:
|
||||
* - Full modern run: runs + stages + gen_log + decisions
|
||||
* - Legacy absent: post has no run_id meta
|
||||
* - Zero post_id: bogus ID
|
||||
* - Amortized research: gen_log row with prompt_version=NULL, agent_role=''
|
||||
* - Pruned meta_json: stage row with meta_json=NULL, status=done
|
||||
* - Decisions present and absent
|
||||
*
|
||||
* All DB queries are mocked via $wpdb mock. No live DB required.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierDataAssemblerTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock gen_log_query. */
|
||||
private $log_query;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// Stub PRAutoBlogger_Run_Stage_State::is_available() to return true.
|
||||
// Static method -- stubbed via wpdb table check returning the table name.
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
|
||||
// table_name returns wp_prautoblogger_run_stages.
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
|
||||
// is_available() calls $wpdb->get_var(SHOW TABLES LIKE ...) -- return the table.
|
||||
$run_stages_table = 'wp_prautoblogger_run_stages';
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql ) use ( $run_stages_table ) {
|
||||
// Return something so prepare always works.
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( null );
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
} );
|
||||
|
||||
$this->log_query = $this->getMockBuilder( \PRAutoBlogger_Dossier_Gen_Log_Query::class )
|
||||
->onlyMethods( array( 'get_by_run' ) )
|
||||
->getMock();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Zero / invalid post ID
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_zero_post_id_returns_no_run(): void {
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 0 );
|
||||
|
||||
$this->assertFalse( $result['has_run'] );
|
||||
$this->assertEmpty( $result['stages'] );
|
||||
}
|
||||
|
||||
public function test_nonexistent_post_returns_no_run(): void {
|
||||
Functions\when( 'get_post' )->justReturn( null );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 9999 );
|
||||
|
||||
$this->assertFalse( $result['has_run'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Legacy post (no run_id meta)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_legacy_post_no_run_id_returns_no_run(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 100;
|
||||
$post->post_title = 'Old Article';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->justReturn( '' );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 100 );
|
||||
|
||||
$this->assertFalse( $result['has_run'],
|
||||
'Legacy post without run_id must return has_run=false' );
|
||||
$this->assertSame( 'Old Article', $result['post_title'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Full modern run
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_full_run_assembles_correctly(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 925;
|
||||
$post->post_title = 'BPC-157 Research Overview';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
$run_id = 'abc-123-def-456';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $run_id ) {
|
||||
if ( '_prautoblogger_run_id' === $key ) { return $run_id; }
|
||||
if ( '_prautoblogger_editor_verdict' === $key ) { return 'approved'; }
|
||||
if ( '_prautoblogger_article_type' === $key ) { return 'authority'; }
|
||||
return '';
|
||||
}
|
||||
);
|
||||
|
||||
// Mock runs table query.
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array(
|
||||
'run_id' => $run_id,
|
||||
'status' => 'done',
|
||||
'started_at' => '2026-06-12 03:00:00',
|
||||
'finished_at' => '2026-06-12 03:04:00',
|
||||
'settled_usd' => '0.012000',
|
||||
) );
|
||||
|
||||
// Mock run_stages query -- get_results returns stage rows.
|
||||
$stage_rows = array(
|
||||
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'draft', 'agent_role' => 'writer', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.008', 'meta_json' => wp_json_encode( array( 'output' => 'Draft content here.' ) ), 'started_at' => '2026-06-12 03:01:00', 'finished_at' => '2026-06-12 03:02:00' ),
|
||||
array( 'id' => 2, 'run_id' => $run_id, 'stage' => 'review', 'agent_role' => 'editor', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.004', 'meta_json' => wp_json_encode( array( 'output' => 'Looks good.' ) ), 'started_at' => '2026-06-12 03:02:00', 'finished_at' => '2026-06-12 03:03:00' ),
|
||||
);
|
||||
|
||||
// is_available check: get_var returns the table name.
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( $stage_rows );
|
||||
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array(
|
||||
'draft' => array( array( 'model' => 'gpt-4', 'prompt_version' => '3', 'agent_role' => 'writer', 'prompt_tokens' => 500, 'completion_tokens' => 1000, 'estimated_cost' => '0.008', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:01:00' ) ),
|
||||
'review' => array( array( 'model' => 'gpt-4', 'prompt_version' => '2', 'agent_role' => 'editor', 'prompt_tokens' => 200, 'completion_tokens' => 50, 'estimated_cost' => '0.004', 'request_json' => '{"model":"gpt-4"}', 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:02:00' ) ),
|
||||
) );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 925 );
|
||||
|
||||
$this->assertTrue( $result['has_run'], 'Modern run must have has_run=true' );
|
||||
$this->assertSame( $run_id, $result['run_id'] );
|
||||
$this->assertSame( 'approved', $result['verdict'] );
|
||||
$this->assertSame( 'authority', $result['tier'] );
|
||||
$this->assertNotEmpty( $result['stages'], 'Stage rows must be present' );
|
||||
$this->assertNotEmpty( $result['gen_log_index'], 'Gen_log index must be present' );
|
||||
|
||||
// Check draft stage has display_output set.
|
||||
$draft_key = 'draft:writer';
|
||||
$this->assertArrayHasKey( $draft_key, $result['stages'] );
|
||||
$this->assertSame( 'Draft content here.', $result['stages'][ $draft_key ]['display_output'] );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Amortized research row (pv=null, role='')
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_amortized_research_row_renders_gracefully(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 930;
|
||||
$post->post_title = 'TB-500 Article';
|
||||
$post->post_status = 'publish';
|
||||
|
||||
$run_id = 'llm-research-run-001';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias(
|
||||
function ( $post_id, $key, $single ) use ( $run_id ) {
|
||||
return '_prautoblogger_run_id' === $key ? $run_id : '';
|
||||
}
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done', 'started_at' => '2026-06-12 03:00:00' ) );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
|
||||
// Amortized research row: pv=null, role=''.
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array(
|
||||
array( 'id' => 1, 'run_id' => $run_id, 'stage' => 'llm_research', 'agent_role' => '', 'item_key' => '', 'status' => 'done', 'cost_usd' => '0.001', 'meta_json' => null, 'started_at' => '2026-06-12 03:00:00', 'finished_at' => '2026-06-12 03:00:30' ),
|
||||
) );
|
||||
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array(
|
||||
'llm_research' => array( array( 'model' => 'gpt-4', 'prompt_version' => null, 'agent_role' => '', 'prompt_tokens' => 100, 'completion_tokens' => 50, 'estimated_cost' => '0.001', 'request_json' => null, 'response_status' => 'success', 'error_message' => null, 'created_at' => '2026-06-12 03:00:00' ) ),
|
||||
) );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
|
||||
// Must not throw or produce fatal.
|
||||
$result = $assembler->assemble( 930 );
|
||||
|
||||
$this->assertTrue( $result['has_run'] );
|
||||
|
||||
// Stage is present; display_output is null (meta_json was null = pruned).
|
||||
$llm_key = 'llm_research:';
|
||||
$this->assertArrayHasKey( $llm_key, $result['stages'] );
|
||||
$this->assertNull( $result['stages'][ $llm_key ]['display_output'],
|
||||
'Amortized research with null meta_json must have null display_output' );
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Decisions present and absent
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function test_decisions_absent_returns_empty_array(): void {
|
||||
$post = new \WP_Post();
|
||||
$post->ID = 901; $post->post_title = 'Test'; $post->post_status = 'publish';
|
||||
$run_id = 'no-decisions-run';
|
||||
|
||||
Functions\when( 'get_post' )->justReturn( $post );
|
||||
Functions\when( 'get_post_meta' )->alias( fn( $id, $key ) => '_prautoblogger_run_id' === $key ? $run_id : '' );
|
||||
$this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => $run_id, 'status' => 'done' ) );
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() ); // no stages, no decisions
|
||||
$this->log_query->method( 'get_by_run' )->willReturn( array() );
|
||||
|
||||
$assembler = new \PRAutoBlogger_Dossier_Data_Assembler( $this->log_query );
|
||||
$result = $assembler->assemble( 901 );
|
||||
|
||||
$this->assertIsArray( $result['decisions'] );
|
||||
$this->assertEmpty( $result['decisions'] );
|
||||
}
|
||||
}
|
||||
149
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
149
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for Article Dossier admin page menu registration.
|
||||
*
|
||||
* Regression guard for the menu-ordering rule (v0.19.1 lesson):
|
||||
* any submenu registered BEFORE add_menu_page fires will resolve under the
|
||||
* fallback hookname `admin_page_*` instead of `prautoblogger_page_*`, causing
|
||||
* a wp_die 404 at request time.
|
||||
*
|
||||
* The dossier page uses a hidden-submenu pattern (removed from $submenu after
|
||||
* registration) at priority 12 -- after board (11) and parent (10).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class DossierMenuRegistrationTest extends BaseTestCase {
|
||||
|
||||
/** @var array<int, string> Call sequence spy. */
|
||||
private array $call_sequence = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->call_sequence = [];
|
||||
|
||||
Functions\when( 'add_menu_page' )->alias(
|
||||
function () {
|
||||
$this->call_sequence[] = 'add_menu_page';
|
||||
return 'prautoblogger-settings';
|
||||
}
|
||||
);
|
||||
Functions\when( 'add_submenu_page' )->alias(
|
||||
function ( string $parent_slug, string $page_title, string $menu_title, string $capability, string $menu_slug ) {
|
||||
$this->call_sequence[] = 'add_submenu_page:' . $menu_slug;
|
||||
return 'prautoblogger_page_' . $menu_slug;
|
||||
}
|
||||
);
|
||||
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
Functions\when( 'get_option' )->justReturn( false );
|
||||
Functions\when( 'admin_url' )->alias( function ( $path = '' ) {
|
||||
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
} );
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
||||
$GLOBALS['submenu'] = array();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->call_sequence = [];
|
||||
unset( $GLOBALS['submenu'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier on_register_menu must be hooked at priority > board (11).
|
||||
* Mirrors the bindings in register_admin_hooks():
|
||||
* parent at 10, board at 11, dossier at 12.
|
||||
*
|
||||
* FAILS if dossier drops to priority ≤ 11 (same as or before board).
|
||||
* FAILS if dossier drops to priority ≤ 10 (same as or before parent).
|
||||
*/
|
||||
public function test_dossier_hook_registered_after_board(): void {
|
||||
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||
$board_page = new \PRAutoBlogger_Board_Page();
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
|
||||
// Mirror class-prautoblogger.php register_admin_hooks() priorities.
|
||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||
add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 );
|
||||
|
||||
$storage = Monkey\Container::instance()->hookStorage();
|
||||
$ref = new \ReflectionClass( $storage );
|
||||
$prop = $ref->getProperty( 'storage' );
|
||||
$prop->setAccessible( true );
|
||||
$data = $prop->getValue( $storage );
|
||||
|
||||
$parent_priority = -1;
|
||||
$board_priority = -1;
|
||||
$dossier_priority = -1;
|
||||
|
||||
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
|
||||
foreach ( $admin_menu_entries as $entry ) {
|
||||
$cb = (string) ( $entry[0] ?? '' );
|
||||
$priority = (int) ( $entry[1] ?? -1 );
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Admin_Page' ) ) { $parent_priority = $priority; }
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Board_Page' ) ) { $board_priority = $priority; }
|
||||
if ( false !== strpos( $cb, 'PRAutoBlogger_Dossier_Page' ) ) { $dossier_priority = $priority; }
|
||||
}
|
||||
|
||||
$this->assertGreaterThan( $parent_priority, $board_priority,
|
||||
'Board priority must exceed parent priority.' );
|
||||
$this->assertGreaterThan( $board_priority, $dossier_priority,
|
||||
'Dossier priority must exceed board priority to ensure hookname resolves correctly.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier submenu slug must be 'prautoblogger-dossier'.
|
||||
*/
|
||||
public function test_dossier_uses_correct_slug(): void {
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$found_slug = null;
|
||||
foreach ( $this->call_sequence as $entry ) {
|
||||
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
|
||||
$found_slug = substr( $entry, strlen( 'add_submenu_page:' ) );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $found_slug,
|
||||
'Dossier must register under PAGE_SLUG.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier submenu parent must be 'prautoblogger-settings'.
|
||||
*/
|
||||
public function test_dossier_parent_slug_is_settings(): void {
|
||||
$captured_parent = null;
|
||||
Functions\when( 'add_submenu_page' )->alias(
|
||||
function ( string $parent_slug ) use ( &$captured_parent ) {
|
||||
$captured_parent = $parent_slug;
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||
$dossier_page->on_register_menu();
|
||||
|
||||
$this->assertSame( 'prautoblogger-settings', $captured_parent,
|
||||
'Dossier must use prautoblogger-settings as parent.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* url_for_post() returns a URL containing the dossier slug and post_id.
|
||||
*/
|
||||
public function test_url_for_post_contains_slug_and_post_id(): void {
|
||||
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 42 );
|
||||
$this->assertStringContainsString( \PRAutoBlogger_Dossier_Page::PAGE_SLUG, $url );
|
||||
$this->assertStringContainsString( '42', $url );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user