From 49f17c3d6fdd0a8364aaa29462716ce125bddec3 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:55:48 +0700 Subject: [PATCH] v0.19.0 - Phase 2 admin M1: kanban board + live progress; cost-reporter SQL fix (#156) M1 of the Editorial Record admin (Direction C, CEO-ratified 2026-06-12). Kanban dashboard (Generating/In Review/Published/Failed) as primary landing, AJAX live progress via status poller, setting-backed poll interval + published window, cost-reporter phpcs:ignore SQL fix, board column mapping tests. QA: REJECT-P1 (29a334b) -> remediated -> APPROVE-WITH-P2-NOTES (60aa449). Verdicts: qa-reviews/prautoblogger/2026-06-12-29a334b.md, 2026-06-12-60aa449.md Thread: convo/prautoblogger/threads/2026-06-pipeline-v2-phase2-admin/ seq 1-9 Agent-Session: cto-phase2-admin-m1-merge-20260612 --- ARCHITECTURE.md | 24 ++ CHANGELOG.md | 276 ++--------------- assets/css/board.css | 249 +++++++++++++++ assets/js/board.js | 238 ++++++++++++++ includes/admin/class-admin-page.php | 3 +- includes/admin/class-board-data-provider.php | 185 +++++++++++ includes/admin/class-board-gen-log-query.php | 184 +++++++++++ includes/admin/class-board-page.php | 150 +++++++++ .../admin/class-settings-fields-extended.php | 48 ++- includes/class-prautoblogger.php | 47 ++- includes/core/class-cost-reporter.php | 8 +- prautoblogger.php | 23 +- templates/admin/board-page.php | 160 ++++++++++ tests/bootstrap.php | 46 ++- tests/unit/Admin/BoardDataProviderTest.php | 292 ++++++++++++++++++ tests/unit/Core/CostReporterSqlBugFixTest.php | 122 ++++++++ 16 files changed, 1738 insertions(+), 317 deletions(-) create mode 100644 assets/css/board.css create mode 100644 assets/js/board.js mode change 100755 => 100644 includes/admin/class-admin-page.php create mode 100644 includes/admin/class-board-data-provider.php create mode 100644 includes/admin/class-board-gen-log-query.php create mode 100644 includes/admin/class-board-page.php create mode 100644 templates/admin/board-page.php create mode 100644 tests/unit/Admin/BoardDataProviderTest.php create mode 100644 tests/unit/Core/CostReporterSqlBugFixTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a180ee7..ba80f86 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -119,9 +119,11 @@ prautoblogger/ │ │ └── OFL.txt # SIL Open Font License │ ├── css/ │ │ ├── admin.css # Admin page styles (wp-admin conventions) +│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0) │ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars) │ └── js/ │ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js) +│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0) │ └── posts-widget.js # React component for frontend post cards (wp.element) │ ├── includes/ @@ -137,6 +139,9 @@ prautoblogger/ │ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes │ │ │ ├── admin/ +│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0) +│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot, delegates gen_log queries (v0.19.0) +│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0) │ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI) │ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources) │ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images @@ -239,6 +244,7 @@ prautoblogger/ │ ├── templates/ │ └── admin/ +│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0) │ ├── settings-page.php # Settings page template (tabbed sidebar layout) │ ├── metrics-page.php # Metrics/cost dashboard template │ ├── review-queue.php # Review queue table template @@ -608,6 +614,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_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') | | `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes | +| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. | +| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). | ### Post Meta @@ -851,6 +859,22 @@ 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. + +### #22b: Kanban board as primary admin landing screen (v0.19.0) + +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 first so it appears before Settings in the nav. +`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. + ## Cross-System LLM Budget Coordination PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 435e4ef..4b4781e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,247 +5,40 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). -## [0.18.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 -- **Analysis-prompts SQL / GA4 self-improvement loop (R1 — P2)**: `phpcs:ignore` annotation - was embedded inside the SQL string literal in `class-analysis-prompts.php:81` (same pattern - as the v0.18.2 cost-reporter fix). MariaDB rejected the query on every pipeline run, - causing `get_performance_context()` to always return `''` — the LLM research/analysis - stage has had no historical performance context since v0.16.0. Annotation moved to the - `// phpcs:ignore` pragma above `get_results()`. Sibling occurrence also fixed in - `class-post-assembler.php:112` (same pattern). Regression + behavioral tests added to - `AnalysisPromptsTest`. -- **Manual-run resilience — transient renewal (R2a)**: `update_generation_stage()` in the - new `PRAutoBlogger_Generation_Status_Poller` now renews the STATUS_TTL on every stage - update, ensuring the status transient outlasts any single LLM call even when `Pipeline_ - Status::broadcast()` is delayed by a long model call. -- **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 -- **Executor split (300-line rule)**: `class-executor.php` was at 351 lines. AJAX generation - handlers (`on_ajax_generate_now`, `on_ajax_generation_status`, `abort_orphaned_run`, - `update_generation_stage`) extracted to new `class-generation-status-poller.php` (260 lines). - Executor retains cron handlers and delegates AJAX calls to the poller. Public API unchanged. -- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the - lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table. +## [0.19.0] - 2026-06-12 ### Added -- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated - from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API. - Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch, - taskUUID:,offset:N,limit:N}]`. Response normalization updated for the new shape: - `air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage` - (filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`. - Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache - option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows. -- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of - 100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community - checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap - warning is logged if the cap is reached before exhausting `totalResults`. Client-side - `io:text-to-image` capability filter applied — no server-side filter is available. -- **Negative-result failure cooldown**: `sync()` records a failure timestamp in - `prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()` - gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable - window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h, - never hardcoded at point of use). A successful sync clears the timestamp. This caps the - admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent. -- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire - `taskType:models` outage. Tests verify it is always non-empty and correctly priced. - - -## [0.18.2] - 2026-06-12 - -Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25). +- **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 (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 -- `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik - span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines. - No behavior change: same prompts, models, params, span names, stage values. -- `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only - reporting query); the Cost_Tracker method remains as a delegating wrapper. -- `OpenRouter_Provider`'s API-key fetch + format validation moved to - `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 - verbatim to the new `PRAutoBlogger_Pipeline_Status` (Pipeline_Runner back under the - 300-line cap at 271). - -### Fixed -- ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always - written `polish`. Canonicalized to **`polish`** everywhere. -- Half-migrated-schema hardening: generation_log inserts retry without the new audit - columns when the columns are missing (a cron run right after deploy no longer loses cost - rows), and the db-version self-heal now also runs on cron requests (`init` + - `wp_doing_cron()`), not only on `admin_init`. -- OpenRouter pricing now resolves dated snapshot model IDs returned by the API - (e.g. response model `deepseek/deepseek-v4-flash-20260423` for a - `deepseek/deepseek-v4-flash` request) to the base model's price before the - conservative-estimate fallback applies. Such responses were previously booked - at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) — - phantom spend that ate the per-run cost ceiling and the monthly budget gate. +- **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 @@ -1161,6 +954,7 @@ swap with no user-visible change in article quality. run_id-based log linking, RuntimeException on WP_Error, action/filter hook firing. Replaces previous method-existence-only checks. + ## [0.2.1] — 2026-04-12 ### Changed @@ -1197,14 +991,6 @@ swap with no user-visible change in article quality. - Posts-per-subreddit limit (5–100) in Sources tab. - 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 ### Added diff --git a/assets/css/board.css b/assets/css/board.css new file mode 100644 index 0000000..52d1110 --- /dev/null +++ b/assets/css/board.css @@ -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; +} diff --git a/assets/js/board.js b/assets/js/board.js new file mode 100644 index 0000000..f06e566 --- /dev/null +++ b/assets/js/board.js @@ -0,0 +1,238 @@ +/** + * 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 $( '
' ).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 += '
'; + + if ( isGenerating ) { + html += '
'; + } + + html += '
' + escHtml( card.title || cfg.strings.empty ) + '
'; + + if ( card.cost_total ) { + html += '
'; + html += escHtml( formatCost( card.cost_total ) ); + html += '
'; + } + + if ( isGenerating && card.stage_current ) { + html += '
'; + /* translators: stage name */ + html += escHtml( cfg.strings.generating + ': ' + card.stage_current ); + html += '
'; + } + + if ( card.error_message ) { + var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' ); + if ( card.error_message.split( ' ' ).length > 12 ) { + errShort += '…'; + } + html += '
' + escHtml( errShort ) + '
'; + } + + // Click-through link. + var linkUrl = ''; + var linkText = ''; + 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 += ''; + } + + html += '
'; + 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( '

