diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b86110c..dbf480d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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=`. 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 `
` 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.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a46d12b..772e01c 100644
--- a/CHANGELOG.md
+++ b/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=`. 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
diff --git a/assets/css/dossier.css b/assets/css/dossier.css
new file mode 100644
index 0000000..259b2b7
--- /dev/null
+++ b/assets/css/dossier.css
@@ -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;
+}
diff --git a/assets/js/board.js b/assets/js/board.js
index f06e566..38f697a 100644
--- a/assets/js/board.js
+++ b/assets/js/board.js
@@ -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 ) {
diff --git a/assets/js/dossier.js b/assets/js/dossier.js
new file mode 100644
index 0000000..4a597fe
--- /dev/null
+++ b/assets/js/dossier.js
@@ -0,0 +1,41 @@
+/**
+ * PRAutoBlogger Article Dossier -- raw-trace toggle (M2).
+ *
+ * Toggles the hidden raw-trace 
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' ); + } + } ); + } ); + } ); +}() ); diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php index 35a25dc..d2fbf15 100644 --- a/includes/admin/class-board-data-provider.php +++ b/includes/admin/class-board-data-provider.php @@ -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>, * in_review: array>, @@ -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> */ @@ -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> */ @@ -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> */ @@ -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> */ @@ -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 ), ); diff --git a/includes/admin/class-dossier-data-assembler.php b/includes/admin/class-dossier-data-assembler.php new file mode 100644 index 0000000..23a8359 --- /dev/null +++ b/includes/admin/class-dossier-data-assembler.php @@ -0,0 +1,191 @@ + 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 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|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> 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 $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> 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; + } +} diff --git a/includes/admin/class-dossier-gen-log-query.php b/includes/admin/class-dossier-gen-log-query.php new file mode 100644 index 0000000..ced0d63 --- /dev/null +++ b/includes/admin/class-dossier-gen-log-query.php @@ -0,0 +1,95 @@ +>> 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>> $log_index Output of get_by_run(). + * @return array + */ + 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; + } +} diff --git a/includes/admin/class-dossier-page.php b/includes/admin/class-dossier-page.php new file mode 100644 index 0000000..f2ae1e2 --- /dev/null +++ b/includes/admin/class-dossier-page.php @@ -0,0 +1,117 @@ + $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 ); + } +} diff --git a/includes/admin/class-post-metabox.php b/includes/admin/class-post-metabox.php index 63ad522..ff18823 100755 --- a/includes/admin/class-post-metabox.php +++ b/includes/admin/class-post-metabox.php @@ -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'; } } diff --git a/includes/class-db-migrations.php b/includes/class-db-migrations.php new file mode 100644 index 0000000..823186b --- /dev/null +++ b/includes/class-db-migrations.php @@ -0,0 +1,129 @@ +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 $schedules Existing schedules. + * @param array $schedules Existing. * @return array */ 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. diff --git a/prautoblogger.php b/prautoblogger.php index 47b247f..912d2e0 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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'; /* |-------------------------------------------------------------------------- diff --git a/templates/admin/dossier-page.php b/templates/admin/dossier-page.php new file mode 100644 index 0000000..7b15c24 --- /dev/null +++ b/templates/admin/dossier-page.php @@ -0,0 +1,224 @@ +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' ); +?> +
+ + +

+ + ← + +

+ + + +
+
+

+ 0 && ! empty( $dossier['post_title'] ) ) : ?> +

+ +

+ +

+ +

+ +
+
+ + + + +
+ + +
+
+

+
+ + + + + + + + + + + +
+
+ 0 ) : ?> +
+ + + +
+ +
+ +
+ + +
+ + $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 ) ) : + ?> +
+

+
+ + + + +
+

+ $d ) : ?> +
+ + + + + +

+ +
+ +
+ + +
+ + +
+ +
+

+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
+ +
+

+ + + $st ) : ?> + + + + + + + + + + + + + +
$
$
+
+ +
+ +
+ +
+ +
diff --git a/templates/admin/dossier-stage-section.php b/templates/admin/dossier-stage-section.php new file mode 100644 index 0000000..5b7113c --- /dev/null +++ b/templates/admin/dossier-stage-section.php @@ -0,0 +1,126 @@ + +
+ +
+

+ + + () + +

+
+ + + + 0 ) : ?> + $ + + + + pv + +
+
+ + +
+ +

+ +

+ +
+ +
+ +

+ +
+ + + +
+ + +
+ + + +
+ + + → + + +
+ + +
diff --git a/templates/admin/metabox-dossier-link.php b/templates/admin/metabox-dossier-link.php new file mode 100644 index 0000000..501277d --- /dev/null +++ b/templates/admin/metabox-dossier-link.php @@ -0,0 +1,41 @@ +ID, '_prautoblogger_run_id', true ); +$url = PRAutoBlogger_Dossier_Page::url_for_post( $post->ID ); +?> +
+ +

+ + → + +

+

+ +

+ +

+ +
diff --git a/tests/unit/Admin/BoardDataProviderTest.php b/tests/unit/Admin/BoardDataProviderTest.php index 8adc485..aa1354e 100644 --- a/tests/unit/Admin/BoardDataProviderTest.php +++ b/tests/unit/Admin/BoardDataProviderTest.php @@ -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 ──────────────────────────────────────────────────────── diff --git a/tests/unit/Admin/DossierDataAssemblerTest.php b/tests/unit/Admin/DossierDataAssemblerTest.php new file mode 100644 index 0000000..82355d4 --- /dev/null +++ b/tests/unit/Admin/DossierDataAssemblerTest.php @@ -0,0 +1,241 @@ +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'] ); + } +} diff --git a/tests/unit/Admin/DossierMenuRegistrationTest.php b/tests/unit/Admin/DossierMenuRegistrationTest.php new file mode 100644 index 0000000..fceb9c8 --- /dev/null +++ b/tests/unit/Admin/DossierMenuRegistrationTest.php @@ -0,0 +1,149 @@ + 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 ); + } +}