Compare commits
5 Commits
claude/pha
...
claude/dos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66891267be | ||
|
|
ae32876b6b | ||
|
|
9c8e500244 | ||
|
|
2911c94f57 | ||
|
|
49f17c3d6f |
117
ARCHITECTURE.md
117
ARCHITECTURE.md
@@ -119,13 +119,18 @@ prautoblogger/
|
|||||||
│ │ └── OFL.txt # SIL Open Font License
|
│ │ └── OFL.txt # SIL Open Font License
|
||||||
│ ├── css/
|
│ ├── css/
|
||||||
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
|
│ │ ├── 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)
|
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.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)
|
│ └── posts-widget.js # React component for frontend post cards (wp.element)
|
||||||
│
|
│
|
||||||
├── includes/
|
├── 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-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-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
|
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
|
||||||
@@ -137,6 +142,12 @@ prautoblogger/
|
|||||||
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
|
||||||
│ │
|
│ │
|
||||||
│ ├── admin/
|
│ ├── admin/
|
||||||
|
│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0)
|
||||||
|
│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot + dossier deep-links (v0.19.0, v0.19.2)
|
||||||
|
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0)
|
||||||
|
│ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3)
|
||||||
|
│ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2)
|
||||||
|
│ │ ├── 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-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.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
|
||||||
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
|
||||||
@@ -239,11 +250,15 @@ prautoblogger/
|
|||||||
│
|
│
|
||||||
├── templates/
|
├── templates/
|
||||||
│ └── admin/
|
│ └── 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)
|
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
|
||||||
│ ├── metrics-page.php # Metrics/cost dashboard template
|
│ ├── metrics-page.php # Metrics/cost dashboard template
|
||||||
│ ├── review-queue.php # Review queue table template
|
│ ├── review-queue.php # Review queue table template
|
||||||
│ ├── log-viewer.php # Activity log viewer 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/
|
├── languages/
|
||||||
│ └── prautoblogger.pot # i18n translation template
|
│ └── prautoblogger.pot # i18n translation template
|
||||||
@@ -608,6 +623,8 @@ All prefixed with `prautoblogger_`:
|
|||||||
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
|
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
|
||||||
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
|
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
|
||||||
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
|
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
|
||||||
|
| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. |
|
||||||
|
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
|
||||||
|
|
||||||
### Post Meta
|
### Post Meta
|
||||||
|
|
||||||
@@ -851,6 +868,102 @@ multi-step publish paths. Four subsystems:
|
|||||||
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
|
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### #22b: Kanban board as primary admin landing screen (v0.19.0 / v0.19.1)
|
||||||
|
|
||||||
|
**Hidden admin page convention (v0.19.3):** See CONVENTIONS.md §Hidden Admin Pages for the full rule. Summary: hidden admin pages (link-accessed only, not visible in nav) MUST use `options.php` as the `add_submenu_page()` parent. NEVER unset/remove registered pages from `$submenu` after registration -- doing so causes `get_admin_page_parent()` to fail at request time, which makes WP recompute the hookname in the `admin_page_*` orphan namespace, find no registered handler, and call `wp_die(403)`. This is the dossier 403 incident (v0.19.2→v0.19.3). Same hookname-mismatch class as the board 404 (v0.19.0→v0.19.1); the only difference is that the board bug was a priority-ordering issue at registration time while the dossier bug was a post-registration $submenu mutation.
|
||||||
|
|
||||||
|
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
|
||||||
|
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
|
||||||
|
registers the `Board` submenu and a `$submenu` reorder makes it the first (primary) entry.
|
||||||
|
`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns
|
||||||
|
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
|
||||||
|
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
|
||||||
|
required for M1. The data provider uses the `prautoblogger_generation_status` transient
|
||||||
|
(written by the Executor for manual/daily runs) as the primary active-run signal, with a
|
||||||
|
secondary query on recent `prab_generation_log` run_ids that have no linked post as a
|
||||||
|
fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier.
|
||||||
|
Poll interval and published window are settings-backed, localized into board.js, never
|
||||||
|
hardcoded.
|
||||||
|
|
||||||
|
**Menu-ordering constraint (v0.19.1 hotfix):** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||||
|
MUST be hooked at `admin_menu` priority 11 (or higher), AFTER
|
||||||
|
`PRAutoBlogger_Admin_Page::on_register_menu` which runs at priority 10. Reason: WordPress
|
||||||
|
computes the page hookname via `get_plugin_page_hookname()` at `add_submenu_page()` call
|
||||||
|
time. If `add_menu_page()` has not yet run, `$admin_page_hooks['prautoblogger-settings']`
|
||||||
|
is unset and WordPress falls back to the `admin_page_*` hookname. At HTTP request time WP
|
||||||
|
recomputes the hookname as `prautoblogger_page_prautoblogger-board` and finds no registered
|
||||||
|
callback -> `wp_die("Invalid plugin page")` 404. The enqueue-gate in `on_enqueue_assets()`
|
||||||
|
checks `prautoblogger_page_prautoblogger-board` and would similarly never fire with the
|
||||||
|
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`): uses the canonical hidden-admin-page
|
||||||
|
pattern -- registered under parent **`options.php`** at `admin_menu` priority **12**. The
|
||||||
|
hookname is `admin_page_prautoblogger-dossier` at BOTH registration time and request time,
|
||||||
|
so there is no hookname mismatch and no need to manipulate `$submenu`. The page does not
|
||||||
|
appear in the WP admin sidebar (options.php-parent pages are hidden by construction).
|
||||||
|
URL shape: `admin.php?page=prautoblogger-dossier&post_id=<int>` (unchanged from v0.19.2 --
|
||||||
|
deep-link URLs are parent-agnostic). Secured by `manage_options` capability. Static
|
||||||
|
`url_for_post(int $post_id): string` provides the canonical deep-link.
|
||||||
|
|
||||||
|
v0.19.2 used `prautoblogger-settings` as parent with post-registration `unset($submenu[...])` to
|
||||||
|
hide the page. This caused `get_admin_page_parent()` to fail at request time -> orphan
|
||||||
|
`admin_page_*` hookname -> no handler registered -> `wp_die(403)`. Fixed in v0.19.3 by switching
|
||||||
|
to `options.php` parent and removing all `$submenu` mutation. See CONVENTIONS.md §Hidden Admin
|
||||||
|
Pages for the full rule and ARCHITECTURE.md §22b for both incident records.
|
||||||
|
|
||||||
|
Regression test: `tests/unit/Admin/DossierMenuRegistrationTest.php` -- the
|
||||||
|
`test_request_time_hookname_matches_registration_hookname` test replicates the request-time
|
||||||
|
resolution algorithm (simulate `get_admin_page_parent()`) so it FAILS on hide-by-unset and
|
||||||
|
PASSES on options.php-parent.
|
||||||
|
|
||||||
|
**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
|
## 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.
|
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.
|
||||||
|
|||||||
351
CHANGELOG.md
351
CHANGELOG.md
@@ -5,247 +5,127 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
and this project uses [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [0.18.3] - 2026-06-12
|
## [0.19.3] - 2026-06-12
|
||||||
|
|
||||||
GA4-context SQL fix, manual-run resilience, and honest status on transient expiry
|
|
||||||
(CTO thread `2026-06-pipeline-v2-phase1` seq 38).
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Analysis-prompts SQL / GA4 self-improvement loop (R1 — P2)**: `phpcs:ignore` annotation
|
- **Dossier page 403 (P0 hotfix).** `PRAutoBlogger_Dossier_Page::on_register_menu` registered the
|
||||||
was embedded inside the SQL string literal in `class-analysis-prompts.php:81` (same pattern
|
dossier as a hidden submenu under `prautoblogger-settings` then called `unset($submenu[...])` to
|
||||||
as the v0.18.2 cost-reporter fix). MariaDB rejected the query on every pipeline run,
|
hide it from the nav. This is the **hide-by-unset anti-pattern**: at registration time WP records
|
||||||
causing `get_performance_context()` to always return `''` — the LLM research/analysis
|
the render callback under hookname `prautoblogger_page_prautoblogger-dossier`; at request time
|
||||||
stage has had no historical performance context since v0.16.0. Annotation moved to the
|
`get_admin_page_parent()` scans `$submenu`, finds the slug absent (it was unset), returns false,
|
||||||
`// phpcs:ignore` pragma above `get_results()`. Sibling occurrence also fixed in
|
and WP recomputes the hookname under the orphan `admin_page_*` namespace -- no handler registered
|
||||||
`class-post-assembler.php:112` (same pattern). Regression + behavioral tests added to
|
there -- `wp_die(403)`. Same hookname-mismatch class as the board 404 (v0.19.1), different vector.
|
||||||
`AnalysisPromptsTest`.
|
**Fix:** register under parent `options.php` (canonical hidden-page pattern). Hookname is
|
||||||
- **Manual-run resilience — transient renewal (R2a)**: `update_generation_stage()` in the
|
`admin_page_prautoblogger-dossier` at both registration and request time. No `$submenu` mutation
|
||||||
new `PRAutoBlogger_Generation_Status_Poller` now renews the STATUS_TTL on every stage
|
required or permitted. Asset enqueue gate updated to match (`admin_page_*` prefix).
|
||||||
update, ensuring the status transient outlasts any single LLM call even when `Pipeline_
|
Deep-link URLs (`admin.php?page=prautoblogger-dossier&post_id=N`) are parent-agnostic and
|
||||||
Status::broadcast()` is delayed by a long model call.
|
unchanged. Board cards and the post metabox link continue to work without modification.
|
||||||
- **Manual-run resilience — stuck-run detection (R2b)**: `on_ajax_generation_status()`
|
|
||||||
now detects when the status transient is absent but the generation lock has been held
|
|
||||||
longer than STATUS_TTL seconds — indicative of a Hostinger background-process kill.
|
|
||||||
Marks the stuck run `failed` in the audit ledger, releases the lock, and returns
|
|
||||||
`status: error` with an "infrastructure timeout" message instead of silently resetting
|
|
||||||
to idle.
|
|
||||||
- **Honest status when transient gone but lock held (R3)**: when the status transient is
|
|
||||||
absent but the lock is held within TTL, `on_ajax_generation_status()` now returns
|
|
||||||
`status: running` with `started_at` from the lock timestamp (rather than `status: idle`)
|
|
||||||
so the Generate Now button reflects reality during long background runs.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Executor split (300-line rule)**: `class-executor.php` was at 351 lines. AJAX generation
|
- **Regression test upgraded to request-time faithful** (`DossierMenuRegistrationTest`).
|
||||||
handlers (`on_ajax_generate_now`, `on_ajax_generation_status`, `abort_orphaned_run`,
|
New `test_request_time_hookname_matches_registration_hookname` simulates
|
||||||
`update_generation_stage`) extracted to new `class-generation-status-poller.php` (260 lines).
|
`get_admin_page_parent()` against the post-registration `$submenu` state and asserts it
|
||||||
Executor retains cron handlers and delegates AJAX calls to the poller. Public API unchanged.
|
equals the registration-time hookname. FAILS on hide-by-unset (4 failures on v0.19.2 code,
|
||||||
- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the
|
captured in test evidence). PASSES on options.php-parent. New
|
||||||
lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table.
|
`test_hide_by_unset_would_fail_request_time_check` documents the broken pattern inline.
|
||||||
|
- **CONVENTIONS.md §Hidden Admin Pages** added: options.php-parent is the only permitted
|
||||||
|
pattern for hidden admin pages; `$submenu` mutation after registration is explicitly
|
||||||
|
prohibited; full incident history (board 404 + dossier 403) and test methodology documented.
|
||||||
|
- **ARCHITECTURE.md §22b** updated with hidden-page convention cross-reference.
|
||||||
|
**§23** updated to reflect options.php-parent registration and the v0.19.2→v0.19.3 fix.
|
||||||
|
- **tests/bootstrap.php**: added `PRAUTOBLOGGER_PLUGIN_URL` and `PRAUTOBLOGGER_VERSION`
|
||||||
|
constants so admin enqueue tests resolve without "Undefined constant" errors.
|
||||||
|
|
||||||
|
## [0.19.2] - 2026-06-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated
|
- **Article Dossier page (M2).** Per-article read-only inspection page at
|
||||||
from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API.
|
`admin.php?page=prautoblogger-dossier&post_id=<id>`. Accessible via board card
|
||||||
Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch,
|
deep-links and the slim post metabox link. Shows: header (title, verdict pill, tier,
|
||||||
taskUUID:<uuid4>,offset:N,limit:N}]`. Response normalization updated for the new shape:
|
status), sidebar (run metadata, per-stage cost receipt with model + prompt-version),
|
||||||
`air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage`
|
vertical stage sections (rendered output default, per-stage raw-trace toggle exposing
|
||||||
(filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`.
|
`generation_log` request JSON + tokens + cost + role), editorial decisions rationale,
|
||||||
Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache
|
image A/B pair sections.
|
||||||
option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows.
|
- **Board card deep-links.** All Kanban cards now deep-link directly to the article
|
||||||
- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of
|
dossier page (`dossier_url` on card data; `click_action = dossier` in board JS).
|
||||||
100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community
|
- **Slim post metabox.** `PRAutoBlogger_Post_Metabox` now renders a single "View
|
||||||
checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap
|
generation dossier →" link (was a verbose generation-metadata dump).
|
||||||
warning is logged if the cap is reached before exhausting `totalResults`. Client-side
|
- **Dossier CSS/JS.** `assets/css/dossier.css` (warm editorial palette, verdict pills,
|
||||||
`io:text-to-image` capability filter applied — no server-side filter is available.
|
cost receipt, raw-trace toggle) + `assets/js/dossier.js` (vanilla JS toggle,
|
||||||
- **Negative-result failure cooldown**: `sync()` records a failure timestamp in
|
`aria-expanded` / `aria-controls` accessible).
|
||||||
`prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()`
|
- **Menu registration test coverage.** `DossierMenuRegistrationTest.php` — asserts
|
||||||
gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable
|
dossier hook fires at priority > board (> parent), uses hidden-submenu pattern,
|
||||||
window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h,
|
correct slug, and `url_for_post()` URL shape. Mirrors `BoardMenuRegistrationTest`.
|
||||||
never hardcoded at point of use). A successful sync clears the timestamp. This caps the
|
- **Raw-trace credential check (binding 4).** `request_json` column contains only
|
||||||
admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent.
|
OpenRouter request bodies (model, messages, temperature — no Authorization headers
|
||||||
- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire
|
per architecture). All model output escaped via `esc_html()` / `wp_kses_post()`;
|
||||||
`taskType:models` outage. Tests verify it is always non-empty and correctly priced.
|
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
|
||||||
## [0.18.2] - 2026-06-12
|
rows (pv=null, role='') render model as "—" with no raw-trace toggle.
|
||||||
|
|
||||||
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Cost-reporter SQL (P2)**: `phpcs:ignore` annotations were embedded inside two SQL string
|
|
||||||
literals in `class-cost-reporter.php` (`get_daily_spend` and `get_spend_by_stage`). MariaDB
|
|
||||||
received the comment text as literal SQL and rejected both queries, breaking the Metrics &
|
|
||||||
Costs admin page. Annotations moved above the `$wpdb->prepare()` calls. Regression tests
|
|
||||||
added to `CostReporterTest` asserting no `// phpcs:ignore` text appears in the SQL passed
|
|
||||||
to `prepare()`.
|
|
||||||
- **Collector failure observability (P2)**: `RuntimeException` catch in
|
|
||||||
`Source_Collector::collect_from_all_sources()` now logs structured meta — model slug,
|
|
||||||
HTTP status parsed from the exception message, and the first 200 chars of the error body —
|
|
||||||
so a 27-day outage like the seq-22 llm_research 404 incident is diagnosable after the fact.
|
|
||||||
No behaviour change; logging only.
|
|
||||||
- **`run_stages.agent_role` symmetric write (P3)**: `Pipeline_Runner`, `Article_Worker`, and
|
|
||||||
`Content_Generator` were calling `start()` with the mapped role but leaving `done()`,
|
|
||||||
`is_done()`, and `get_output()` with `agent_role = ''`. With `agent_role` in the UNIQUE key,
|
|
||||||
the mismatch addressed different rows — stranding `start()` rows in `running` forever and
|
|
||||||
breaking idempotent resume (duplicate-post vector). Fixed via an in-class seam in
|
|
||||||
`Run_Stage_State::resolve_role()`: when the caller passes `''`, the role is derived from
|
|
||||||
`Stage_Display_Map::default_agent_role($stage)` before any DB query, ensuring every method
|
|
||||||
addresses the same row. Explicit non-empty roles (Phase-2 fan-out) are passed through
|
|
||||||
unchanged. `get()` falls back to `role=''` on miss for mid-run-upgrade compat with v0.18.1
|
|
||||||
rows. Two new `RunStageStateTest` cases lock the symmetric-role and fallback contracts.
|
|
||||||
ARCHITECTURE.md §22 updated to document Phase 1 role population.
|
|
||||||
- **Runware float deprecation (P3)**: `normalize_seed()` in `class-runware-image-support.php`
|
|
||||||
applied the modulo operator to a float (`microtime(true) * 1000`), triggering
|
|
||||||
`Deprecated: Implicit conversion from float … to int` on every image generation. Fixed
|
|
||||||
with `(int) round(...)` before the `%` operator.
|
|
||||||
|
|
||||||
## [0.18.1] - 2026-06-11
|
|
||||||
|
|
||||||
Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run
|
|
||||||
`acf24029`, draft post 921). A true reasoning model at a high reasoning effort could spend the
|
|
||||||
entire `max_tokens` completion budget on thinking, return zero visible content with
|
|
||||||
`finish_reason=length`, and the empty article flowed writer → editor (reject: "draft text is
|
|
||||||
missing") → empty draft post in the Review Queue, with the cost booked as `success`.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Empty-completion guard at the call seam** (`PRAutoBlogger_OpenRouter_Completion_Guard`):
|
|
||||||
a chat completion whose visible content is empty/whitespace is now a **failure**, never a
|
|
||||||
success. When the empty result co-occurs with `finish_reason=length` or an active reasoning
|
|
||||||
request, the call is retried **once** with reasoning disabled (per-call `reasoning`
|
|
||||||
override) before the stage fails. Failed/retried attempts land in `generation_log` with
|
|
||||||
`response_status='error'`, the failure note, and the real token burn; inside a v0.18.0 run a
|
|
||||||
`run_decisions` row records the verdict (`retry_reasoning_disabled` /
|
|
||||||
`failed_empty_completion`).
|
|
||||||
- **Writer-path guard**: a writing stage (outline/draft/polish/single-pass) whose output is
|
|
||||||
empty throws instead of passing an empty article to the editor (belt over the seam guard,
|
|
||||||
covers any future non-OpenRouter provider).
|
|
||||||
- **Publisher belt-and-braces**: `Publisher::create_post()` refuses to create any post —
|
|
||||||
publish or draft — whose content is empty after tag stripping. No empty draft post can ever
|
|
||||||
be created again.
|
|
||||||
- `finish_reason=length` is now always warning-logged with model, stage, and
|
|
||||||
prompt/completion/reasoning token counts (previously silent — the truncated `llm_research`
|
|
||||||
call in the incident run was invisible).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Reasoning token cap + completion headroom**: when a request enables reasoning, the
|
|
||||||
thinking budget is capped via OpenRouter's `reasoning.max_tokens` and the request's
|
|
||||||
`max_tokens` is **raised** by the same amount, so no reasoning effort (incl. `xhigh`) can
|
|
||||||
consume the visible-content budget. New setting `prautoblogger_reasoning_max_tokens`
|
|
||||||
(Settings → AI Models, default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` = 2048; 0
|
|
||||||
restores the previous uncapped effort mode; the option is sanitized server-side as
|
|
||||||
numeric). When the cap is active it replaces `effort`
|
|
||||||
(OpenRouter treats them as alternative budget controls); the `xhigh`/`high` effort options
|
|
||||||
stay available but are bounded by default. Cost-governor reservations use the effective
|
|
||||||
(raised) ceiling.
|
|
||||||
- LLM call sites now pass their pipeline stage to the provider (`stage` option, never sent
|
|
||||||
upstream) so guard warnings and failure rows are attributable: `analysis`, `review`,
|
|
||||||
`llm_research`, `image_prompt_rewrite`, `opik_eval_judge`, and the writer stages (which
|
|
||||||
also pass `prompt_key`).
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- `OpenRouter_Provider::send_chat_completion()` body assembly moved to
|
|
||||||
`OpenRouter_Request_Builder::build_body()` (300-line cap; behavior unchanged apart from the
|
|
||||||
reasoning budget above).
|
|
||||||
|
|
||||||
## [0.18.0] - 2026-06-12
|
|
||||||
|
|
||||||
Pipeline v2 — Phase 1 substrate (plan of record: CPO thread
|
|
||||||
`2026-06-prautoblogger-pipeline-redesign`, seq 3). Built on the current pipeline with **no
|
|
||||||
behavior change** to the Economy (single-pass) and multi-step publish paths.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **Schema v1.2.0** (`prautoblogger_db_version` 1.1.0 → 1.2.0, idempotent dbDelta
|
|
||||||
migrations, self-healing on version mismatch): new tables
|
|
||||||
`wp_prautoblogger_prompts` (versioned prompt registry),
|
|
||||||
`wp_prautoblogger_run_sources` + `wp_prautoblogger_run_decisions` (audit child tables),
|
|
||||||
`wp_prautoblogger_runs` (per-run cost ledger + lifecycle) and
|
|
||||||
`wp_prautoblogger_run_stages` (per-run per-stage state machine, idempotency key
|
|
||||||
`run_id + stage + agent_role + item_key`); additive nullable columns `agent_role` and
|
|
||||||
`prompt_version` on `wp_prautoblogger_generation_log` (historical rows stay valid).
|
|
||||||
- `PRAutoBlogger_Stage_Display_Map`: single PHP vocabulary for historical stages
|
|
||||||
(`analysis`, `outline`, `draft`, `polish`, `review`, `llm_research`, `image_a`, `image_b`,
|
|
||||||
`image_prompt_rewrite`, `opik_eval_judge`), Pipeline v2 stages (`research`, `curate`,
|
|
||||||
`draft`, `editorial`, `seo`, `publish`) and a humanizing fallback for unknown values, plus
|
|
||||||
per-stage default agent role and primary prompt-registry key.
|
|
||||||
- Uninstall now drops the five substrate tables and clears every plugin cron hook
|
|
||||||
(previously only two of eight were cleared).
|
|
||||||
- **Versioned prompt registry** (`PRAutoBlogger_Prompt_Registry` + `_Writer`): all pipeline
|
|
||||||
prompt copy — content system/single-pass/outline/draft/polish, analysis system/user,
|
|
||||||
editor system/review, research system, plus the **illustration rewriter prompt and the
|
|
||||||
image Style Template** (the image-composer PR seam) — is seeded as immutable version 1
|
|
||||||
from the v0.16.0 hardcoded texts (`prautoblogger_migrated_prompt_seed_v0180`). One active
|
|
||||||
version per key; changes create new versions, never mutate. Call sites render through the
|
|
||||||
registry with a **byte-identical in-code fallback** when the table is missing/empty
|
|
||||||
(self-healing, no fatals on half-migrated state). No admin UI in Phase 1.
|
|
||||||
- **Prompt-version pinning**: `Cost_Tracker::set_run_id()` pins the active version of every
|
|
||||||
key on the run's row; every generation_log row (text and image stages) is stamped with the
|
|
||||||
pinned `prompt_version` plus a stage-derived `agent_role`. Single-pass rows keep stage
|
|
||||||
`draft` and stamp `content.single_pass` explicitly.
|
|
||||||
- `PRAutoBlogger_Run_State` (runs-table ledger row: ceiling/reserved/settled/overage, pins,
|
|
||||||
sticky lifecycle states) and `PRAutoBlogger_Run_Context` (per-process run id, mirrors the
|
|
||||||
Opik trace-context pattern).
|
|
||||||
|
|
||||||
- **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`, net-new enforcement — previously
|
|
||||||
only the monthly cap was enforced): every `send_chat_completion()` reserves its worst-case
|
|
||||||
estimate (prompt chars/4 + max_tokens, priced via the #18 chain) against the run ledger
|
|
||||||
with a single atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch; the
|
|
||||||
image `curl_multi` batch reserves its summed estimate before dispatch; reservations settle
|
|
||||||
to actuals after each response (a failed call releases its hold). Breach → run `halted`
|
|
||||||
(sticky), overage recorded, `Cost_Ceiling_Exception` aborts the call, remaining queued
|
|
||||||
articles abort, and the run is surfaced in a "Halted runs" notice on the Review Queue.
|
|
||||||
New setting **Per-Run Cost Ceiling (USD)** (`prautoblogger_per_run_cost_ceiling_usd`,
|
|
||||||
default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` = $0.50, snapshotted at run start,
|
|
||||||
0 = disabled). Calls outside a run, the monthly budget, and the Cloudflare AI Gateway
|
|
||||||
path behave exactly as before.
|
|
||||||
|
|
||||||
- **Run state machine + idempotent resume** (`PRAutoBlogger_Run_Stage_State`): per-run
|
|
||||||
per-stage rows keyed `run_id + stage + agent_role + item_key` (item_key scopes
|
|
||||||
article-level stages — one run_id spans all N articles of a batch; agent_role is the
|
|
||||||
Phase-2 quorum dimension, schema-only for now). Done stages snapshot their output and are
|
|
||||||
REUSED on re-entry — never re-run, never re-charged (writer stages via
|
|
||||||
`Content_Generator::set_run_item()`, the editorial review via its stored verdict, a fully
|
|
||||||
published item is skipped outright). **Post creation is keyed by `_prautoblogger_run_id`
|
|
||||||
+ new `_prautoblogger_idea_hash` meta (check-before-insert)** so a retried run cannot
|
|
||||||
create a duplicate post. Run-level research/analysis checkpoints ride Pipeline_Runner;
|
|
||||||
completion/failure marks land on the runs row (Executor catch paths, orphan aborts,
|
|
||||||
monthly-budget aborts).
|
|
||||||
- **Reaper extension** (`PRAutoBlogger_Run_Reaper`, same daily cron as the #19 research
|
|
||||||
reaper — no new schedule): stages stuck `running` > 2× expected stage wall-clock and runs
|
|
||||||
silent > 2× expected run wall-clock are marked `failed` (filterable expectations); their
|
|
||||||
open stages are reaped; such runs are queryable as **"incomplete"** via
|
|
||||||
`Run_Reaper::incomplete_runs()`. Also enforces **retention**: `request_json` on the
|
|
||||||
generation log and stage output snapshots are NULLed after R days — R from the new
|
|
||||||
**Audit Payload Retention** setting (`prautoblogger_request_json_retention_days`, default
|
|
||||||
`PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` = 14, 0 = keep forever).
|
|
||||||
- **Audit writes** (`PRAutoBlogger_Audit_Writer`): every reviewed article records its
|
|
||||||
editorial verdict in `run_decisions`; every published article records the sources that
|
|
||||||
fed it in `run_sources` (the source_ids_json / `_prautoblogger_research_sources`
|
|
||||||
consolidation for new runs; no backfill).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik
|
- **`class-prautoblogger.php` → `class-db-migrations.php` split (binding 1).**
|
||||||
span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines.
|
All DB-migration methods extracted byte-identically into new static class
|
||||||
No behavior change: same prompts, models, params, span names, stage values.
|
`PRAutoBlogger_DB_Migrations` (`includes/class-db-migrations.php`, 129 lines).
|
||||||
- `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only
|
`class-prautoblogger.php` shrinks from 407 → 258 lines; delegates via thin proxy.
|
||||||
reporting query); the Cost_Tracker method remains as a delegating wrapper.
|
Loaded via explicit `require_once` in `prautoblogger.php` (autoloader filename
|
||||||
- `OpenRouter_Provider`'s API-key fetch + format validation moved to
|
collision avoidance — see ARCHITECTURE.md §File Naming Notes).
|
||||||
`OpenRouter_Request_Builder::resolve_api_key()` (300-line cap); identical messages/behavior.
|
|
||||||
- Pipeline_Runner's and Article_Worker's duplicated status-transient/summary helpers moved
|
## [0.19.1] - 2026-06-12
|
||||||
verbatim to the new `PRAutoBlogger_Pipeline_Status` (Pipeline_Runner back under the
|
|
||||||
300-line cap at 271).
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always
|
- **Board admin submenu 404 (P0 hotfix).** `PRAutoBlogger_Board_Page::on_register_menu`
|
||||||
written `polish`. Canonicalized to **`polish`** everywhere.
|
was hooked at `admin_menu` priority 10, identical to
|
||||||
- Half-migrated-schema hardening: generation_log inserts retry without the new audit
|
`PRAutoBlogger_Admin_Page::on_register_menu`. Because the board hook was
|
||||||
columns when the columns are missing (a cron run right after deploy no longer loses cost
|
registered first in `register_admin_hooks()`, `add_submenu_page()` fired while
|
||||||
rows), and the db-version self-heal now also runs on cron requests (`init` +
|
`$admin_page_hooks['prautoblogger-settings']` was still unset. WordPress fell back
|
||||||
`wp_doing_cron()`), not only on `admin_init`.
|
to the `admin_page_prautoblogger-board` hookname; at request time it recomputed
|
||||||
- OpenRouter pricing now resolves dated snapshot model IDs returned by the API
|
`prautoblogger_page_prautoblogger-board`, found no registered callback, and called
|
||||||
(e.g. response model `deepseek/deepseek-v4-flash-20260423` for a
|
`wp_die()` with "Invalid plugin page". Board CSS/JS also never loaded (same
|
||||||
`deepseek/deepseek-v4-flash` request) to the base model's price before the
|
enqueue-gate mismatch). Fix: parent menu hook stays at priority 10; board submenu
|
||||||
conservative-estimate fallback applies. Such responses were previously booked
|
hook moves to priority 11, guaranteeing `add_menu_page()` fires first. Board is
|
||||||
at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) —
|
kept as the first submenu item via explicit `$submenu` reorder in
|
||||||
phantom spend that ate the per-run cost ceiling and the monthly budget gate.
|
`PRAutoBlogger_Board_Page::on_register_menu()`.
|
||||||
|
- **Regression test added:** `tests/unit/Admin/BoardMenuRegistrationTest.php` —
|
||||||
|
three assertions covering priority relationship, execution order, and parent-slug
|
||||||
|
correctness. Includes a "documents the bug" test that fires the unfixed hook order
|
||||||
|
to prove the broken state.
|
||||||
|
|
||||||
|
## [0.19.0] - 2026-06-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Kanban board dashboard (M1 — Phase 2 admin / Direction C).** New
|
||||||
|
`Board` submenu page is now the primary landing screen for the plugin.
|
||||||
|
Four columns — Generating | In Review | Published | Failed — with card
|
||||||
|
click-throughs: Generating/Failed → Activity Log; In Review → Review
|
||||||
|
Queue; Published → Post edit screen. (M2 will rewire all to the Article
|
||||||
|
Dossier.)
|
||||||
|
- **Live board polling.** AJAX action `prautoblogger_board_status` (nonce
|
||||||
|
`prautoblogger_board`) returns the full board snapshot every N seconds.
|
||||||
|
Poll interval backs off to 2× when no article is actively generating
|
||||||
|
and resets to base as soon as a generating card appears. All nonce and
|
||||||
|
capability checks enforced.
|
||||||
|
- **Two new settings** in Schedule & Budget: `prautoblogger_board_poll_interval`
|
||||||
|
(default 5s, min 3s) and `prautoblogger_board_published_window_days`
|
||||||
|
(default 7 days). Both localized into board.js — no hardcoded values.
|
||||||
|
- New files: `includes/admin/class-board-page.php`,
|
||||||
|
`includes/admin/class-board-data-provider.php`,
|
||||||
|
`templates/admin/board-page.php`, `assets/css/board.css`,
|
||||||
|
`assets/js/board.js`.
|
||||||
|
- PHPUnit behavioral tests: `tests/unit/Admin/BoardDataProviderTest.php`
|
||||||
|
(8 tests covering all column/state mappings) and
|
||||||
|
`tests/unit/Core/CostReporterSqlBugFixTest.php` (3 tests for the SQL
|
||||||
|
inline-comment fix).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Cost-reporter SQL inline-comment bug (P2).** `PRAutoBlogger_Cost_Reporter::get_daily_spend()`
|
||||||
|
and `get_spend_by_stage()` embedded PHP `// phpcs:ignore` annotations
|
||||||
|
inside SQL string literals, passing the comment text to MySQL. Moved
|
||||||
|
phpcs suppression annotations to standalone PHP line comments above the
|
||||||
|
`$wpdb->prepare()` call — matching the pattern already used in
|
||||||
|
`get_monthly_spend()`. No query behavior change; fixes malformed
|
||||||
|
prepared-statement strings visible in debug logging.
|
||||||
|
|
||||||
## [0.17.0] - 2026-06-11
|
## [0.17.0] - 2026-06-11
|
||||||
|
|
||||||
@@ -1161,6 +1041,7 @@ swap with no user-visible change in article quality.
|
|||||||
run_id-based log linking, RuntimeException on WP_Error, action/filter
|
run_id-based log linking, RuntimeException on WP_Error, action/filter
|
||||||
hook firing. Replaces previous method-existence-only checks.
|
hook firing. Replaces previous method-existence-only checks.
|
||||||
|
|
||||||
|
|
||||||
## [0.2.1] — 2026-04-12
|
## [0.2.1] — 2026-04-12
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -1197,14 +1078,6 @@ swap with no user-visible change in article quality.
|
|||||||
- Posts-per-subreddit limit (5–100) in Sources tab.
|
- Posts-per-subreddit limit (5–100) in Sources tab.
|
||||||
- LiteSpeed cache purge step in CI/CD deploy pipeline.
|
- LiteSpeed cache purge step in CI/CD deploy pipeline.
|
||||||
|
|
||||||
### Removed
|
|
||||||
- `class-reddit-api-client.php` — replaced by `class-reddit-json-client.php`.
|
|
||||||
- Reddit OAuth credential fields and encrypted storage entry.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Deploy pipeline now purges LiteSpeed/OPcache after file extraction,
|
|
||||||
preventing stale PHP bytecode from being served.
|
|
||||||
|
|
||||||
## [0.1.0] — 2026-04-10
|
## [0.1.0] — 2026-04-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -502,3 +502,73 @@ public function my_operation( $input ) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
That's it. No config, no feature-flag checks, no try/catch needed.
|
That's it. No config, no feature-flag checks, no try/catch needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hidden Admin Pages (v0.19.3 convention, codified after two incidents)
|
||||||
|
|
||||||
|
### Rule: use options.php as parent; NEVER unset/remove registered pages from $submenu
|
||||||
|
|
||||||
|
When a WordPress admin page must be accessible by URL but hidden from the nav menu, use the
|
||||||
|
**options.php-parent pattern** exclusively:
|
||||||
|
|
||||||
|
```php
|
||||||
|
add_submenu_page(
|
||||||
|
'options.php', // parent: built-in WP page; pages hidden by construction
|
||||||
|
__( 'My Page', 'prautoblogger' ), // page title
|
||||||
|
__( 'My Page', 'prautoblogger' ), // menu title (never shown; options.php is hidden)
|
||||||
|
'manage_options', // capability
|
||||||
|
'my-page-slug', // page slug
|
||||||
|
array( $this, 'render_page' ), // callback
|
||||||
|
);
|
||||||
|
// No $submenu manipulation. options.php-parent pages are hidden from nav by construction.
|
||||||
|
```
|
||||||
|
|
||||||
|
The resulting hookname is `admin_page_my-page-slug` at BOTH registration time and request time.
|
||||||
|
|
||||||
|
### Why the hide-by-unset pattern causes 403 (the incident class)
|
||||||
|
|
||||||
|
WordPress computes the page hookname at two distinct moments:
|
||||||
|
|
||||||
|
**Registration time** (inside `add_submenu_page`):
|
||||||
|
WP resolves the parent slug against `$admin_page_hooks`. If found, hookname = `{parent_key}_page_{slug}`. WP registers the render callback and `$_registered_pages` entry under that name.
|
||||||
|
|
||||||
|
**Request time** (inside `wp-admin/admin.php`):
|
||||||
|
WP calls `get_admin_page_parent()` which scans `$submenu` for the page slug. If found, it derives the hookname the same way as (1). If **not found** (e.g., because the entry was unset), it falls back to `admin_page_{slug}`.
|
||||||
|
|
||||||
|
When these two hooknames diverge, WP dispatches the request to a hookname with no registered callback → `wp_die(403)`.
|
||||||
|
|
||||||
|
**Why options.php avoids this:** `options.php` is a built-in page that WordPress always keeps intact in `$admin_page_hooks` and `$submenu`. For its children, `get_admin_page_parent()` returns `options.php` consistently at both moments, so both produce `admin_page_{slug}`. No mutation required.
|
||||||
|
|
||||||
|
### Incident history
|
||||||
|
|
||||||
|
| Version | Symptom | Root cause | Fix |
|
||||||
|
|---------|---------|------------|-----|
|
||||||
|
| v0.19.1 | Board page 404 | Board submenu registered at same priority as parent (both 10) — add_submenu_page fired before add_menu_page → wrong hookname at registration time | Board moved to priority 11, after parent at 10 |
|
||||||
|
| v0.19.3 | Dossier page 403 | Dossier registered under `prautoblogger-settings` parent (priority 12, after parent ✓) but post-registration `unset($submenu[...])` removed the slug → get_admin_page_parent() returned false at request time → orphan `admin_page_*` hookname → no handler → 403 | Switched to `options.php` parent; removed all $submenu mutation |
|
||||||
|
|
||||||
|
Both incidents are in the same **hookname-mismatch class**: registration-time hookname ≠ request-time hookname.
|
||||||
|
|
||||||
|
### Test pattern
|
||||||
|
|
||||||
|
The `DossierMenuRegistrationTest::test_request_time_hookname_matches_registration_hookname` test
|
||||||
|
replicates the request-time resolution algorithm (scan `$submenu` for the slug; if found derive
|
||||||
|
hookname; if absent return `admin_page_*`) and asserts it equals the registration-time hookname.
|
||||||
|
This test FAILS on hide-by-unset implementations and passes on the options.php-parent pattern.
|
||||||
|
Apply the same test strategy to any new hidden admin page.
|
||||||
|
|
||||||
|
### Asset enqueue gate
|
||||||
|
|
||||||
|
For `options.php`-parent pages, the hook suffix passed to `admin_enqueue_scripts` is
|
||||||
|
`admin_page_{slug}`. For plugin-parent pages it would be `{parent_key}_page_{slug}`. Always
|
||||||
|
match the asset enqueue gate to the actual hookname for the chosen parent pattern:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// options.php parent → 'admin_page_{slug}'
|
||||||
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
|
if ( $hook_suffix !== 'admin_page_' . self::PAGE_SLUG ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ... enqueue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
249
assets/css/board.css
Normal file
249
assets/css/board.css
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger — Kanban Board Styles
|
||||||
|
*
|
||||||
|
* Warm editorial style (Proposal C / Direction C).
|
||||||
|
* Inside wp-admin chrome; uses WP admin vars where available.
|
||||||
|
* No build step — plain CSS, no preprocessor.
|
||||||
|
*
|
||||||
|
* Palette:
|
||||||
|
* Teal #1B8A92 (brand primary — Generating pill, spinner)
|
||||||
|
* Cream #FAF6EE (card background)
|
||||||
|
* Ink #0D0D0D (primary text)
|
||||||
|
* Orange #FF8A3D (In Review pill)
|
||||||
|
* Lime #7FD600 (Published pill — success)
|
||||||
|
* Red #D94F4F (Failed pill)
|
||||||
|
* Mid-grey #8A8A8A (meta text, border)
|
||||||
|
*
|
||||||
|
* @see templates/admin/board-page.php — Rendered HTML structure.
|
||||||
|
* @see assets/js/board.js — DOM updates that match these selectors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Page wrapper ──────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-wrap {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-heading {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0d0d0d;
|
||||||
|
margin: 20px 0 18px 0;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-heading-sub {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #8a8a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Four-column board grid ────────────────────────────────────────────── */
|
||||||
|
.prab-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 20px 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.prab-board {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.prab-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Column ────────────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-column {
|
||||||
|
background: #f8f7f5;
|
||||||
|
border: 1px solid #e2ddd6;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 14px 10px;
|
||||||
|
border-bottom: 1px solid #e2ddd6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-title {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #4a4540;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-cards {
|
||||||
|
padding: 10px 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-col-empty {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #b0a898;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status pills ──────────────────────────────────────────────────────── */
|
||||||
|
.prab-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #fff;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--generating {
|
||||||
|
background: #1b8a92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--review {
|
||||||
|
background: #ff8a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--published {
|
||||||
|
background: #5a9e28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-pill--failed {
|
||||||
|
background: #d94f4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card ──────────────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-card {
|
||||||
|
background: #faf6ee;
|
||||||
|
border: 1px solid #e0d8cc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 11px 12px 10px;
|
||||||
|
position: relative;
|
||||||
|
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
|
||||||
|
border-color: #b8cfd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-card--generating {
|
||||||
|
border-left: 3px solid #1b8a92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0d0d0d;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-right: 20px; /* room for spinner */
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-cost {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #5a8a50;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-stage {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #1b8a92;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-error {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #d94f4f;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #1b8a92;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid #b8d8db;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
transition: background 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-card-link:hover {
|
||||||
|
background: #e8f4f5;
|
||||||
|
color: #136870;
|
||||||
|
border-color: #8ac4c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Generating spinner ────────────────────────────────────────────────── */
|
||||||
|
.prab-card-spinner {
|
||||||
|
position: absolute;
|
||||||
|
top: 11px;
|
||||||
|
right: 11px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #c8e6e8;
|
||||||
|
border-top-color: #1b8a92;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: prab-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes prab-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Board footer ──────────────────────────────────────────────────────── */
|
||||||
|
.prab-board-footer {
|
||||||
|
padding: 0 20px 16px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #a0998f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Poll-error banner ─────────────────────────────────────────────────── */
|
||||||
|
.prab-board-error {
|
||||||
|
background: #fff4f4;
|
||||||
|
border: 1px solid #f5c4c4;
|
||||||
|
color: #8b2020;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 0 20px 12px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prab-board-error.is-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
241
assets/js/board.js
Normal file
241
assets/js/board.js
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* PRAutoBlogger — Kanban Board Poller
|
||||||
|
*
|
||||||
|
* Live-polls the board status AJAX endpoint and updates the four columns
|
||||||
|
* (Generating | In Review | Published | Failed) in-place without a full
|
||||||
|
* page reload.
|
||||||
|
*
|
||||||
|
* Poll behaviour:
|
||||||
|
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
|
||||||
|
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
|
||||||
|
* at 4× the base interval — prevents needless AJAX on an idle board.
|
||||||
|
* - Resets to base interval as soon as an active run is detected again.
|
||||||
|
*
|
||||||
|
* Click-throughs (M1):
|
||||||
|
* - Generating → Activity Log
|
||||||
|
* - In Review → Review Queue
|
||||||
|
* - Published → Post edit screen
|
||||||
|
* - Failed → Activity Log
|
||||||
|
* (M2 rewires all to Article Dossier)
|
||||||
|
*
|
||||||
|
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
|
||||||
|
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
|
||||||
|
*
|
||||||
|
* @see includes/admin/class-board-page.php — AJAX handler + localise.
|
||||||
|
* @see assets/css/board.css — Card and column styles.
|
||||||
|
* @see templates/admin/board-page.php — Initial server-rendered HTML.
|
||||||
|
*/
|
||||||
|
( function ( $ ) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var cfg = window.prabBoard || {};
|
||||||
|
var baseInterval = cfg.pollInterval || 5000;
|
||||||
|
var maxBackoff = 4;
|
||||||
|
var backoffFactor = 1;
|
||||||
|
var pollTimer = null;
|
||||||
|
var $board = null;
|
||||||
|
var $errorBanner = null;
|
||||||
|
|
||||||
|
/** Escape HTML for safe DOM insertion. */
|
||||||
|
function escHtml( str ) {
|
||||||
|
return $( '<div>' ).text( String( str ) ).html();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format cost to 4 decimal places with a leading $. */
|
||||||
|
function formatCost( cost ) {
|
||||||
|
var n = parseFloat( cost ) || 0;
|
||||||
|
return '$' + n.toFixed( 4 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a card HTML string from a card data object.
|
||||||
|
*
|
||||||
|
* @param {Object} card Card data from server.
|
||||||
|
* @param {string} columnKey Column identifier.
|
||||||
|
* @returns {string} HTML string.
|
||||||
|
*/
|
||||||
|
function buildCardHtml( card, columnKey ) {
|
||||||
|
var isGenerating = ( columnKey === 'generating' );
|
||||||
|
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
html += '<div class="' + escHtml( cls ) + '"';
|
||||||
|
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
|
||||||
|
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
|
||||||
|
|
||||||
|
if ( isGenerating ) {
|
||||||
|
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
|
||||||
|
|
||||||
|
if ( card.cost_total ) {
|
||||||
|
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
|
||||||
|
html += escHtml( formatCost( card.cost_total ) );
|
||||||
|
html += '</span></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( isGenerating && card.stage_current ) {
|
||||||
|
html += '<div class="prab-card-stage">';
|
||||||
|
/* translators: stage name */
|
||||||
|
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( card.error_message ) {
|
||||||
|
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
|
||||||
|
if ( card.error_message.split( ' ' ).length > 12 ) {
|
||||||
|
errShort += '…';
|
||||||
|
}
|
||||||
|
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click-through link.
|
||||||
|
var linkUrl = '';
|
||||||
|
var linkText = '';
|
||||||
|
if ( card.click_action === 'dossier' && card.dossier_url ) {
|
||||||
|
linkUrl = card.dossier_url;
|
||||||
|
linkText = cfg.strings.view;
|
||||||
|
} else if ( card.click_action === 'review' && card.review_url ) {
|
||||||
|
linkUrl = card.review_url;
|
||||||
|
linkText = cfg.strings.view;
|
||||||
|
} else if ( card.click_action === 'edit' && card.edit_url ) {
|
||||||
|
linkUrl = card.edit_url;
|
||||||
|
linkText = cfg.strings.edit;
|
||||||
|
} else if ( card.click_action === 'logs' && card.log_url ) {
|
||||||
|
linkUrl = card.log_url;
|
||||||
|
linkText = cfg.strings.viewLog;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( linkUrl ) {
|
||||||
|
html += '<div class="prab-card-actions">';
|
||||||
|
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
|
||||||
|
html += escHtml( linkText );
|
||||||
|
html += '</a></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-render a single column from the server snapshot.
|
||||||
|
*
|
||||||
|
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
|
||||||
|
* to prevent flicker on the running spinner.
|
||||||
|
*
|
||||||
|
* @param {string} colKey Column identifier.
|
||||||
|
* @param {Array} cards Array of card objects.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function renderColumn( colKey, cards ) {
|
||||||
|
var $col = $board.find( '[data-column="' + colKey + '"]' );
|
||||||
|
var $cards = $col.find( '[data-column-cards]' );
|
||||||
|
var $pill = $col.find( '.prab-col-count' );
|
||||||
|
|
||||||
|
$pill.text( cards.length );
|
||||||
|
|
||||||
|
if ( ! cards.length ) {
|
||||||
|
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new card HTML.
|
||||||
|
var newHtml = '';
|
||||||
|
for ( var i = 0; i < cards.length; i++ ) {
|
||||||
|
newHtml += buildCardHtml( cards[ i ], colKey );
|
||||||
|
}
|
||||||
|
$cards.html( newHtml );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a server snapshot to all four columns.
|
||||||
|
*
|
||||||
|
* @param {Object} snapshot Board snapshot from AJAX.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function applySnapshot( snapshot ) {
|
||||||
|
renderColumn( 'generating', snapshot.generating || [] );
|
||||||
|
renderColumn( 'in_review', snapshot.in_review || [] );
|
||||||
|
renderColumn( 'published', snapshot.published || [] );
|
||||||
|
renderColumn( 'failed', snapshot.failed || [] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the next poll delay based on whether there are active runs.
|
||||||
|
*
|
||||||
|
* @param {boolean} hasActiveRuns
|
||||||
|
* @returns {number} Milliseconds until next poll.
|
||||||
|
*/
|
||||||
|
function nextDelay( hasActiveRuns ) {
|
||||||
|
if ( hasActiveRuns ) {
|
||||||
|
backoffFactor = 1;
|
||||||
|
return baseInterval;
|
||||||
|
}
|
||||||
|
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
|
||||||
|
return baseInterval * backoffFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next poll after a given delay.
|
||||||
|
*
|
||||||
|
* @param {number} delay Milliseconds.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function schedulePoll( delay ) {
|
||||||
|
clearTimeout( pollTimer );
|
||||||
|
pollTimer = setTimeout( doPoll, delay );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function doPoll() {
|
||||||
|
$.ajax( {
|
||||||
|
url: cfg.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: cfg.action,
|
||||||
|
nonce: cfg.nonce
|
||||||
|
},
|
||||||
|
success: function ( response ) {
|
||||||
|
if ( $errorBanner ) {
|
||||||
|
$errorBanner.removeClass( 'is-visible' );
|
||||||
|
}
|
||||||
|
if ( response && response.success && response.data ) {
|
||||||
|
applySnapshot( response.data );
|
||||||
|
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
|
||||||
|
} else {
|
||||||
|
schedulePoll( nextDelay( false ) );
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
if ( $errorBanner ) {
|
||||||
|
$errorBanner.addClass( 'is-visible' );
|
||||||
|
}
|
||||||
|
schedulePoll( nextDelay( false ) );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initialise the board poller on DOM ready. */
|
||||||
|
$( function () {
|
||||||
|
$board = $( '#prab-board' );
|
||||||
|
if ( ! $board.length ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the error banner after the heading.
|
||||||
|
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
|
||||||
|
$board.before( $errorBanner );
|
||||||
|
|
||||||
|
// Check if any generating card is present in the initial server render.
|
||||||
|
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
|
||||||
|
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
|
||||||
|
|
||||||
|
schedulePoll( nextDelay( hasActive ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
}( jQuery ) );
|
||||||
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' );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}() );
|
||||||
3
includes/admin/class-admin-page.php
Executable file → Normal file
3
includes/admin/class-admin-page.php
Executable file → Normal file
@@ -62,6 +62,7 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
public function on_enqueue_assets( string $hook_suffix ): void {
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
$pages = array(
|
$pages = array(
|
||||||
'toplevel_page_' . self::PAGE_SLUG,
|
'toplevel_page_' . self::PAGE_SLUG,
|
||||||
|
'prautoblogger_page_prautoblogger-board',
|
||||||
'prautoblogger_page_prautoblogger-metrics',
|
'prautoblogger_page_prautoblogger-metrics',
|
||||||
'prautoblogger_page_prautoblogger-review-queue',
|
'prautoblogger_page_prautoblogger-review-queue',
|
||||||
'prautoblogger_page_prautoblogger-ideas',
|
'prautoblogger_page_prautoblogger-ideas',
|
||||||
@@ -323,7 +324,7 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_reasoning_max_tokens' );
|
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days' );
|
||||||
if ( in_array( $option_name, $numeric, true ) ) {
|
if ( in_array( $option_name, $numeric, true ) ) {
|
||||||
return is_numeric( $value ) ? $value : 0;
|
return is_numeric( $value ) ? $value : 0;
|
||||||
}
|
}
|
||||||
|
|||||||
188
includes/admin/class-board-data-provider.php
Normal file
188
includes/admin/class-board-data-provider.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Supplies card data for the kanban board page from existing DB tables.
|
||||||
|
*
|
||||||
|
* Orchestrates the four board columns:
|
||||||
|
* Generating | In Review | Published | Failed
|
||||||
|
*
|
||||||
|
* Generation-log queries (Generating, Failed) are delegated to
|
||||||
|
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
|
||||||
|
* are handled here.
|
||||||
|
*
|
||||||
|
* 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, PRAutoBlogger_Dossier_Page,
|
||||||
|
* WordPress WP_Query, get_option().
|
||||||
|
*
|
||||||
|
* @see admin/class-board-gen-log-query.php -- Raw gen_log queries (Generating/Failed).
|
||||||
|
* @see admin/class-board-page.php -- Calls get_board_snapshot().
|
||||||
|
* @see admin/class-dossier-page.php -- Provides url_for_post() for deep-links.
|
||||||
|
* @see ARCHITECTURE.md -- Database schema.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Board_Data_Provider {
|
||||||
|
|
||||||
|
/** @var PRAutoBlogger_Board_Gen_Log_Query Handles raw gen_log DB queries. */
|
||||||
|
private PRAutoBlogger_Board_Gen_Log_Query $gen_log_query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param PRAutoBlogger_Board_Gen_Log_Query|null $gen_log_query Optional injection for testing.
|
||||||
|
*/
|
||||||
|
public function __construct( ?PRAutoBlogger_Board_Gen_Log_Query $gen_log_query = null ) {
|
||||||
|
$this->gen_log_query = $gen_log_query ?? new PRAutoBlogger_Board_Gen_Log_Query();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the full board snapshot consumed by the AJAX poller.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* generating: array<int, array<string, mixed>>,
|
||||||
|
* in_review: array<int, array<string, mixed>>,
|
||||||
|
* published: array<int, array<string, mixed>>,
|
||||||
|
* failed: array<int, array<string, mixed>>,
|
||||||
|
* has_active_runs: bool,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get_board_snapshot(): array {
|
||||||
|
$generating = $this->gen_log_query->get_generating_cards();
|
||||||
|
$in_review = $this->get_in_review_cards();
|
||||||
|
$published = $this->get_published_cards();
|
||||||
|
$failed = $this->gen_log_query->get_failed_cards();
|
||||||
|
|
||||||
|
return array(
|
||||||
|
'generating' => $generating,
|
||||||
|
'in_review' => $in_review,
|
||||||
|
'published' => $published,
|
||||||
|
'failed' => $failed,
|
||||||
|
'has_active_runs' => ! empty( $generating ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for runs that are currently in-progress.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_generating_cards(): array {
|
||||||
|
return $this->gen_log_query->get_generating_cards();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for generation runs that ended in an error state.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_failed_cards(): array {
|
||||||
|
return $this->gen_log_query->get_failed_cards();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for generated draft posts awaiting human review.
|
||||||
|
* M2: cards include dossier_url for deep-linking.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_in_review_cards(): array {
|
||||||
|
$query = new WP_Query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'draft',
|
||||||
|
'posts_per_page' => 20,
|
||||||
|
'meta_query' => array(
|
||||||
|
array(
|
||||||
|
'key' => '_prautoblogger_generated',
|
||||||
|
'value' => '1',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
foreach ( $query->posts as $post ) {
|
||||||
|
if ( ! ( $post instanceof WP_Post ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||||
|
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
|
||||||
|
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'cost_total' => $cost_total,
|
||||||
|
'created_at' => strtotime( $post->post_date_gmt ),
|
||||||
|
'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' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for published posts within the configured window.
|
||||||
|
* M2: cards include dossier_url for deep-linking.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_published_cards(): array {
|
||||||
|
$window_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||||
|
$date_after = gmdate( 'Y-m-d H:i:s', time() - ( $window_days * DAY_IN_SECONDS ) );
|
||||||
|
|
||||||
|
$query = new WP_Query(
|
||||||
|
array(
|
||||||
|
'post_type' => 'post',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'posts_per_page' => 20,
|
||||||
|
'date_query' => array(
|
||||||
|
array(
|
||||||
|
'after' => $date_after,
|
||||||
|
'inclusive' => true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'meta_query' => array(
|
||||||
|
array(
|
||||||
|
'key' => '_prautoblogger_generated',
|
||||||
|
'value' => '1',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
foreach ( $query->posts as $post ) {
|
||||||
|
if ( ! ( $post instanceof WP_Post ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
|
||||||
|
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
|
||||||
|
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'cost_total' => $cost_total,
|
||||||
|
'published_at' => strtotime( $post->post_date_gmt ),
|
||||||
|
'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 ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
includes/admin/class-board-gen-log-query.php
Normal file
184
includes/admin/class-board-gen-log-query.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Raw generation-log queries for the kanban board.
|
||||||
|
*
|
||||||
|
* Extracted from PRAutoBlogger_Board_Data_Provider to stay under the 300-line
|
||||||
|
* cap. Handles the two columns that read directly from prab_generation_log:
|
||||||
|
* Generating (in-progress runs) | Failed (error runs with no linked post)
|
||||||
|
*
|
||||||
|
* No schema changes — data comes from prab_generation_log which already exists.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger_Board_Data_Provider::get_generating_cards()
|
||||||
|
* PRAutoBlogger_Board_Data_Provider::get_failed_cards()
|
||||||
|
* Dependencies: WordPress $wpdb, get_transient().
|
||||||
|
*
|
||||||
|
* @see admin/class-board-data-provider.php — Orchestrator that calls this class.
|
||||||
|
* @see ARCHITECTURE.md — Database schema (generation_log).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Board_Gen_Log_Query {
|
||||||
|
|
||||||
|
/** Seconds of gen-log inactivity after which a run is no longer "generating". */
|
||||||
|
public const GENERATING_GRACE_SECONDS = 1800;
|
||||||
|
|
||||||
|
/** Transient key written by PRAutoBlogger_Executor for the active generation run. */
|
||||||
|
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for runs that are currently in-progress.
|
||||||
|
*
|
||||||
|
* Detection strategy: check the generation status transient first (authoritative
|
||||||
|
* for the active manual-run or daily-cron run), then fall back to recent gen_log
|
||||||
|
* run_ids that have no associated published/draft post yet.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_generating_cards(): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
|
||||||
|
// Primary: check if the executor transient signals an active run.
|
||||||
|
$status = get_transient( self::STATUS_TRANSIENT );
|
||||||
|
if ( is_array( $status ) && 'running' === ( $status['status'] ?? '' ) ) {
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => '',
|
||||||
|
'post_id' => 0,
|
||||||
|
'title' => $status['stage'] ?? __( 'Generating…', 'prautoblogger' ),
|
||||||
|
'cost_total' => 0.0,
|
||||||
|
'stage_current' => $status['stage'] ?? '',
|
||||||
|
'stage_count' => 0,
|
||||||
|
'started_at' => $status['started'] ?? 0,
|
||||||
|
'click_action' => 'logs',
|
||||||
|
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary: runs in gen_log that are recent and have no linked post.
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
$since = gmdate( 'Y-m-d H:i:s', time() - self::GENERATING_GRACE_SECONDS );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$raw_runs = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT run_id,
|
||||||
|
MIN(created_at) as started_at,
|
||||||
|
MAX(stage) as last_stage,
|
||||||
|
COUNT(*) as stage_count,
|
||||||
|
SUM(CASE WHEN response_status = 'success' THEN estimated_cost ELSE 0 END) as cost_total
|
||||||
|
FROM {$table}
|
||||||
|
WHERE created_at >= %s
|
||||||
|
AND run_id IS NOT NULL
|
||||||
|
AND post_id IS NULL
|
||||||
|
GROUP BY run_id
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 10",
|
||||||
|
$since
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! is_array( $raw_runs ) ) {
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $raw_runs as $row ) {
|
||||||
|
$run_id = (string) $row['run_id'];
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if we already have a transient card (avoid duplicate).
|
||||||
|
if ( ! empty( $cards ) && '' === $cards[0]['run_id'] ) {
|
||||||
|
// Only add secondary cards if we have a real run_id.
|
||||||
|
// The transient card already covers the active run.
|
||||||
|
}
|
||||||
|
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => 0,
|
||||||
|
'title' => sprintf(
|
||||||
|
/* translators: %s: pipeline stage name */
|
||||||
|
__( 'Generating — %s', 'prautoblogger' ),
|
||||||
|
esc_html( ucfirst( (string) $row['last_stage'] ) )
|
||||||
|
),
|
||||||
|
'cost_total' => round( (float) $row['cost_total'], 6 ),
|
||||||
|
'stage_current' => (string) $row['last_stage'],
|
||||||
|
'stage_count' => (int) $row['stage_count'],
|
||||||
|
'started_at' => strtotime( (string) $row['started_at'] ),
|
||||||
|
'click_action' => 'logs',
|
||||||
|
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cards for generation runs that ended in an error state.
|
||||||
|
*
|
||||||
|
* Looks for recent gen_log rows with response_status='error' that have
|
||||||
|
* no corresponding published post — these are pipeline failures.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function get_failed_cards(): array {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
$since = gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$raw = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT run_id,
|
||||||
|
MIN(created_at) as started_at,
|
||||||
|
MAX(error_message) as last_error,
|
||||||
|
MAX(stage) as last_stage,
|
||||||
|
COUNT(*) as call_count
|
||||||
|
FROM {$table}
|
||||||
|
WHERE created_at >= %s
|
||||||
|
AND response_status = 'error'
|
||||||
|
AND run_id IS NOT NULL
|
||||||
|
AND post_id IS NULL
|
||||||
|
GROUP BY run_id
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 20",
|
||||||
|
$since
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( ! is_array( $raw ) ) {
|
||||||
|
return array();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cards = array();
|
||||||
|
foreach ( $raw as $row ) {
|
||||||
|
$run_id = (string) $row['run_id'];
|
||||||
|
if ( '' === $run_id ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cards[] = array(
|
||||||
|
'run_id' => $run_id,
|
||||||
|
'post_id' => 0,
|
||||||
|
'title' => sprintf(
|
||||||
|
/* translators: %s: pipeline stage name */
|
||||||
|
__( 'Failed — %s', 'prautoblogger' ),
|
||||||
|
esc_html( ucfirst( (string) $row['last_stage'] ) )
|
||||||
|
),
|
||||||
|
'error_message' => (string) $row['last_error'],
|
||||||
|
'started_at' => strtotime( (string) $row['started_at'] ),
|
||||||
|
'click_action' => 'logs',
|
||||||
|
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
includes/admin/class-board-page.php
Normal file
182
includes/admin/class-board-page.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
|
*
|
||||||
|
* Registers and renders the kanban board — the primary landing screen for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Board columns: Generating | In Review | Published | Failed.
|
||||||
|
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
|
||||||
|
* backing off to 2× when no active generating run is detected.
|
||||||
|
*
|
||||||
|
* Card click-throughs (M1):
|
||||||
|
* - Generating → Activity Log filtered to run context
|
||||||
|
* - In Review → Review Queue
|
||||||
|
* - Published → Post edit screen
|
||||||
|
* - Failed → Activity Log
|
||||||
|
*
|
||||||
|
* M2 will rewire all click-throughs to the Article Dossier page.
|
||||||
|
*
|
||||||
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||||
|
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
|
||||||
|
*
|
||||||
|
* @see admin/class-board-data-provider.php — Supplies card data.
|
||||||
|
* @see templates/admin/board-page.php — HTML template.
|
||||||
|
* @see assets/js/board.js — Polling + DOM updates.
|
||||||
|
* @see ARCHITECTURE.md — §Board (kanban dashboard).
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Board_Page {
|
||||||
|
|
||||||
|
private const PAGE_SLUG = 'prautoblogger-board';
|
||||||
|
|
||||||
|
/** AJAX action name for board polling. */
|
||||||
|
public const AJAX_ACTION = 'prautoblogger_board_status';
|
||||||
|
|
||||||
|
/** Nonce action string for board AJAX. */
|
||||||
|
public const NONCE_ACTION = 'prautoblogger_board';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Board as the PRIMARY (first) submenu item, overriding the
|
||||||
|
* implicit Settings landing page. The trick is: WordPress auto-generates a
|
||||||
|
* submenu duplicate of the top-level page; we name it "Board" so the
|
||||||
|
* first click goes to the board, not the raw settings page.
|
||||||
|
*
|
||||||
|
* Registration order note: this method is hooked at admin_menu priority 11
|
||||||
|
* so that PRAutoBlogger_Admin_Page::on_register_menu() (priority 10) has
|
||||||
|
* already called add_menu_page(). This ensures WordPress has populated
|
||||||
|
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
||||||
|
* runs here, so get_plugin_page_hookname() resolves to the correct
|
||||||
|
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
|
||||||
|
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_register_menu(): void {
|
||||||
|
// Override the auto-created "PRAutoBlogger" submenu entry with "Board".
|
||||||
|
add_submenu_page(
|
||||||
|
'prautoblogger-settings',
|
||||||
|
__( 'PRAutoBlogger Board', 'prautoblogger' ),
|
||||||
|
__( 'Board', 'prautoblogger' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE_SLUG,
|
||||||
|
array( $this, 'render_page' )
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder submenu so Board is the FIRST entry (primary click target).
|
||||||
|
// WordPress auto-appended a duplicate of the top-level page as submenu
|
||||||
|
// index 0; our Board was appended at index 1. We swap them so the
|
||||||
|
// top-level PRAutoBlogger click lands on the Board page, not Settings.
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional submenu reorder.
|
||||||
|
global $submenu;
|
||||||
|
if ( isset( $submenu['prautoblogger-settings'] ) ) {
|
||||||
|
$items = $submenu['prautoblogger-settings'];
|
||||||
|
// Find the Board entry and move it to position 0.
|
||||||
|
$board_idx = null;
|
||||||
|
foreach ( $items as $i => $item ) {
|
||||||
|
if ( isset( $item[2] ) && self::PAGE_SLUG === $item[2] ) {
|
||||||
|
$board_idx = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( null !== $board_idx && $board_idx > 0 ) {
|
||||||
|
$board_item = $items[ $board_idx ];
|
||||||
|
unset( $items[ $board_idx ] );
|
||||||
|
// Prepend board item at position 0 while preserving remaining order.
|
||||||
|
$submenu['prautoblogger-settings'] = array_merge( array( $board_item ), array_values( $items ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the board page.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function render_page(): void {
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/board-page.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue board-specific JS + CSS and localize the poller config.
|
||||||
|
*
|
||||||
|
* Called via `admin_enqueue_scripts` action, gated to this page's hook suffix.
|
||||||
|
*
|
||||||
|
* @param string $hook_suffix Current admin page hook suffix.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
|
$board_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
|
||||||
|
if ( $hook_suffix !== $board_hook ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_enqueue_style(
|
||||||
|
'prautoblogger-board',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/board.css',
|
||||||
|
array( 'prautoblogger-admin' ),
|
||||||
|
PRAUTOBLOGGER_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
wp_enqueue_script(
|
||||||
|
'prautoblogger-board',
|
||||||
|
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board.js',
|
||||||
|
array( 'jquery' ),
|
||||||
|
PRAUTOBLOGGER_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
|
||||||
|
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||||
|
|
||||||
|
wp_localize_script(
|
||||||
|
'prautoblogger-board',
|
||||||
|
'prabBoard',
|
||||||
|
array(
|
||||||
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
|
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
|
||||||
|
'pollInterval' => $poll_interval * 1000,
|
||||||
|
'publishedWindowDays' => $published_days,
|
||||||
|
'action' => self::AJAX_ACTION,
|
||||||
|
'strings' => array(
|
||||||
|
'generating' => __( 'Generating', 'prautoblogger' ),
|
||||||
|
'inReview' => __( 'In Review', 'prautoblogger' ),
|
||||||
|
'published' => __( 'Published', 'prautoblogger' ),
|
||||||
|
'failed' => __( 'Failed', 'prautoblogger' ),
|
||||||
|
'empty' => __( 'Nothing here yet.', 'prautoblogger' ),
|
||||||
|
'cost' => __( 'Cost', 'prautoblogger' ),
|
||||||
|
'view' => __( 'View', 'prautoblogger' ),
|
||||||
|
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||||
|
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||||
|
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler: return the current board snapshot (all four columns).
|
||||||
|
*
|
||||||
|
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
|
||||||
|
*
|
||||||
|
* Side effects: DB reads only — no writes.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_ajax_board_status(): void {
|
||||||
|
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
|
||||||
|
|
||||||
|
if ( ! current_user_can( 'manage_options' ) ) {
|
||||||
|
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = new PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$snapshot = $provider->get_board_snapshot();
|
||||||
|
|
||||||
|
wp_send_json_success( $snapshot );
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
includes/admin/class-dossier-page.php
Normal file
136
includes/admin/class-dossier-page.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?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 uses
|
||||||
|
* the canonical hidden-admin-page pattern: register under parent 'options.php'
|
||||||
|
* so WordPress computes the hookname as `admin_page_prautoblogger-dossier` at
|
||||||
|
* BOTH registration time AND request time. The page is hidden from nav by
|
||||||
|
* construction (options.php is a built-in WP page with no visible submenu block)
|
||||||
|
* so no $submenu mutation is needed or permitted.
|
||||||
|
*
|
||||||
|
* Why NOT the hide-by-unset pattern (the v0.19.2 bug):
|
||||||
|
* add_submenu_page('prautoblogger-settings', ...) → hookname 'prautoblogger_page_prautoblogger-dossier'
|
||||||
|
* post-registration unset($submenu['prautoblogger-settings'][$idx])
|
||||||
|
* → at request time get_admin_page_parent() no longer finds the slug in $submenu
|
||||||
|
* → WP recomputes hookname in the 'admin_page_*' orphan namespace
|
||||||
|
* → no callback registered there → wp_die(403).
|
||||||
|
* See ARCHITECTURE.md §22b + CONVENTIONS.md §Hidden Admin Pages for the full incident
|
||||||
|
* history (board 404 v0.19.1, dossier 403 v0.19.2).
|
||||||
|
*
|
||||||
|
* Board cards and post-metabox deep-link here via
|
||||||
|
* admin.php?page=prautoblogger-dossier&post_id=X. Deep-link URLs are parent-agnostic
|
||||||
|
* (they reference only the page slug) so they are unchanged by this fix.
|
||||||
|
*
|
||||||
|
* 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 -- §22b (hidden admin page convention).
|
||||||
|
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||||
|
*/
|
||||||
|
class PRAutoBlogger_Dossier_Page {
|
||||||
|
|
||||||
|
/** Admin page slug. */
|
||||||
|
public const PAGE_SLUG = 'prautoblogger-dossier';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent slug used at registration.
|
||||||
|
* 'options.php' is the canonical WP hidden-page parent: the hookname resolves
|
||||||
|
* to `admin_page_{slug}` at both registration time and request time, so there is
|
||||||
|
* no hookname mismatch and no need to manipulate $submenu.
|
||||||
|
*/
|
||||||
|
public const PARENT_SLUG = 'options.php';
|
||||||
|
|
||||||
|
/** Nonce action for any future stateful actions. */
|
||||||
|
public const NONCE_ACTION = 'prautoblogger_dossier';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the dossier as a hidden admin page.
|
||||||
|
*
|
||||||
|
* Uses the canonical options.php-parent pattern so the hookname is stable at
|
||||||
|
* both registration time and request time. No $submenu manipulation required
|
||||||
|
* or permitted (see CONVENTIONS.md §Hidden Admin Pages).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_register_menu(): void {
|
||||||
|
add_submenu_page(
|
||||||
|
self::PARENT_SLUG,
|
||||||
|
__( 'Article Dossier', 'prautoblogger' ),
|
||||||
|
__( 'Dossier', 'prautoblogger' ),
|
||||||
|
'manage_options',
|
||||||
|
self::PAGE_SLUG,
|
||||||
|
array( $this, 'render_page' )
|
||||||
|
);
|
||||||
|
// No $submenu mutation. options.php-parent pages are hidden by construction.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* The hook suffix for an options.php-parent page is `admin_page_{slug}`.
|
||||||
|
*
|
||||||
|
* @param string $hook_suffix Current admin page hook suffix.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function on_enqueue_assets( string $hook_suffix ): void {
|
||||||
|
// options.php-parent pages resolve to 'admin_page_{slug}' (not 'prautoblogger_page_*').
|
||||||
|
$dossier_hook = 'admin_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.
|
||||||
|
*
|
||||||
|
* Deep-link URLs use admin.php?page=<slug> which is parent-agnostic.
|
||||||
|
* This method is unchanged from v0.19.2 -- only the registration parent changed.
|
||||||
|
*
|
||||||
|
* @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
|
* 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
|
* M2: Slimmed to a "View generation dossier" link. Full generation metadata
|
||||||
* _prautoblogger_generated post meta).
|
* lives on the Article Dossier page (PRAutoBlogger_Dossier_Page).
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `add_meta_boxes`.
|
* 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 class-prautoblogger.php -- Registers the hook.
|
||||||
* @see core/class-publisher.php — Writes the metadata this class displays.
|
* @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 {
|
class PRAutoBlogger_Post_Metabox {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the metabox for the post editor.
|
* Register the metabox for PRAutoBlogger-generated posts only.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -35,7 +36,7 @@ class PRAutoBlogger_Post_Metabox {
|
|||||||
|
|
||||||
add_meta_box(
|
add_meta_box(
|
||||||
'prautoblogger_generation_info',
|
'prautoblogger_generation_info',
|
||||||
__( 'PRAutoBlogger Generation Info', 'prautoblogger' ),
|
__( 'PRAutoBlogger', 'prautoblogger' ),
|
||||||
array( $this, 'render_metabox' ),
|
array( $this, 'render_metabox' ),
|
||||||
'post',
|
'post',
|
||||||
'side',
|
'side',
|
||||||
@@ -44,13 +45,12 @@ class PRAutoBlogger_Post_Metabox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the generation metadata metabox.
|
* Render the dossier-link metabox.
|
||||||
*
|
|
||||||
* @param \WP_Post $post
|
|
||||||
*
|
*
|
||||||
|
* @param \WP_Post $post Current post.
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function render_metabox( \WP_Post $post ): 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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,17 +78,29 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above. When the Reasoning Token Cap below is set (default), the cap is sent instead of the effort level — set the cap to 0 to use effort only.', 'prautoblogger' ),
|
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ── Board Settings ─────────────────────────────────────────
|
||||||
|
array(
|
||||||
|
'id' => 'prautoblogger_board_poll_interval',
|
||||||
|
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
|
||||||
|
'type' => 'number',
|
||||||
|
'section' => 'prautoblogger_schedule',
|
||||||
|
'default' => 5,
|
||||||
|
'min' => 3,
|
||||||
|
'max' => 60,
|
||||||
|
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
array(
|
array(
|
||||||
'id' => 'prautoblogger_reasoning_max_tokens',
|
'id' => 'prautoblogger_board_published_window_days',
|
||||||
'label' => __( 'Reasoning Token Cap', 'prautoblogger' ),
|
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
|
||||||
'type' => 'number',
|
'type' => 'number',
|
||||||
'section' => 'prautoblogger_models',
|
'section' => 'prautoblogger_schedule',
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS,
|
'default' => 7,
|
||||||
'min' => 0,
|
'min' => 1,
|
||||||
'max' => 32000,
|
'max' => 90,
|
||||||
'description' => __( 'Hard ceiling on thinking tokens per call when reasoning is enabled. The completion budget is raised by this amount so reasoning can never crowd out the article text — an uncapped high effort can spend the entire budget thinking and return an empty draft. 0 = no cap (uncapped effort mode, not recommended).', 'prautoblogger' ),
|
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── Schedule & Budget ───────────────────────────────────────
|
// ── Schedule & Budget ───────────────────────────────────────
|
||||||
@@ -123,26 +135,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'step' => '0.01',
|
'step' => '0.01',
|
||||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||||
),
|
),
|
||||||
array(
|
|
||||||
'id' => 'prautoblogger_request_json_retention_days',
|
|
||||||
'label' => __( 'Audit Payload Retention (days)', 'prautoblogger' ),
|
|
||||||
'type' => 'number',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
|
|
||||||
'min' => 0,
|
|
||||||
'step' => '1',
|
|
||||||
'description' => __( 'Heavy audit payloads (request_json on the generation log, stage output snapshots) are emptied after this many days by the daily cleanup cron. Cost/token rows are kept forever. 0 = keep payloads forever.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'id' => 'prautoblogger_per_run_cost_ceiling_usd',
|
|
||||||
'label' => __( 'Per-Run Cost Ceiling (USD)', 'prautoblogger' ),
|
|
||||||
'type' => 'number',
|
|
||||||
'section' => 'prautoblogger_schedule',
|
|
||||||
'default' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
|
|
||||||
'min' => 0,
|
|
||||||
'step' => '0.01',
|
|
||||||
'description' => __( 'Hard per-run stop: every API call reserves its worst-case cost against this ceiling before dispatch; a breach halts the run and routes it to the Review Queue. Snapshotted at run start. 0 = disabled.', 'prautoblogger' ),
|
|
||||||
),
|
|
||||||
|
|
||||||
// ── Publishing ──────────────────────────────────────────────
|
// ── Publishing ──────────────────────────────────────────────
|
||||||
array(
|
array(
|
||||||
|
|||||||
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.
|
* Main orchestrator for the PRAutoBlogger plugin.
|
||||||
*
|
*
|
||||||
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
|
* Registers all WordPress hooks and wires up dependencies.
|
||||||
* This is the ONLY place hooks are registered — individual classes contain only
|
* DB/option migrations extracted to PRAutoBlogger_DB_Migrations (v0.19.2).
|
||||||
* business logic, not hook registration.
|
|
||||||
*
|
*
|
||||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
* @see prautoblogger.php -- Plugin bootstrap.
|
||||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
|
* @see class-db-migrations.php -- One-time DB/option migrations.
|
||||||
*
|
* @see ARCHITECTURE.md -- Data flow diagram.
|
||||||
* @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.
|
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger {
|
class PRAutoBlogger {
|
||||||
|
|
||||||
@@ -28,14 +20,12 @@ class PRAutoBlogger {
|
|||||||
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
/** @var PRAutoBlogger_Executor Handles generation cron/AJAX, model registry. */
|
||||||
private PRAutoBlogger_Executor $executor;
|
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;
|
private PRAutoBlogger_Ajax_Handlers $ajax_handlers;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all hooks and initialize the plugin.
|
* Register all hooks and initialize the plugin.
|
||||||
* Called once on `plugins_loaded`. Idempotent.
|
* Called once on `plugins_loaded`. Idempotent.
|
||||||
*
|
|
||||||
* Side effects: registers WordPress actions and filters.
|
|
||||||
*/
|
*/
|
||||||
public function run(): void {
|
public function run(): void {
|
||||||
if ( $this->initialized ) {
|
if ( $this->initialized ) {
|
||||||
@@ -46,9 +36,7 @@ class PRAutoBlogger {
|
|||||||
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
|
||||||
|
|
||||||
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
|
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
|
||||||
// v0.18.0: cron requests must also self-heal the schema — after a
|
// v0.18.0: cron requests must also self-heal the schema.
|
||||||
// deploy, scheduled generation can run before any admin page load,
|
|
||||||
// and new-column inserts would silently fail on the old schema.
|
|
||||||
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
|
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
|
||||||
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
|
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
|
||||||
|
|
||||||
@@ -65,13 +53,30 @@ class PRAutoBlogger {
|
|||||||
do_action( 'prautoblogger_loaded' );
|
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 {
|
private function register_admin_hooks(): void {
|
||||||
$admin_page = new PRAutoBlogger_Admin_Page();
|
$admin_page = new PRAutoBlogger_Admin_Page();
|
||||||
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
|
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||||
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
|
||||||
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
add_action( 'admin_enqueue_scripts', array( $admin_page, 'on_enqueue_assets' ) );
|
||||||
|
|
||||||
|
$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( '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( 'add_meta_boxes', array( new PRAutoBlogger_Post_Metabox(), 'on_register_metabox' ) );
|
||||||
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
add_action( 'admin_menu', array( new PRAutoBlogger_Metrics_Page(), 'on_register_menu' ) );
|
||||||
@@ -86,7 +91,6 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
( 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' ) );
|
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,13 +110,7 @@ class PRAutoBlogger {
|
|||||||
private function register_cron_hooks(): void {
|
private function register_cron_hooks(): void {
|
||||||
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
|
add_action( 'prautoblogger_daily_generation', array( $this->executor, 'on_daily_generation' ) );
|
||||||
add_action( 'prautoblogger_collect_metrics', array( $this->executor, 'on_collect_metrics' ) );
|
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' ) );
|
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(
|
add_action(
|
||||||
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||||
array( $this->executor, 'on_process_article_queue' )
|
array( $this->executor, 'on_process_article_queue' )
|
||||||
@@ -120,59 +118,39 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
$registry = $this->executor->get_model_registry();
|
$registry = $this->executor->get_model_registry();
|
||||||
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
|
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' ) );
|
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
|
||||||
|
|
||||||
// Opik observability: async trace/span dispatch.
|
|
||||||
add_action(
|
add_action(
|
||||||
'prautoblogger_opik_dispatch',
|
'prautoblogger_opik_dispatch',
|
||||||
static function (): void {
|
static function (): void {
|
||||||
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
|
if ( ! defined( 'PRAUTOBLOGGER_OPIK_API_KEY' ) || empty( PRAUTOBLOGGER_OPIK_API_KEY ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$client = new PRAutoBlogger_Opik_Client(
|
$client = new PRAutoBlogger_Opik_Client(
|
||||||
PRAUTOBLOGGER_OPIK_API_KEY,
|
PRAUTOBLOGGER_OPIK_API_KEY,
|
||||||
PRAUTOBLOGGER_OPIK_WORKSPACE,
|
PRAUTOBLOGGER_OPIK_WORKSPACE,
|
||||||
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
|
PRAUTOBLOGGER_OPIK_URL_OVERRIDE
|
||||||
);
|
);
|
||||||
$queue = new PRAutoBlogger_Opik_Span_Queue();
|
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, new PRAutoBlogger_Opik_Span_Queue() );
|
||||||
$dispatcher = new PRAutoBlogger_Opik_Dispatcher( $client, $queue );
|
|
||||||
$dispatcher->dispatch();
|
$dispatcher->dispatch();
|
||||||
update_option( 'prautoblogger_opik_last_dispatch', time() );
|
update_option( 'prautoblogger_opik_last_dispatch', time() );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Opik settings registration.
|
|
||||||
if ( is_admin() ) {
|
if ( is_admin() ) {
|
||||||
new PRAutoBlogger_Opik_Settings();
|
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' ) );
|
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' ) );
|
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' ) );
|
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 ) {
|
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||||
\WP_CLI::add_command(
|
\WP_CLI::add_command(
|
||||||
'prautoblogger reap-research',
|
'prautoblogger reap-research',
|
||||||
static function (): void {
|
static function (): void {
|
||||||
$stats = PRAutoBlogger_Research_Reaper::reap();
|
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||||
\WP_CLI::success(
|
\WP_CLI::success( sprintf( 'Reaped %d, deleted %d, skipped %d.', (int) $stats['reaped'], (int) $stats['deleted'], (int) $stats['skipped'] ) );
|
||||||
sprintf(
|
|
||||||
'Reaped %d, deleted %d (stale), skipped %d.',
|
|
||||||
(int) $stats['reaped'],
|
|
||||||
(int) $stats['deleted'],
|
|
||||||
(int) $stats['skipped']
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -180,11 +158,9 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
/** Register AJAX handlers for admin actions. */
|
/** Register AJAX handlers for admin actions. */
|
||||||
private function register_ajax_hooks(): void {
|
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_generate_now', array( $this->executor, 'on_ajax_generate_now' ) );
|
||||||
add_action( 'wp_ajax_prautoblogger_generation_status', array( $this->executor, 'on_ajax_generation_status' ) );
|
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_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_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' ) );
|
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
|
||||||
@@ -196,6 +172,7 @@ class PRAutoBlogger {
|
|||||||
add_action( 'wp_ajax_prautoblogger_reject_post', array( $review_queue, 'on_ajax_reject_post' ) );
|
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' ) );
|
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
|
||||||
|
add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) );
|
||||||
|
|
||||||
$ideas = new PRAutoBlogger_Ideas_Browser();
|
$ideas = new PRAutoBlogger_Ideas_Browser();
|
||||||
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
|
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
|
||||||
@@ -203,121 +180,16 @@ class PRAutoBlogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if database needs migration after plugin update.
|
* Check if database needs migration. Delegates to PRAutoBlogger_DB_Migrations::run().
|
||||||
* Compares stored DB version with PRAUTOBLOGGER_DB_VERSION; re-runs activation if different.
|
* Hooked on `admin_init`.
|
||||||
*
|
|
||||||
* Side effects: may update database schema and db_version option.
|
|
||||||
*/
|
*/
|
||||||
public function on_check_db_version(): void {
|
public function on_check_db_version(): void {
|
||||||
$stored_version = get_option( 'prautoblogger_db_version', '0' );
|
PRAutoBlogger_DB_Migrations::run();
|
||||||
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 §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' );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the db-version check on cron requests (admin_init never fires
|
* Run DB migrations during cron requests (hooked on `init`).
|
||||||
* there). Gated to wp_doing_cron() so normal frontend requests pay
|
* Only fires when wp_doing_cron() is true.
|
||||||
* nothing beyond one autoloaded option read.
|
|
||||||
*
|
|
||||||
* Side effects: may run the same migrations as on_check_db_version().
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function on_check_db_version_for_cron(): void {
|
public function on_check_db_version_for_cron(): void {
|
||||||
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
|
||||||
@@ -328,7 +200,7 @@ class PRAutoBlogger {
|
|||||||
/**
|
/**
|
||||||
* Add custom cron schedules (six-hourly for metrics collection).
|
* 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}>
|
* @return array<string, array{interval: int, display: string}>
|
||||||
*/
|
*/
|
||||||
public function filter_add_cron_schedules( array $schedules ): array {
|
public function filter_add_cron_schedules( array $schedules ): array {
|
||||||
@@ -346,7 +218,6 @@ class PRAutoBlogger {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Block false update notifications from wordpress.org.
|
* 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.
|
* @param object $transient The update_plugins transient data.
|
||||||
* @return object Modified transient.
|
* @return object Modified transient.
|
||||||
@@ -363,7 +234,7 @@ class PRAutoBlogger {
|
|||||||
return $this->executor;
|
return $this->executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backward-compatible proxies ──
|
// -- Backward-compatible proxies --
|
||||||
|
|
||||||
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||||
public function on_daily_generation(): void {
|
public function on_daily_generation(): void {
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
$result = $wpdb->get_var(
|
$result = $wpdb->get_var(
|
||||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
$wpdb->prepare(
|
||||||
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
||||||
$first_of_month
|
$first_of_month
|
||||||
)
|
)
|
||||||
@@ -64,8 +65,8 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
$results = $wpdb->get_results(
|
$results = $wpdb->get_results(
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||||
FROM {$table}
|
FROM {$table}
|
||||||
@@ -102,8 +103,8 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||||
|
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||||
|
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
$results = $wpdb->get_results(
|
$results = $wpdb->get_results(
|
||||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT stage,
|
"SELECT stage,
|
||||||
SUM(estimated_cost) as total_cost,
|
SUM(estimated_cost) as total_cost,
|
||||||
@@ -142,6 +143,7 @@ class PRAutoBlogger_Cost_Reporter {
|
|||||||
}
|
}
|
||||||
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
return ( $this->get_monthly_spend() / $budget ) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get average input/output token counts for given stages over a time period.
|
* Get average input/output token counts for given stages over a time period.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Plugin Name: PRAutoBlogger
|
* Plugin Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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.18.3
|
* Version: 0.19.3
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,31 +35,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.18.3' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.19.3' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
|
||||||
// API and retry limits — named constants instead of magic numbers.
|
// API and retry limits — named constants instead of magic numbers.
|
||||||
// SAFETY INVARIANT: API_TIMEOUT_SECONDS × MAX_RETRIES must stay < STATUS_TTL (600s,
|
|
||||||
// defined in PRAutoBlogger_Generation_Status_Poller). The current product
|
|
||||||
// (180s × 3 = 540s) leaves a 60s margin. If either constant is raised, verify the
|
|
||||||
// inequality still holds — otherwise a fully-retrying LLM call can exhaust the
|
|
||||||
// status transient TTL within a single stage and trigger a false orphan-run abort.
|
|
||||||
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
|
||||||
// Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every
|
|
||||||
// read site (prautoblogger_per_run_cost_ceiling_usd /
|
|
||||||
// prautoblogger_request_json_retention_days); these constants exist only as
|
|
||||||
// the named get_option() fallbacks — never inline the literals.
|
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
|
||||||
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
|
|
||||||
// request's completion budget (max_tokens) is RAISED by this amount so
|
|
||||||
// reasoning can never consume the visible-content budget and return an empty
|
|
||||||
// message (the 2026-06-11 empty-draft incident). SETTINGS-backed
|
|
||||||
// (prautoblogger_reasoning_max_tokens); 0 disables the cap (pure effort mode).
|
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS', 2048 );
|
|
||||||
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
|
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
|
||||||
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
|
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
|
||||||
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );
|
||||||
@@ -124,6 +107,7 @@ PRAutoBlogger_Autoloader::register();
|
|||||||
// The main orchestrator class is loaded explicitly because its CamelCase name
|
// The main orchestrator class is loaded explicitly because its CamelCase name
|
||||||
// (PRAutoBlogger) doesn't map cleanly to a filename via the autoloader's
|
// (PRAutoBlogger) doesn't map cleanly to a filename via the autoloader's
|
||||||
// kebab-case convention (it would look for class-pr-auto-blogger.php).
|
// 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';
|
require_once PRAUTOBLOGGER_PLUGIN_DIR . 'includes/class-prautoblogger.php';
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
160
templates/admin/board-page.php
Normal file
160
templates/admin/board-page.php
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Kanban board template — primary landing screen for PRAutoBlogger.
|
||||||
|
*
|
||||||
|
* Renders the four-column board: Generating | In Review | Published | Failed.
|
||||||
|
* Cards are populated on first load by a synchronous PHP data call, then kept
|
||||||
|
* live by the board.js poller (AJAX, settings-backed interval).
|
||||||
|
*
|
||||||
|
* @see admin/class-board-page.php — Renders this template.
|
||||||
|
* @see admin/class-board-data-provider.php — Supplies card data.
|
||||||
|
* @see assets/js/board.js — Polling + DOM updates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eager load on first render so the board is not blank on arrival.
|
||||||
|
$provider = new PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$snapshot = $provider->get_board_snapshot();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single board card as HTML.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $card Card data from Board_Data_Provider.
|
||||||
|
* @param string $column_key Column identifier (generating|in_review|published|failed).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function prab_render_board_card( array $card, string $column_key ): void {
|
||||||
|
$title = ! empty( $card['title'] ) ? esc_html( $card['title'] ) : esc_html__( 'Untitled', 'prautoblogger' );
|
||||||
|
$cost = isset( $card['cost_total'] ) ? '$' . esc_html( number_format( (float) $card['cost_total'], 4 ) ) : '';
|
||||||
|
$click_url = '';
|
||||||
|
$link_text = '';
|
||||||
|
|
||||||
|
if ( 'review' === ( $card['click_action'] ?? '' ) ) {
|
||||||
|
$click_url = esc_url( $card['review_url'] ?? '' );
|
||||||
|
$link_text = esc_html__( 'Review Queue', 'prautoblogger' );
|
||||||
|
} elseif ( 'edit' === ( $card['click_action'] ?? '' ) ) {
|
||||||
|
$click_url = esc_url( $card['edit_url'] ?? '' );
|
||||||
|
$link_text = esc_html__( 'Edit Post', 'prautoblogger' );
|
||||||
|
} elseif ( 'logs' === ( $card['click_action'] ?? '' ) ) {
|
||||||
|
$click_url = esc_url( $card['log_url'] ?? '' );
|
||||||
|
$link_text = esc_html__( 'View Log', 'prautoblogger' );
|
||||||
|
}
|
||||||
|
|
||||||
|
$is_generating = ( 'generating' === $column_key );
|
||||||
|
$card_class = 'prab-board-card';
|
||||||
|
if ( $is_generating ) {
|
||||||
|
$card_class .= ' prab-board-card--generating';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr( $card_class ); ?>"
|
||||||
|
data-run-id="<?php echo esc_attr( (string) ( $card['run_id'] ?? '' ) ); ?>"
|
||||||
|
data-post-id="<?php echo esc_attr( (string) ( $card['post_id'] ?? 0 ) ); ?>">
|
||||||
|
|
||||||
|
<?php if ( $is_generating ) : ?>
|
||||||
|
<div class="prab-card-spinner" aria-label="<?php esc_attr_e( 'Generating in progress', 'prautoblogger' ); ?>"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="prab-card-title"><?php echo $title; // Already escaped above. ?></div>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $cost ) ) : ?>
|
||||||
|
<div class="prab-card-meta">
|
||||||
|
<span class="prab-card-cost"><?php echo $cost; // Already escaped above. ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( $is_generating && ! empty( $card['stage_current'] ) ) : ?>
|
||||||
|
<div class="prab-card-stage">
|
||||||
|
<?php
|
||||||
|
printf(
|
||||||
|
/* translators: %s: pipeline stage name */
|
||||||
|
esc_html__( 'Stage: %s', 'prautoblogger' ),
|
||||||
|
esc_html( ucfirst( (string) $card['stage_current'] ) )
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $card['error_message'] ) ) : ?>
|
||||||
|
<div class="prab-card-error">
|
||||||
|
<?php echo esc_html( wp_trim_words( (string) $card['error_message'], 12, '…' ) ); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ( ! empty( $click_url ) ) : ?>
|
||||||
|
<div class="prab-card-actions">
|
||||||
|
<a href="<?php echo $click_url; // Already escaped above. ?>" class="prab-card-link">
|
||||||
|
<?php echo $link_text; // Already escaped above. ?>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="wrap prab-board-wrap">
|
||||||
|
<h1 class="prab-board-heading">
|
||||||
|
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
|
||||||
|
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$columns = array(
|
||||||
|
'generating' => array(
|
||||||
|
'label' => __( 'Generating', 'prautoblogger' ),
|
||||||
|
'pill' => 'prab-pill--generating',
|
||||||
|
'cards' => $snapshot['generating'],
|
||||||
|
),
|
||||||
|
'in_review' => array(
|
||||||
|
'label' => __( 'In Review', 'prautoblogger' ),
|
||||||
|
'pill' => 'prab-pill--review',
|
||||||
|
'cards' => $snapshot['in_review'],
|
||||||
|
),
|
||||||
|
'published' => array(
|
||||||
|
'label' => __( 'Published', 'prautoblogger' ),
|
||||||
|
'pill' => 'prab-pill--published',
|
||||||
|
'cards' => $snapshot['published'],
|
||||||
|
),
|
||||||
|
'failed' => array(
|
||||||
|
'label' => __( 'Failed', 'prautoblogger' ),
|
||||||
|
'pill' => 'prab-pill--failed',
|
||||||
|
'cards' => $snapshot['failed'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
foreach ( $columns as $col_key => $col ) :
|
||||||
|
$count = count( $col['cards'] );
|
||||||
|
?>
|
||||||
|
<div class="prab-board-column" data-column="<?php echo esc_attr( $col_key ); ?>">
|
||||||
|
<div class="prab-col-header">
|
||||||
|
<span class="prab-col-title"><?php echo esc_html( $col['label'] ); ?></span>
|
||||||
|
<span class="prab-col-count prab-pill <?php echo esc_attr( $col['pill'] ); ?>"><?php echo esc_html( (string) $count ); ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="prab-col-cards" data-column-cards="<?php echo esc_attr( $col_key ); ?>">
|
||||||
|
<?php if ( empty( $col['cards'] ) ) : ?>
|
||||||
|
<p class="prab-col-empty"><?php esc_html_e( 'Nothing here yet.', 'prautoblogger' ); ?></p>
|
||||||
|
<?php else : ?>
|
||||||
|
<?php foreach ( $col['cards'] as $card ) : ?>
|
||||||
|
<?php prab_render_board_card( $card, $col_key ); ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
</div><!-- #prab-board -->
|
||||||
|
|
||||||
|
<p class="prab-board-footer">
|
||||||
|
<?php
|
||||||
|
$interval_secs = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
|
||||||
|
printf(
|
||||||
|
/* translators: %d: poll interval in seconds */
|
||||||
|
esc_html__( 'Board refreshes every %d seconds while an article is generating. Configure in Settings → Schedule & Budget.', 'prautoblogger' ),
|
||||||
|
$interval_secs
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
</p>
|
||||||
|
</div><!-- .prab-board-wrap -->
|
||||||
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>
|
||||||
@@ -103,12 +103,17 @@ if ( ! defined( 'OBJECT' ) ) {
|
|||||||
/**
|
/**
|
||||||
* Minimal WP_Query stub for unit tests.
|
* Minimal WP_Query stub for unit tests.
|
||||||
*
|
*
|
||||||
* IdeaScorer uses WP_Query to check for existing posts.
|
* IdeaScorer uses WP_Query to check for existing posts with empty results.
|
||||||
* This stub returns empty results by default.
|
* Board data provider tests inject posts via WP_Query::$_test_posts_queue
|
||||||
|
* (static FIFO): push arrays of WP_Post objects before instantiating the
|
||||||
|
* provider, and each new WP_Query() call will dequeue one batch.
|
||||||
*/
|
*/
|
||||||
if ( ! class_exists( 'WP_Query' ) ) {
|
if ( ! class_exists( 'WP_Query' ) ) {
|
||||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||||
class WP_Query {
|
class WP_Query {
|
||||||
|
/** @var array<int, array> FIFO queue of post-arrays for tests. */
|
||||||
|
public static $_test_posts_queue = [];
|
||||||
|
|
||||||
/** @var array */
|
/** @var array */
|
||||||
public $posts = [];
|
public $posts = [];
|
||||||
/** @var int */
|
/** @var int */
|
||||||
@@ -117,7 +122,11 @@ if ( ! class_exists( 'WP_Query' ) ) {
|
|||||||
public $have_posts = false;
|
public $have_posts = false;
|
||||||
|
|
||||||
public function __construct( $args = [] ) {
|
public function __construct( $args = [] ) {
|
||||||
// No-op for testing — returns no posts.
|
if ( ! empty( self::$_test_posts_queue ) ) {
|
||||||
|
$this->posts = array_shift( self::$_test_posts_queue );
|
||||||
|
$this->found_posts = count( $this->posts );
|
||||||
|
$this->have_posts = $this->found_posts > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function have_posts(): bool {
|
public function have_posts(): bool {
|
||||||
@@ -165,3 +174,47 @@ if ( ! class_exists( 'WP_Error' ) ) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal WP_Post stub for unit tests.
|
||||||
|
*
|
||||||
|
* Board data provider tests return WP_Post instances from WP_Query.
|
||||||
|
* Class defined here so instanceof checks in production code work correctly.
|
||||||
|
*/
|
||||||
|
if ( ! class_exists( 'WP_Post' ) ) {
|
||||||
|
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||||
|
class WP_Post {
|
||||||
|
/** @var int */
|
||||||
|
public $ID = 0;
|
||||||
|
/** @var string */
|
||||||
|
public $post_title = '';
|
||||||
|
/** @var string */
|
||||||
|
public $post_status = 'publish';
|
||||||
|
/** @var string */
|
||||||
|
public $post_date_gmt = '';
|
||||||
|
/** @var string */
|
||||||
|
public $post_modified_gmt = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function __construct( array $data = [] ) {
|
||||||
|
foreach ( $data as $key => $value ) {
|
||||||
|
$this->$key = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin URL and version constants used by admin enqueue methods.
|
||||||
|
// Defined here so all tests that load admin classes can stub WP enqueue calls
|
||||||
|
// without hitting "Undefined constant" errors.
|
||||||
|
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
|
||||||
|
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
|
||||||
|
}
|
||||||
|
if ( ! defined( 'PRAUTOBLOGGER_VERSION' ) ) {
|
||||||
|
define( 'PRAUTOBLOGGER_VERSION', '0.19.3-test' );
|
||||||
|
}
|
||||||
|
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_DIR' ) ) {
|
||||||
|
define( 'PRAUTOBLOGGER_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
|
||||||
|
}
|
||||||
|
|||||||
300
tests/unit/Admin/BoardDataProviderTest.php
Normal file
300
tests/unit/Admin/BoardDataProviderTest.php
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for PRAutoBlogger_Board_Data_Provider — column/state bucket mapping.
|
||||||
|
*
|
||||||
|
* Seeds mock DB results and WordPress post data, then asserts that each
|
||||||
|
* board column receives the correct cards. No live DB required; uses Brain\Monkey
|
||||||
|
* and a mock $wpdb.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class BoardDataProviderTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||||
|
private $wpdb;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->wpdb = $this->create_mock_wpdb();
|
||||||
|
$GLOBALS['wpdb'] = $this->wpdb;
|
||||||
|
|
||||||
|
// Reset WP_Query test queue before each test.
|
||||||
|
\WP_Query::$_test_posts_queue = [];
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias(
|
||||||
|
function ( string $name, $default = false ) {
|
||||||
|
$map = array(
|
||||||
|
'prautoblogger_board_published_window_days' => 7,
|
||||||
|
'prautoblogger_board_poll_interval' => 5,
|
||||||
|
);
|
||||||
|
return $map[ $name ] ?? $default;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Transient stub: by default no active run.
|
||||||
|
Functions\when( 'get_transient' )->justReturn( false );
|
||||||
|
|
||||||
|
// Admin URL stub.
|
||||||
|
Functions\when( 'admin_url' )->alias(
|
||||||
|
function ( string $path = '' ) {
|
||||||
|
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DAY_IN_SECONDS constant.
|
||||||
|
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
|
||||||
|
define( 'DAY_IN_SECONDS', 86400 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
\WP_Query::$_test_posts_queue = [];
|
||||||
|
unset( $GLOBALS['wpdb'] );
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generating cards ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A running transient with status='running' yields a Generating card.
|
||||||
|
*/
|
||||||
|
public function test_generating_cards_from_transient(): void {
|
||||||
|
Functions\when( 'get_transient' )->alias(
|
||||||
|
function ( $key ) {
|
||||||
|
if ( 'prautoblogger_generation_status' === $key ) {
|
||||||
|
return array(
|
||||||
|
'status' => 'running',
|
||||||
|
'stage' => 'Drafting article…',
|
||||||
|
'started' => time() - 60,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// No gen_log secondary runs needed.
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$cards = $provider->get_generating_cards();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $cards );
|
||||||
|
$this->assertSame( 'Drafting article…', $cards[0]['stage_current'] );
|
||||||
|
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the transient is idle, orphan gen_log rows produce Generating cards.
|
||||||
|
*/
|
||||||
|
public function test_generating_cards_from_gen_log(): void {
|
||||||
|
Functions\when( 'get_transient' )->justReturn( false );
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn(
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'run_id' => 'abc-123',
|
||||||
|
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ),
|
||||||
|
'last_stage' => 'draft',
|
||||||
|
'stage_count' => '3',
|
||||||
|
'cost_total' => '0.005000',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$cards = $provider->get_generating_cards();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $cards );
|
||||||
|
$this->assertSame( 'abc-123', $cards[0]['run_id'] );
|
||||||
|
$this->assertSame( 'draft', $cards[0]['stage_current'] );
|
||||||
|
$this->assertEqualsWithDelta( 0.005, $cards[0]['cost_total'], 0.0001 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An idle transient + empty gen_log yields zero Generating cards.
|
||||||
|
*/
|
||||||
|
public function test_no_generating_cards_when_idle(): void {
|
||||||
|
Functions\when( 'get_transient' )->justReturn( false );
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$cards = $provider->get_generating_cards();
|
||||||
|
|
||||||
|
$this->assertCount( 0, $cards );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In Review cards ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draft posts with the _prautoblogger_generated meta appear in In Review.
|
||||||
|
*/
|
||||||
|
public function test_in_review_cards_from_draft_posts(): void {
|
||||||
|
$post = new \WP_Post();
|
||||||
|
$post->ID = 42;
|
||||||
|
$post->post_title = 'Test Draft Post';
|
||||||
|
$post->post_date_gmt = '2026-06-12 10:00:00';
|
||||||
|
$post->post_status = 'draft';
|
||||||
|
|
||||||
|
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||||
|
\WP_Query::$_test_posts_queue[] = array( $post );
|
||||||
|
|
||||||
|
Functions\when( 'get_post_meta' )->alias(
|
||||||
|
function ( $post_id, $key, $single ) use ( $post ) {
|
||||||
|
if ( $post_id === $post->ID ) {
|
||||||
|
if ( '_prautoblogger_run_id' === $key ) {
|
||||||
|
return 'run-draft-999';
|
||||||
|
}
|
||||||
|
if ( '_prautoblogger_total_cost' === $key ) {
|
||||||
|
return '0.012000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Functions\when( 'get_edit_post_link' )->justReturn(
|
||||||
|
'https://test.example.com/wp-admin/post.php?post=42&action=edit'
|
||||||
|
);
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$cards = $provider->get_in_review_cards();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $cards );
|
||||||
|
$this->assertSame( 42, $cards[0]['post_id'] );
|
||||||
|
$this->assertSame( 'Test Draft Post', $cards[0]['title'] );
|
||||||
|
// 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 );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Published cards ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Published posts within the window appear in Published.
|
||||||
|
*/
|
||||||
|
public function test_published_cards_within_window(): void {
|
||||||
|
$post = new \WP_Post();
|
||||||
|
$post->ID = 77;
|
||||||
|
$post->post_title = 'Published Article';
|
||||||
|
$post->post_date_gmt = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
|
||||||
|
$post->post_status = 'publish';
|
||||||
|
|
||||||
|
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||||
|
\WP_Query::$_test_posts_queue[] = array( $post );
|
||||||
|
|
||||||
|
Functions\when( 'get_post_meta' )->alias(
|
||||||
|
function ( $post_id, $key, $single ) use ( $post ) {
|
||||||
|
if ( $post_id === $post->ID ) {
|
||||||
|
if ( '_prautoblogger_run_id' === $key ) {
|
||||||
|
return 'run-pub-77';
|
||||||
|
}
|
||||||
|
if ( '_prautoblogger_total_cost' === $key ) {
|
||||||
|
return '0.020000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Functions\when( 'get_edit_post_link' )->justReturn(
|
||||||
|
'https://test.example.com/wp-admin/post.php?post=77&action=edit'
|
||||||
|
);
|
||||||
|
Functions\when( 'get_permalink' )->justReturn( 'https://test.example.com/?p=77' );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$cards = $provider->get_published_cards();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $cards );
|
||||||
|
$this->assertSame( 77, $cards[0]['post_id'] );
|
||||||
|
// 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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gen_log error rows with no post produce Failed cards.
|
||||||
|
*/
|
||||||
|
public function test_failed_cards_from_error_log(): void {
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn(
|
||||||
|
array(
|
||||||
|
array(
|
||||||
|
'run_id' => 'fail-run-001',
|
||||||
|
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
|
||||||
|
'last_error' => 'OpenRouter rate limit exceeded',
|
||||||
|
'last_stage' => 'draft',
|
||||||
|
'call_count' => '2',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$cards = $provider->get_failed_cards();
|
||||||
|
|
||||||
|
$this->assertCount( 1, $cards );
|
||||||
|
$this->assertSame( 'fail-run-001', $cards[0]['run_id'] );
|
||||||
|
$this->assertStringContainsString( 'rate limit', $cards[0]['error_message'] );
|
||||||
|
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full snapshot ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_board_snapshot() has_active_runs = true when Generating is non-empty.
|
||||||
|
*/
|
||||||
|
public function test_snapshot_has_active_runs_flag(): void {
|
||||||
|
Functions\when( 'get_transient' )->alias(
|
||||||
|
function ( $key ) {
|
||||||
|
if ( 'prautoblogger_generation_status' === $key ) {
|
||||||
|
return array(
|
||||||
|
'status' => 'running',
|
||||||
|
'stage' => 'Writing…',
|
||||||
|
'started' => time(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$snapshot = $provider->get_board_snapshot();
|
||||||
|
|
||||||
|
$this->assertTrue( $snapshot['has_active_runs'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_board_snapshot() has_active_runs = false when Generating is empty.
|
||||||
|
*/
|
||||||
|
public function test_snapshot_no_active_runs_when_idle(): void {
|
||||||
|
Functions\when( 'get_transient' )->justReturn( false );
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||||
|
|
||||||
|
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||||
|
$snapshot = $provider->get_board_snapshot();
|
||||||
|
|
||||||
|
$this->assertFalse( $snapshot['has_active_runs'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
266
tests/unit/Admin/BoardMenuRegistrationTest.php
Normal file
266
tests/unit/Admin/BoardMenuRegistrationTest.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for Board submenu registration order and hook-suffix correctness.
|
||||||
|
*
|
||||||
|
* Root-cause regression guard for the v0.19.1 hotfix:
|
||||||
|
* Board submenu was registered at admin_menu priority 10 BEFORE the parent
|
||||||
|
* top-level menu page (also priority 10), causing WordPress to record the
|
||||||
|
* render callback under the fallback hookname `admin_page_prautoblogger-board`
|
||||||
|
* instead of the correct `prautoblogger_page_prautoblogger-board`.
|
||||||
|
* At request time WP recomputes the hookname and finds nothing -> wp_die 404.
|
||||||
|
*
|
||||||
|
* ## Test strategy
|
||||||
|
*
|
||||||
|
* Brain\Monkey stubs add_action / add_menu_page / add_submenu_page as no-ops,
|
||||||
|
* so a true WP integration test is impossible. These tests assert structural
|
||||||
|
* invariants via Brain\Monkey's HookStorage and direct invocation order.
|
||||||
|
*
|
||||||
|
* ### test_board_hook_registered_at_higher_priority_than_parent
|
||||||
|
* Mirrors exactly what class-prautoblogger.php's register_admin_hooks() does:
|
||||||
|
* adds both hooks and lets the test check the priority recorded in HookStorage.
|
||||||
|
* - FAILS on origin/main (both add_action calls at default priority 10).
|
||||||
|
* - PASSES after fix (board at priority 11, parent at priority 10).
|
||||||
|
* This test is the true regression guard: revert to both-at-10 = test fails.
|
||||||
|
*
|
||||||
|
* ### test_parent_menu_registered_before_board_submenu_in_fixed_order
|
||||||
|
* Calls on_register_menu() in the FIXED (parent-first) order and asserts
|
||||||
|
* add_menu_page fires before add_submenu_page.
|
||||||
|
*
|
||||||
|
* ### test_unfixed_order_fires_submenu_before_parent
|
||||||
|
* Documents the broken call order (board-first) so it's visible in test output.
|
||||||
|
*
|
||||||
|
* ### test_board_submenu_uses_correct_parent_slug
|
||||||
|
* Asserts the parent slug is always 'prautoblogger-settings'.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class BoardMenuRegistrationTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/** @var array<int, string> Call sequence spy for menu registration order. */
|
||||||
|
private array $call_sequence = [];
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
$this->call_sequence = [];
|
||||||
|
|
||||||
|
// Spy stubs for WordPress menu registration functions.
|
||||||
|
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 ) {
|
||||||
|
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug;
|
||||||
|
return 'prautoblogger_page_prautoblogger-board';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Functions\when( 'current_user_can' )->justReturn( true );
|
||||||
|
Functions\when( 'get_option' )->justReturn( false );
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
$this->call_sequence = [];
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Priority relationship (regression guard)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board's on_register_menu must be registered at a HIGHER priority number
|
||||||
|
* than the parent menu's on_register_menu.
|
||||||
|
*
|
||||||
|
* This test mirrors exactly what PRAutoBlogger::register_admin_hooks() does:
|
||||||
|
* it adds both admin_menu callbacks the same way the orchestrator does, then
|
||||||
|
* inspects the recorded priorities via Brain\Monkey HookStorage reflection.
|
||||||
|
*
|
||||||
|
* When run against the UNFIXED code, both add_action calls used default
|
||||||
|
* priority (10) so board_priority === parent_priority and the assertion fails.
|
||||||
|
* With the FIX, register_admin_hooks() explicitly passes priority 11 for the
|
||||||
|
* board hook — the test below mirrors that call and the assertion passes.
|
||||||
|
*
|
||||||
|
* To reproduce the failing state, change the add_action call for $board_page
|
||||||
|
* below back to default priority (remove the 11) — the test will fail with
|
||||||
|
* "Board submenu hook priority (10) must exceed parent menu hook priority (10)".
|
||||||
|
*
|
||||||
|
* Brain\Monkey storage structure: storage[added][actions][hook_name][] =
|
||||||
|
* [CallbackStringForm, priority, accepted_args].
|
||||||
|
* CallbackStringForm::__toString() = "ClassName->method()".
|
||||||
|
*
|
||||||
|
* FAILS on unfixed code (both hooks at default priority 10).
|
||||||
|
* PASSES after fix (parent at 10, board at 11).
|
||||||
|
*/
|
||||||
|
public function test_board_hook_registered_at_higher_priority_than_parent(): void {
|
||||||
|
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||||
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
|
|
||||||
|
// *** Mirror register_admin_hooks() — this is the regression guard. ***
|
||||||
|
// UNFIXED origin/main used no explicit priority (both default to 10).
|
||||||
|
// FIXED uses 10 for parent and 11 for board. Update this when the fix changes.
|
||||||
|
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ), 10 );
|
||||||
|
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 );
|
||||||
|
// *** End mirror ***
|
||||||
|
|
||||||
|
// Inspect Brain\Monkey HookStorage via reflection.
|
||||||
|
$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;
|
||||||
|
|
||||||
|
// storage[added][actions][hook_name][] = [CallbackStringForm, priority, accepted_args]
|
||||||
|
$admin_menu_entries = $data['added']['actions']['admin_menu'] ?? array();
|
||||||
|
foreach ( $admin_menu_entries as $entry ) {
|
||||||
|
$cb_string = (string) ( $entry[0] ?? '' );
|
||||||
|
$priority = (int) ( $entry[1] ?? -1 );
|
||||||
|
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Admin_Page' ) ) {
|
||||||
|
$parent_priority = $priority;
|
||||||
|
}
|
||||||
|
if ( false !== strpos( $cb_string, 'PRAutoBlogger_Board_Page' ) ) {
|
||||||
|
$board_priority = $priority;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
-1,
|
||||||
|
$parent_priority,
|
||||||
|
'PRAutoBlogger_Admin_Page::on_register_menu must be registered on admin_menu'
|
||||||
|
);
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
-1,
|
||||||
|
$board_priority,
|
||||||
|
'PRAutoBlogger_Board_Page::on_register_menu must be registered on admin_menu'
|
||||||
|
);
|
||||||
|
|
||||||
|
// THE KEY ASSERTION: board fires AFTER parent (higher priority number).
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
$parent_priority,
|
||||||
|
$board_priority,
|
||||||
|
sprintf(
|
||||||
|
'Board submenu hook priority (%d) must exceed parent menu hook priority (%d). '
|
||||||
|
. 'Both at 10 is the root cause of the 404: add_submenu_page fires before '
|
||||||
|
. 'add_menu_page, WP falls back to admin_page_* hookname, request-time '
|
||||||
|
. 'recompute finds nothing, wp_die 404.',
|
||||||
|
$board_priority,
|
||||||
|
$parent_priority
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Execution order: on_register_menu calls
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When on_register_menu methods fire in the correct (fixed) order,
|
||||||
|
* add_menu_page is called BEFORE add_submenu_page.
|
||||||
|
*/
|
||||||
|
public function test_parent_menu_registered_before_board_submenu_in_fixed_order(): void {
|
||||||
|
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||||
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
|
|
||||||
|
// FIXED order: parent first (fires at priority 10), board second (priority 11).
|
||||||
|
$admin_page->on_register_menu();
|
||||||
|
$board_page->on_register_menu();
|
||||||
|
|
||||||
|
$add_menu_idx = null;
|
||||||
|
$add_submenu_idx = null;
|
||||||
|
foreach ( $this->call_sequence as $idx => $entry ) {
|
||||||
|
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
|
||||||
|
$add_menu_idx = $idx;
|
||||||
|
}
|
||||||
|
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
|
||||||
|
$add_submenu_idx = $idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
|
||||||
|
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
|
||||||
|
|
||||||
|
$this->assertLessThan(
|
||||||
|
$add_submenu_idx,
|
||||||
|
$add_menu_idx,
|
||||||
|
sprintf(
|
||||||
|
'add_menu_page must be called BEFORE add_submenu_page. '
|
||||||
|
. 'Call sequence: [%s].',
|
||||||
|
implode( ', ', $this->call_sequence )
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents the broken call order (root-cause proof): when board fires before
|
||||||
|
* parent, add_submenu_page fires before add_menu_page, which causes WP to use
|
||||||
|
* the fallback `admin_page_*` hookname -> 404.
|
||||||
|
*/
|
||||||
|
public function test_unfixed_order_fires_submenu_before_parent(): void {
|
||||||
|
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||||
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
|
|
||||||
|
// UNFIXED order: board fires first (as in origin/main register_admin_hooks).
|
||||||
|
$board_page->on_register_menu();
|
||||||
|
$admin_page->on_register_menu();
|
||||||
|
|
||||||
|
$add_menu_idx = null;
|
||||||
|
$add_submenu_idx = null;
|
||||||
|
foreach ( $this->call_sequence as $idx => $entry ) {
|
||||||
|
if ( 'add_menu_page' === $entry && null === $add_menu_idx ) {
|
||||||
|
$add_menu_idx = $idx;
|
||||||
|
}
|
||||||
|
if ( str_starts_with( $entry, 'add_submenu_page:' ) && null === $add_submenu_idx ) {
|
||||||
|
$add_submenu_idx = $idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertNotNull( $add_menu_idx, 'add_menu_page (parent) must be called' );
|
||||||
|
$this->assertNotNull( $add_submenu_idx, 'add_submenu_page (board) must be called' );
|
||||||
|
|
||||||
|
// In the broken order, add_submenu_page has a lower index (fires first).
|
||||||
|
$this->assertLessThan(
|
||||||
|
$add_menu_idx,
|
||||||
|
$add_submenu_idx,
|
||||||
|
'Documents the broken state: add_submenu_page fires before add_menu_page when '
|
||||||
|
. 'board is invoked first. WP registers under admin_page_* -> 404.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Parent slug correctness
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board submenu is always registered under 'prautoblogger-settings'.
|
||||||
|
*/
|
||||||
|
public function test_board_submenu_uses_correct_parent_slug(): void {
|
||||||
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
|
$board_page->on_register_menu();
|
||||||
|
|
||||||
|
$board_entry = null;
|
||||||
|
foreach ( $this->call_sequence as $entry ) {
|
||||||
|
if ( str_starts_with( $entry, 'add_submenu_page:' ) ) {
|
||||||
|
$board_entry = $entry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'add_submenu_page:prautoblogger-settings',
|
||||||
|
$board_entry,
|
||||||
|
'Board submenu must use parent slug "prautoblogger-settings"'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
198
tests/unit/Admin/DossierHooknameMechanismTest.php
Normal file
198
tests/unit/Admin/DossierHooknameMechanismTest.php
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for Dossier admin page hookname faithfulness — request-time mechanism.
|
||||||
|
*
|
||||||
|
* ## Why the v0.19.2 hide-by-unset pattern caused a 403
|
||||||
|
*
|
||||||
|
* WordPress computes a page's hook suffix at TWO separate moments:
|
||||||
|
*
|
||||||
|
* 1. Registration time (inside add_submenu_page):
|
||||||
|
* WP resolves the parent slug against $admin_page_hooks. If found, the hookname
|
||||||
|
* becomes "{parent_key}_page_{slug}" (e.g. "prautoblogger_page_prautoblogger-dossier").
|
||||||
|
*
|
||||||
|
* 2. Request time (inside wp-admin/admin.php):
|
||||||
|
* WP calls get_admin_page_parent() which scans $submenu for the slug. If not found
|
||||||
|
* (because it was unset post-registration), it falls back to "admin_page_{slug}".
|
||||||
|
*
|
||||||
|
* When these two hooknames diverge, the render callback lives under name-A but WP
|
||||||
|
* dispatches to name-B -- no handler found -- wp_die(403).
|
||||||
|
*
|
||||||
|
* The fix: options.php-parent guarantees "admin_page_{slug}" at BOTH moments.
|
||||||
|
* No $submenu mutation is needed or permitted.
|
||||||
|
*
|
||||||
|
* ## Discriminator function: simulate_request_time_hookname
|
||||||
|
*
|
||||||
|
* Replicates get_admin_page_parent() + hookname derivation in wp-admin/admin.php.
|
||||||
|
* Returns different hooknames for hide-by-unset vs options.php-parent, making the
|
||||||
|
* 403 divergence mechanically verifiable in a unit test.
|
||||||
|
*
|
||||||
|
* Structural invariant tests (parent slug, URL shape, priority) live in
|
||||||
|
* DossierMenuRegistrationTest.
|
||||||
|
*
|
||||||
|
* @see DossierMenuRegistrationTest -- structural invariants + deep-link URLs + priority.
|
||||||
|
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
||||||
|
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
class DossierHooknameMechanismTest extends DossierMenuRegistrationTestCase {
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// § REQUEST-TIME HOOKNAME FAITHFULNESS
|
||||||
|
// The primary regression guard for the 403 incident class.
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the request-time hookname resolution algorithm.
|
||||||
|
*
|
||||||
|
* Replicates get_admin_page_parent() + the hookname derivation in
|
||||||
|
* wp-admin/admin.php::wp_admin_canonical_url() / do_action("admin_{$page}"):
|
||||||
|
*
|
||||||
|
* scan $submenu for the slug
|
||||||
|
* if found at parent P → return hookname derived from P
|
||||||
|
* if not found → return "admin_page_{slug}"
|
||||||
|
*
|
||||||
|
* This is the discriminator function: it WILL return a different hookname than
|
||||||
|
* add_submenu_page returned when the slug has been unset from $submenu.
|
||||||
|
*
|
||||||
|
* @param array<string, array<int, array<int, string>>> $submenu_state $submenu after all registration + mutations.
|
||||||
|
* @param string $slug Page slug to resolve.
|
||||||
|
* @return string Request-time hookname.
|
||||||
|
*/
|
||||||
|
private function simulate_request_time_hookname( array $submenu_state, string $slug ): string {
|
||||||
|
foreach ( $submenu_state as $parent => $items ) {
|
||||||
|
foreach ( $items as $item ) {
|
||||||
|
if ( isset( $item[2] ) && $slug === $item[2] ) {
|
||||||
|
// Found the slug; derive hookname the same way as registration.
|
||||||
|
if ( 'options.php' === $parent ) {
|
||||||
|
return 'admin_page_' . $slug;
|
||||||
|
}
|
||||||
|
$parent_key = str_replace( '.php', '', $parent );
|
||||||
|
return $parent_key . '_page_' . $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Slug absent from $submenu -- WP falls back to the orphan namespace.
|
||||||
|
return 'admin_page_' . $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REQUEST-TIME FAITHFULNESS: registration hookname must equal request-time hookname.
|
||||||
|
*
|
||||||
|
* This test directly discriminates the two patterns:
|
||||||
|
*
|
||||||
|
* HIDE-BY-UNSET (v0.19.2, BROKEN):
|
||||||
|
* - add_submenu_page('prautoblogger-settings', ...) → 'prautoblogger_page_prautoblogger-dossier'
|
||||||
|
* - unset($submenu['prautoblogger-settings'][$idx])
|
||||||
|
* - post-registration $submenu does NOT contain the slug
|
||||||
|
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
|
||||||
|
* - 'prautoblogger_page_...' ≠ 'admin_page_...' → FAIL ✓ (test correctly detects broken state)
|
||||||
|
*
|
||||||
|
* OPTIONS.PHP-PARENT (v0.19.3, FIXED):
|
||||||
|
* - add_submenu_page('options.php', ...) → 'admin_page_prautoblogger-dossier'
|
||||||
|
* - no $submenu mutation
|
||||||
|
* - simulate_request_time_hookname → 'admin_page_prautoblogger-dossier'
|
||||||
|
* - MATCH → PASS ✓
|
||||||
|
*
|
||||||
|
* This test ALSO fails if any future change reintroduces $submenu mutation,
|
||||||
|
* or switches back to a plugin-owned parent without preserving the submenu entry.
|
||||||
|
*/
|
||||||
|
public function test_request_time_hookname_matches_registration_hookname(): void {
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
$dossier_page->on_register_menu();
|
||||||
|
|
||||||
|
$this->assertNotNull(
|
||||||
|
$this->registration_hookname,
|
||||||
|
'add_submenu_page must be called during on_register_menu()'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture $submenu state AFTER all mutations that on_register_menu() performs.
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
|
||||||
|
global $submenu;
|
||||||
|
$submenu_after = $submenu;
|
||||||
|
|
||||||
|
$request_time_hookname = $this->simulate_request_time_hookname(
|
||||||
|
$submenu_after,
|
||||||
|
\PRAutoBlogger_Dossier_Page::PAGE_SLUG
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$this->registration_hookname,
|
||||||
|
$request_time_hookname,
|
||||||
|
sprintf(
|
||||||
|
'Hookname mismatch: registration="%s", request-time="%s". '
|
||||||
|
. 'This is the 403 failure class. '
|
||||||
|
. 'The hide-by-unset pattern unsets the slug from $submenu after registration, '
|
||||||
|
. 'so get_admin_page_parent() fails at request time, WP recomputes under '
|
||||||
|
. 'the orphan "admin_page_*" namespace, finds no registered handler, '
|
||||||
|
. 'and calls wp_die(403). Fix: use options.php as parent -- hookname is '
|
||||||
|
. '"admin_page_{slug}" at both registration and request time, no mutation needed.',
|
||||||
|
$this->registration_hookname,
|
||||||
|
$request_time_hookname
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REGRESSION PROOF: documents that v0.19.2 hide-by-unset would have failed the
|
||||||
|
* request-time test above.
|
||||||
|
*
|
||||||
|
* Directly simulates the broken implementation, applies simulate_request_time_hookname,
|
||||||
|
* and asserts the MISMATCH -- proving the discriminator is effective.
|
||||||
|
*
|
||||||
|
* This test documents the failure state and serves as evidence that the test methodology
|
||||||
|
* is correct. It passes by verifying the broken pattern produces the wrong hookname.
|
||||||
|
*/
|
||||||
|
public function test_hide_by_unset_would_fail_request_time_check(): void {
|
||||||
|
// --- SIMULATE V0.19.2 hide-by-unset IMPLEMENTATION ---
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
|
||||||
|
global $submenu;
|
||||||
|
|
||||||
|
// Step 1: Registration (add_submenu_page with plugin parent).
|
||||||
|
// Real add_submenu_page stub returns 'prautoblogger_page_prautoblogger-dossier'
|
||||||
|
// when parent is 'prautoblogger-settings'. Populate $submenu as WP does.
|
||||||
|
$broken_parent_slug = 'prautoblogger-settings';
|
||||||
|
$slug = 'prautoblogger-dossier';
|
||||||
|
|
||||||
|
$submenu[ $broken_parent_slug ] = $submenu[ $broken_parent_slug ] ?? array();
|
||||||
|
$submenu[ $broken_parent_slug ][] = array( 'Article Dossier', 'manage_options', $slug, 'Dossier' );
|
||||||
|
|
||||||
|
// The hookname WP computed at registration time with plugin-owned parent:
|
||||||
|
$broken_registration_hookname = 'prautoblogger_page_' . $slug;
|
||||||
|
|
||||||
|
// Step 2: The unset mutation (the bug).
|
||||||
|
// This is what v0.19.2's on_register_menu() did after add_submenu_page.
|
||||||
|
foreach ( $submenu[ $broken_parent_slug ] as $idx => $item ) {
|
||||||
|
if ( isset( $item[2] ) && $slug === $item[2] ) {
|
||||||
|
unset( $submenu[ $broken_parent_slug ][ $idx ] );
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Request time -- slug is now ABSENT from $submenu.
|
||||||
|
$request_time_hookname = $this->simulate_request_time_hookname( $submenu, $slug );
|
||||||
|
|
||||||
|
// THE MISMATCH that caused the 403:
|
||||||
|
$this->assertNotSame(
|
||||||
|
$broken_registration_hookname,
|
||||||
|
$request_time_hookname,
|
||||||
|
'Test methodology error: hide-by-unset must produce a hookname mismatch. '
|
||||||
|
. 'If this assertion fails the discriminator is broken.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Specifically: request-time falls back to admin_page_* orphan namespace.
|
||||||
|
$this->assertSame(
|
||||||
|
'admin_page_' . $slug,
|
||||||
|
$request_time_hookname,
|
||||||
|
'Request-time fallback must be admin_page_{slug} when slug absent from $submenu.'
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'prautoblogger_page_' . $slug,
|
||||||
|
$broken_registration_hookname,
|
||||||
|
'Registration-time hookname with prautoblogger-settings parent must be prautoblogger_page_{slug}.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
235
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
235
tests/unit/Admin/DossierMenuRegistrationTest.php
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests for Dossier admin page menu registration — structural invariants.
|
||||||
|
*
|
||||||
|
* Covers: parent slug (options.php), $submenu non-mutation, page slug constant,
|
||||||
|
* asset enqueue gate prefix, deep-link URL shape, and hook priority ordering.
|
||||||
|
*
|
||||||
|
* Request-time hookname faithfulness tests live in DossierHooknameMechanismTest.
|
||||||
|
*
|
||||||
|
* @see DossierHooknameMechanismTest -- request-time discriminator + regression proof.
|
||||||
|
* @see ARCHITECTURE.md -- §22b (hidden admin page convention).
|
||||||
|
* @see CONVENTIONS.md -- §Hidden Admin Pages.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
use Brain\Monkey;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class DossierMenuRegistrationTest extends DossierMenuRegistrationTestCase {
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// § STRUCTURAL INVARIANTS
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parent slug must be PARENT_SLUG constant ('options.php').
|
||||||
|
*
|
||||||
|
* FAILS on v0.19.2 (parent was 'prautoblogger-settings').
|
||||||
|
* PASSES after v0.19.3 fix (parent is 'options.php').
|
||||||
|
*/
|
||||||
|
public function test_dossier_parent_slug_is_options_php(): void {
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
$dossier_page->on_register_menu();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'options.php',
|
||||||
|
$this->captured_parent_slug,
|
||||||
|
'Dossier must use options.php as parent (canonical hidden-page pattern). '
|
||||||
|
. 'Never use a plugin-owned parent with post-registration $submenu unset -- '
|
||||||
|
. 'that is the v0.19.2 403 bug class. See CONVENTIONS.md §Hidden Admin Pages.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
\PRAutoBlogger_Dossier_Page::PARENT_SLUG,
|
||||||
|
$this->captured_parent_slug,
|
||||||
|
'Captured parent slug must match PARENT_SLUG constant.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No $submenu mutation after registration.
|
||||||
|
*
|
||||||
|
* options.php-parent pages are hidden by construction; the slug must remain
|
||||||
|
* in $submenu so get_admin_page_parent() can resolve the parent at request time.
|
||||||
|
*
|
||||||
|
* FAILS on v0.19.2 (slug was unset from $submenu after registration).
|
||||||
|
* PASSES after v0.19.3 fix (no mutation).
|
||||||
|
*/
|
||||||
|
public function test_no_submenu_mutation_after_registration(): void {
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
$dossier_page->on_register_menu();
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test read.
|
||||||
|
global $submenu;
|
||||||
|
|
||||||
|
// The dossier slug must still be present somewhere in $submenu.
|
||||||
|
$slug_found = false;
|
||||||
|
foreach ( $submenu as $parent => $items ) {
|
||||||
|
foreach ( $items as $item ) {
|
||||||
|
if ( isset( $item[2] ) && \PRAutoBlogger_Dossier_Page::PAGE_SLUG === $item[2] ) {
|
||||||
|
$slug_found = true;
|
||||||
|
break 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue(
|
||||||
|
$slug_found,
|
||||||
|
'Dossier slug must remain in $submenu after on_register_menu() completes. '
|
||||||
|
. 'Unsetting it causes get_admin_page_parent() to fail at request time and '
|
||||||
|
. 'produces the admin_page_* orphan hookname -- the v0.19.2 403 bug class. '
|
||||||
|
. 'options.php-parent pages are hidden by construction; no mutation needed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page slug must be PAGE_SLUG constant ('prautoblogger-dossier').
|
||||||
|
*/
|
||||||
|
public function test_dossier_uses_correct_slug(): void {
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
$dossier_page->on_register_menu();
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
\PRAutoBlogger_Dossier_Page::PAGE_SLUG,
|
||||||
|
$this->captured_menu_slug,
|
||||||
|
'Dossier must register under PAGE_SLUG.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset enqueue gate must use 'admin_page_' prefix (not 'prautoblogger_page_').
|
||||||
|
*
|
||||||
|
* options.php-parent pages always resolve to 'admin_page_{slug}'.
|
||||||
|
* FAILS on v0.19.2 (gate checked 'prautoblogger_page_prautoblogger-dossier').
|
||||||
|
* PASSES after v0.19.3 fix (gate checks 'admin_page_prautoblogger-dossier').
|
||||||
|
*/
|
||||||
|
public function test_asset_enqueue_gate_uses_admin_page_prefix(): void {
|
||||||
|
Functions\when( 'wp_enqueue_style' )->justReturn( null );
|
||||||
|
Functions\when( 'wp_enqueue_script' )->justReturn( null );
|
||||||
|
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
|
||||||
|
$expected_hook = 'admin_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG;
|
||||||
|
|
||||||
|
// Wrong hook -- assets must NOT enqueue.
|
||||||
|
$enqueued = false;
|
||||||
|
Functions\when( 'wp_enqueue_style' )->alias( function () use ( &$enqueued ) {
|
||||||
|
$enqueued = true;
|
||||||
|
} );
|
||||||
|
|
||||||
|
$dossier_page->on_enqueue_assets( 'wrong_hook_prautoblogger_page_' . \PRAutoBlogger_Dossier_Page::PAGE_SLUG );
|
||||||
|
$this->assertFalse(
|
||||||
|
$enqueued,
|
||||||
|
'Assets must NOT enqueue when hook_suffix is the old prautoblogger_page_* hook.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Correct hook -- assets must enqueue.
|
||||||
|
$dossier_page->on_enqueue_assets( $expected_hook );
|
||||||
|
$this->assertTrue(
|
||||||
|
$enqueued,
|
||||||
|
sprintf(
|
||||||
|
'Assets must enqueue when hook_suffix is "%s" (admin_page_* from options.php parent).',
|
||||||
|
$expected_hook
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// § DEEP-LINK URL INVARIANCE
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* url_for_post() must produce admin.php?page=prautoblogger-dossier&post_id=N.
|
||||||
|
*
|
||||||
|
* Deep-link URLs are parent-agnostic -- they reference only the page slug.
|
||||||
|
* The fix changes only the registration parent; the URL shape is unchanged.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
'url_for_post() must contain the dossier page slug.'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'42',
|
||||||
|
$url,
|
||||||
|
'url_for_post() must contain the post_id.'
|
||||||
|
);
|
||||||
|
$this->assertStringContainsString(
|
||||||
|
'admin.php',
|
||||||
|
$url,
|
||||||
|
'url_for_post() must use admin.php (parent-agnostic deep-link).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* url_for_post() URL shape is unchanged from v0.19.2.
|
||||||
|
*
|
||||||
|
* Confirms the exact URL format that board cards and the metabox link depend on.
|
||||||
|
*/
|
||||||
|
public function test_url_for_post_exact_shape(): void {
|
||||||
|
$url = \PRAutoBlogger_Dossier_Page::url_for_post( 930 );
|
||||||
|
|
||||||
|
// Must contain admin.php?page=prautoblogger-dossier&post_id=930
|
||||||
|
// (or equivalent query string order -- admin_url may vary order).
|
||||||
|
$this->assertStringContainsString( 'page=prautoblogger-dossier', $url );
|
||||||
|
$this->assertStringContainsString( 'post_id=930', $url );
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// § HOOK REGISTRATION ORDERING (priority guard)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dossier on_register_menu must remain at priority 12 (after board 11, parent 10).
|
||||||
|
*
|
||||||
|
* The options.php-parent fix removes the dependency on ordering relative to the
|
||||||
|
* plugin's own parent menu (since we no longer add a submenu under it), but we
|
||||||
|
* keep the priority assertion to prevent accidental priority regression.
|
||||||
|
*/
|
||||||
|
public function test_dossier_hook_registered_at_priority_12(): void {
|
||||||
|
$admin_page = new \PRAutoBlogger_Admin_Page();
|
||||||
|
$board_page = new \PRAutoBlogger_Board_Page();
|
||||||
|
$dossier_page = new \PRAutoBlogger_Dossier_Page();
|
||||||
|
|
||||||
|
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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
tests/unit/Admin/DossierMenuRegistrationTestCase.php
Normal file
120
tests/unit/Admin/DossierMenuRegistrationTestCase.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Shared test harness for Dossier menu registration test suites.
|
||||||
|
*
|
||||||
|
* Provides the Brain\Monkey spy stubs (add_menu_page, add_submenu_page) and the
|
||||||
|
* $submenu isolation that both DossierMenuRegistrationTest and
|
||||||
|
* DossierHooknameMechanismTest depend on.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Admin
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Admin;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
abstract class DossierMenuRegistrationTestCase extends BaseTestCase {
|
||||||
|
|
||||||
|
/** @var array<int, string> Call sequence spy. */
|
||||||
|
protected array $call_sequence = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured parent slug from the most recent add_submenu_page call.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
protected ?string $captured_parent_slug = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured menu slug from the most recent add_submenu_page call.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
protected ?string $captured_menu_slug = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hookname returned by the most recent add_submenu_page call.
|
||||||
|
*
|
||||||
|
* In real WordPress this is `{parent_key}_page_{slug}` when parent is known,
|
||||||
|
* or `admin_page_{slug}` when parent is not in $admin_page_hooks. For options.php
|
||||||
|
* children WordPress always emits `admin_page_{slug}`.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
protected ?string $registration_hookname = null;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
$this->call_sequence = [];
|
||||||
|
$this->captured_parent_slug = null;
|
||||||
|
$this->captured_menu_slug = null;
|
||||||
|
$this->registration_hookname = null;
|
||||||
|
|
||||||
|
// Spy stub: tracks call order, captures slugs, returns a hookname that
|
||||||
|
// reflects how WordPress actually resolves it for known parents.
|
||||||
|
Functions\when( 'add_menu_page' )->alias(
|
||||||
|
function () {
|
||||||
|
$this->call_sequence[] = 'add_menu_page';
|
||||||
|
return 'prautoblogger-settings';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate WordPress hookname computation:
|
||||||
|
// - options.php parent → 'admin_page_{slug}'
|
||||||
|
// - known plugin parent → '{parent_key}_page_{slug}'
|
||||||
|
// - unknown parent → 'admin_page_{slug}' (orphan fallback)
|
||||||
|
Functions\when( 'add_submenu_page' )->alias(
|
||||||
|
function (
|
||||||
|
string $parent_slug,
|
||||||
|
string $page_title,
|
||||||
|
string $menu_title,
|
||||||
|
string $capability,
|
||||||
|
string $menu_slug,
|
||||||
|
$callback = ''
|
||||||
|
) {
|
||||||
|
$this->call_sequence[] = 'add_submenu_page:' . $parent_slug . ':' . $menu_slug;
|
||||||
|
$this->captured_parent_slug = $parent_slug;
|
||||||
|
$this->captured_menu_slug = $menu_slug;
|
||||||
|
|
||||||
|
if ( 'options.php' === $parent_slug ) {
|
||||||
|
$hookname = 'admin_page_' . $menu_slug;
|
||||||
|
} else {
|
||||||
|
$parent_key = str_replace( '.php', '', $parent_slug );
|
||||||
|
$hookname = $parent_key . '_page_' . $menu_slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->registration_hookname = $hookname;
|
||||||
|
|
||||||
|
// Populate $submenu as WordPress does at registration time.
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test simulation.
|
||||||
|
global $submenu;
|
||||||
|
$submenu[ $parent_slug ] = $submenu[ $parent_slug ] ?? array();
|
||||||
|
$submenu[ $parent_slug ][] = array( $page_title, $capability, $menu_slug, $menu_title );
|
||||||
|
|
||||||
|
return $hookname;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Functions\when( 'current_user_can' )->justReturn( true );
|
||||||
|
Functions\when( 'get_option' )->justReturn( false );
|
||||||
|
Functions\when( 'admin_url' )->alias(
|
||||||
|
function ( string $path = '' ) {
|
||||||
|
return 'https://example.com/wp-admin/' . ltrim( $path, '/' );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Functions\when( '__' )->returnArg( 1 );
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test isolation.
|
||||||
|
$GLOBALS['submenu'] = array();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
$this->call_sequence = [];
|
||||||
|
$this->captured_parent_slug = null;
|
||||||
|
$this->captured_menu_slug = null;
|
||||||
|
$this->registration_hookname = null;
|
||||||
|
unset( $GLOBALS['submenu'] );
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
122
tests/unit/Core/CostReporterSqlBugFixTest.php
Normal file
122
tests/unit/Core/CostReporterSqlBugFixTest.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Tests verifying the SQL inline-comment bug fix in PRAutoBlogger_Cost_Reporter.
|
||||||
|
*
|
||||||
|
* The original code embedded PHP `// phpcs:ignore` annotations inside SQL string
|
||||||
|
* literals, passing them to MySQL as literal comment text. These tests confirm the
|
||||||
|
* SQL strings passed to $wpdb->prepare() no longer contain inline PHP comments.
|
||||||
|
*
|
||||||
|
* Kept separate from CostReporterTest to isolate the bug-fix regression signal.
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger\Tests\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace PRAutoBlogger\Tests\Core;
|
||||||
|
|
||||||
|
use PRAutoBlogger\Tests\BaseTestCase;
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
|
||||||
|
class CostReporterSqlBugFixTest extends BaseTestCase {
|
||||||
|
|
||||||
|
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||||
|
private $wpdb;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->wpdb = $this->create_mock_wpdb();
|
||||||
|
$GLOBALS['wpdb'] = $this->wpdb;
|
||||||
|
|
||||||
|
Functions\when( 'get_option' )->alias(
|
||||||
|
function ( $name, $default = false ) {
|
||||||
|
return 'prautoblogger_monthly_budget_usd' === $name ? '50.00' : $default;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
unset( $GLOBALS['wpdb'] );
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_daily_spend() must not pass inline PHP comments to prepare().
|
||||||
|
*
|
||||||
|
* Captures the SQL argument passed to $wpdb->prepare() and asserts it
|
||||||
|
* contains no `// phpcs:ignore` text (which would be literal SQL, not PHP).
|
||||||
|
*/
|
||||||
|
public function test_get_daily_spend_sql_has_no_inline_comment(): void {
|
||||||
|
$captured_sql = null;
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||||
|
function ( string $query ) use ( &$captured_sql ) {
|
||||||
|
$captured_sql = $query;
|
||||||
|
return 'prepared_query';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||||
|
|
||||||
|
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||||
|
$reporter->get_daily_spend( 30 );
|
||||||
|
|
||||||
|
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
'// phpcs:ignore',
|
||||||
|
$captured_sql,
|
||||||
|
'get_daily_spend() SQL must not contain a PHP inline comment'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get_spend_by_stage() must not pass inline PHP comments to prepare().
|
||||||
|
*/
|
||||||
|
public function test_get_spend_by_stage_sql_has_no_inline_comment(): void {
|
||||||
|
$captured_sql = null;
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||||
|
function ( string $query ) use ( &$captured_sql ) {
|
||||||
|
$captured_sql = $query;
|
||||||
|
return 'prepared_query';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||||
|
|
||||||
|
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||||
|
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
|
||||||
|
|
||||||
|
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
'// phpcs:ignore',
|
||||||
|
$captured_sql,
|
||||||
|
'get_spend_by_stage() SQL must not contain a PHP inline comment'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the existing monthly spend query was already clean (regression guard).
|
||||||
|
*/
|
||||||
|
public function test_get_monthly_spend_sql_was_already_clean(): void {
|
||||||
|
$captured_sql = null;
|
||||||
|
|
||||||
|
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||||
|
function ( string $query ) use ( &$captured_sql ) {
|
||||||
|
$captured_sql = $query;
|
||||||
|
return 'prepared_query';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->wpdb->method( 'get_var' )->willReturn( '5.00' );
|
||||||
|
|
||||||
|
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||||
|
$reporter->get_monthly_spend();
|
||||||
|
|
||||||
|
$this->assertNotNull( $captured_sql );
|
||||||
|
$this->assertStringNotContainsString(
|
||||||
|
'// phpcs:ignore',
|
||||||
|
$captured_sql,
|
||||||
|
'get_monthly_spend() SQL must not contain a PHP inline comment'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user