' + escHtml( cfg.strings.empty ) + '

' ); + 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 = $( '' ); + $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 ) ); diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php old mode 100755 new mode 100644 index a06decb..e9eb6c0 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -62,6 +62,7 @@ class PRAutoBlogger_Admin_Page { public function on_enqueue_assets( string $hook_suffix ): void { $pages = array( 'toplevel_page_' . self::PAGE_SLUG, + 'prautoblogger_page_prautoblogger-board', 'prautoblogger_page_prautoblogger-metrics', 'prautoblogger_page_prautoblogger-review-queue', 'prautoblogger_page_prautoblogger-ideas', @@ -323,7 +324,7 @@ class PRAutoBlogger_Admin_Page { 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 ) ) { return is_numeric( $value ) ? $value : 0; } diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php new file mode 100644 index 0000000..35a25dc --- /dev/null +++ b/includes/admin/class-board-data-provider.php @@ -0,0 +1,185 @@ +gen_log_query = $gen_log_query ?? new PRAutoBlogger_Board_Gen_Log_Query(); + } + + /** + * Return the full board snapshot consumed by the AJAX poller. + * + * Each column is an array of card objects. Cards contain only the data + * needed to render the board card and provide click-throughs. + * + * @return array{ + * generating: array>, + * in_review: array>, + * published: array>, + * failed: array>, + * 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. + * Delegates to PRAutoBlogger_Board_Gen_Log_Query. + * + * @return array> + */ + public function get_generating_cards(): array { + return $this->gen_log_query->get_generating_cards(); + } + + /** + * Cards for generation runs that ended in an error state. + * Delegates to PRAutoBlogger_Board_Gen_Log_Query. + * + * @return array> + */ + public function get_failed_cards(): array { + return $this->gen_log_query->get_failed_cards(); + } + + /** + * Cards for generated draft posts awaiting human review. + * + * @return array> + */ + 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' => 'review', + '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. + * + * @return array> + */ + 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' => 'edit', + 'edit_url' => get_edit_post_link( $post->ID, 'raw' ), + 'post_url' => get_permalink( $post->ID ), + ); + } + + return $cards; + } +} diff --git a/includes/admin/class-board-gen-log-query.php b/includes/admin/class-board-gen-log-query.php new file mode 100644 index 0000000..4f9f278 --- /dev/null +++ b/includes/admin/class-board-gen-log-query.php @@ -0,0 +1,184 @@ +> + */ + 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> + */ + 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; + } +} diff --git a/includes/admin/class-board-page.php b/includes/admin/class-board-page.php new file mode 100644 index 0000000..69d0864 --- /dev/null +++ b/includes/admin/class-board-page.php @@ -0,0 +1,150 @@ + 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 ); + } +} diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index e4f154c..20a0768 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -78,17 +78,29 @@ class PRAutoBlogger_Settings_Fields_Extended { 'low' => __( 'Low — light reasoning, lower cost', '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( - 'id' => 'prautoblogger_reasoning_max_tokens', - 'label' => __( 'Reasoning Token Cap', 'prautoblogger' ), + 'id' => 'prautoblogger_board_published_window_days', + 'label' => __( 'Published Column Window (days)', 'prautoblogger' ), 'type' => 'number', - 'section' => 'prautoblogger_models', - 'default' => PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS, - 'min' => 0, - 'max' => 32000, - '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' ), + 'section' => 'prautoblogger_schedule', + 'default' => 7, + 'min' => 1, + 'max' => 90, + 'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ), ), // ── Schedule & Budget ─────────────────────────────────────── @@ -123,26 +135,6 @@ class PRAutoBlogger_Settings_Fields_Extended { 'step' => '0.01', '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 ────────────────────────────────────────────── array( diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index f846679..074078c 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -7,19 +7,19 @@ declare(strict_types=1); * Main orchestrator for the PRAutoBlogger plugin. * * Registers all WordPress hooks (actions and filters) and wires up dependencies. - * This is the ONLY place hooks are registered — individual classes contain only + * This is the ONLY place hooks are registered -- individual classes contain only * business logic, not hook registration. * * Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`. * Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor. * - * @see prautoblogger.php — Plugin bootstrap that instantiates this class. - * @see class-executor.php — Cron/AJAX handlers for generation, model registry. - * @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test). - * @see class-generation-lock.php — DB mutex for single-writer generation. - * @see core/class-pipeline-runner.php — Executes the generation pipeline. - * @see ARCHITECTURE.md — Data flow diagram showing how classes interact. - * @see CONVENTIONS.md — Hook naming conventions. + * @see prautoblogger.php -- Plugin bootstrap 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 { @@ -46,7 +46,7 @@ class PRAutoBlogger { $this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() ); add_action( 'admin_init', array( $this, 'on_check_db_version' ) ); - // v0.18.0: cron requests must also self-heal the schema — after a + // v0.18.0: cron requests must also self-heal the schema -- after a // deploy, scheduled generation can run before any admin page load, // and new-column inserts would silently fail on the old schema. add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) ); @@ -67,6 +67,11 @@ class PRAutoBlogger { /** Register admin-only hooks (settings, notices, metabox, dashboard widget). */ private function register_admin_hooks(): void { + // Kanban board -- registered first so it appears as the first submenu item. + $board_page = new PRAutoBlogger_Board_Page(); + add_action( 'admin_menu', array( $board_page, 'on_register_menu' ) ); + add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) ); + $admin_page = new PRAutoBlogger_Admin_Page(); add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) ); add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) ); @@ -86,7 +91,7 @@ class PRAutoBlogger { ( new PRAutoBlogger_Post_List_Columns() )->register(); - // Block false update notifications — our slug collides with another plugin. + // Block false update notifications -- our slug collides with another plugin. add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) ); } @@ -111,7 +116,7 @@ class PRAutoBlogger { // Hostinger's 120-second web-server connection timeout. add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) ); - // Chained article generation — each queued article fires as its own + // Chained article generation -- each queued article fires as its own // cron event so it gets a fresh PHP process and execution time budget. add_action( PRAutoBlogger_Pipeline_Runner::CRON_ACTION, @@ -197,6 +202,12 @@ class PRAutoBlogger { add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) ); + // Board status polling AJAX (nonce: prautoblogger_board). + add_action( + 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, + array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) + ); + $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_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) ); @@ -280,7 +291,7 @@ class PRAutoBlogger { // 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. + // NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md paragraph 8 Commit 1. if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) { $old_suffix = get_option( 'prautoblogger_image_style_suffix', '' ); if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) { @@ -293,7 +304,7 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_style_template_v0160', '1' ); } - // v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI. + // 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(); @@ -311,9 +322,11 @@ class PRAutoBlogger { } /** - * Run the db-version check on cron requests (admin_init never fires - * there). Gated to wp_doing_cron() so normal frontend requests pay - * nothing beyond one autoloaded option read. + * Run schema migrations during cron requests. + * + * Wraps on_check_db_version() so it only fires during WP-Cron execution. + * After a deploy, the daily generation cron can fire before any admin page + * load -- without this, new-column inserts would silently fail. * * Side effects: may run the same migrations as on_check_db_version(). * @@ -363,7 +376,7 @@ class PRAutoBlogger { return $this->executor; } - // ── Backward-compatible proxies ── + // -- Backward-compatible proxies -- /** @see PRAutoBlogger_Executor::on_daily_generation() */ public function on_daily_generation(): void { diff --git a/includes/core/class-cost-reporter.php b/includes/core/class-cost-reporter.php index c0cccbe..4d5689f 100644 --- a/includes/core/class-cost-reporter.php +++ b/includes/core/class-cost-reporter.php @@ -36,8 +36,9 @@ class PRAutoBlogger_Cost_Reporter { $first_of_month = gmdate( 'Y-m-01 00:00:00' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $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'", $first_of_month ) @@ -64,8 +65,8 @@ class PRAutoBlogger_Cost_Reporter { $start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->prepare( "SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost FROM {$table} @@ -102,8 +103,8 @@ class PRAutoBlogger_Cost_Reporter { $table = $wpdb->prefix . 'prautoblogger_generation_log'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $results = $wpdb->get_results( - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->prepare( "SELECT stage, SUM(estimated_cost) as total_cost, @@ -142,6 +143,7 @@ class PRAutoBlogger_Cost_Reporter { } return ( $this->get_monthly_spend() / $budget ) * 100.0; } + /** * Get average input/output token counts for given stages over a time period. * diff --git a/prautoblogger.php b/prautoblogger.php index d179067..1502d49 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.18.3 + * Version: 0.19.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,31 +35,14 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.18.3' ); -define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.19.0' ); +define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); // 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 ); -// 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_API_TIMEOUT_SECONDS', 180 ); define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 ); diff --git a/templates/admin/board-page.php b/templates/admin/board-page.php new file mode 100644 index 0000000..318db30 --- /dev/null +++ b/templates/admin/board-page.php @@ -0,0 +1,160 @@ +get_board_snapshot(); + +/** + * Render a single board card as HTML. + * + * @param array $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'; + } + ?> +
+ + +
+ + +
+ + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ + + +
+ +
+ +
+

+ + +

+ +
+ + 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'] ); + ?> +
+
+ + +
+
+ +

+ + + + + +
+
+ + +
+ + +
diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3de8303..c4d1b67 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -103,12 +103,17 @@ if ( ! defined( 'OBJECT' ) ) { /** * Minimal WP_Query stub for unit tests. * - * IdeaScorer uses WP_Query to check for existing posts. - * This stub returns empty results by default. + * IdeaScorer uses WP_Query to check for existing posts with empty results. + * 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' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound class WP_Query { + /** @var array FIFO queue of post-arrays for tests. */ + public static $_test_posts_queue = []; + /** @var array */ public $posts = []; /** @var int */ @@ -117,7 +122,11 @@ if ( ! class_exists( 'WP_Query' ) ) { public $have_posts = false; 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 { @@ -165,3 +174,34 @@ 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 $data + */ + public function __construct( array $data = [] ) { + foreach ( $data as $key => $value ) { + $this->$key = $value; + } + } + } +} diff --git a/tests/unit/Admin/BoardDataProviderTest.php b/tests/unit/Admin/BoardDataProviderTest.php new file mode 100644 index 0000000..8adc485 --- /dev/null +++ b/tests/unit/Admin/BoardDataProviderTest.php @@ -0,0 +1,292 @@ +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'] ); + $this->assertSame( 'review', $cards[0]['click_action'] ); + $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'] ); + $this->assertSame( 'edit', $cards[0]['click_action'] ); + } + + // ── 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'] ); + } +} diff --git a/tests/unit/Core/CostReporterSqlBugFixTest.php b/tests/unit/Core/CostReporterSqlBugFixTest.php new file mode 100644 index 0000000..cba3e47 --- /dev/null +++ b/tests/unit/Core/CostReporterSqlBugFixTest.php @@ -0,0 +1,122 @@ +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' + ); + } +}