From 8b789b3f1b3dc348e1dcb299ac0093110989da04 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:03:03 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20Pipeline=20Settings=20M2=20=E2=80=94=20?= =?UTF-8?q?decompose=20Content/AI=20Models/Sources=20into=20per-step=20pan?= =?UTF-8?q?els=20+=20Global=20Content=20Context=20(#172,=20v0.24.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocates Content/AI Models/Sources fields into the per-step Pipeline panels; adds a Global Content Context block; retires the 3 duplicate Settings tabs. Option keys unchanged (no migration). QA APPROVE for 80d8e5f; CI green. Agent-Session: pipeline-settings-m2-merge --- ARCHITECTURE.md | 53 ++-- CHANGELOG.md | 40 +++ CONTEXT.md | 14 + CONVENTIONS.md | 23 ++ INFORMATION-ARCHITECTURE.md | 6 +- ...s-pipeline-settings-option-fields-data.php | 243 +++++++++++++++ .../class-pipeline-settings-option-fields.php | 125 ++++++++ .../class-pipeline-settings-renderer.php | 63 ++-- .../class-pipeline-settings-save-handler.php | 130 ++++---- .../admin/class-settings-fields-extended.php | 68 +--- includes/admin/class-settings-fields.php | 207 ++---------- prautoblogger.php | 4 +- templates/admin/pipeline-settings-page.php | 61 ++-- .../admin/pipeline-settings-step-options.php | 127 ++++++++ .../PipelineSettingsOptionFieldsTest.php | 214 +++++++++++++ .../Admin/PipelineSettingsStepSaveTest.php | 294 ++++++++++++++++++ .../unit/Admin/SettingsFieldsExtendedTest.php | 16 +- 17 files changed, 1308 insertions(+), 380 deletions(-) create mode 100644 includes/admin/class-pipeline-settings-option-fields-data.php create mode 100644 includes/admin/class-pipeline-settings-option-fields.php create mode 100644 templates/admin/pipeline-settings-step-options.php create mode 100644 tests/unit/Admin/PipelineSettingsOptionFieldsTest.php create mode 100644 tests/unit/Admin/PipelineSettingsStepSaveTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a9435ac..7ebdb17 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -866,33 +866,46 @@ Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell ### #15: Optional Cloudflare AI Gateway in front of OpenRouter We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged. -### #25: Pipeline Settings page — per-step model, prompts, params (v0.23.0, M1) +### #25: Pipeline Settings page — per-step model, prompts, params + step options (v0.23.0 M1, v0.24.0 M2) -**Scope (M1 — additive):** A new "Pipeline" wp-admin submenu page -(`prautoblogger-pipeline`, slug `prautoblogger_page_prautoblogger-pipeline`) that -surfaces per-step configuration for every LLM stage. The page is ADDITIVE — the -existing Settings sections (AI Models, Content, Sources) remain in place. Decomposition -into stage-exclusive panels is M2. +**M1 (v0.23.0 — additive):** A new "Pipeline" wp-admin submenu page surfacing per-step +model picker, system instructions, and agent prompts for every LLM stage. In M1 the +existing Settings sections (AI Models, Content, Sources) remained in place. + +**M2 (v0.24.0 — decomposition):** Retired the AI Models, Content, and Sources Settings +tabs. All fields from those tabs now live exclusively in Pipeline Settings per-step panels. +The underlying wp_options are unchanged; only the editing UI has moved. The Settings page +now shows only: API Keys, Schedule & Budget, Publishing, Analytics, Display, Images. + +**Retired sections:** `prautoblogger_models`, `prautoblogger_content`, `prautoblogger_sources` +removed from `PRAutoBlogger_Settings_Fields::get_sections()`. Their field definitions +removed from `get_core_fields()` and `PRAutoBlogger_Settings_Fields_Extended::get_fields()`. +The `uninstall.php` wildcard purge (`LIKE 'prautoblogger\_%'`) still covers all options. + +**New M2 classes:** +- `PRAutoBlogger_Pipeline_Settings_Option_Fields` — public API: `contexts()`, `allowed_options()`, `get_fields_for_context()`, `sanitize_option()`. +- `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data` — raw field arrays per context (split for 300-line rule). + +**Save handler extension:** `pipeline_action = save_step_settings` with `step_context` +(global|research|analysis|writer|editorial). Validates context, iterates field defs, +sanitizes per type (textarea/select/number/toggle/checkboxes), persists via `update_option()`. + +**Template additions:** Global Content Context block (editable niche_description form) +at top of Pipeline page; per-step `pipeline-settings-step-options.php` partial renders +type-aware field controls inside the step panel. **Classes:** `PRAutoBlogger_Pipeline_Settings_Page` (registration + asset enqueue), `PRAutoBlogger_Pipeline_Settings_Renderer` (view data assembly + template include), `PRAutoBlogger_Pipeline_Settings_Save_Handler` (nonce + capability + sanitize + write), -`PRAutoBlogger_Pipeline_Settings_Step_Map` (canonical step definitions + key allowlists). +`PRAutoBlogger_Pipeline_Settings_Step_Map` (canonical step definitions + key allowlists), +`PRAutoBlogger_Pipeline_Settings_Option_Fields` + `_Data` (step option allowlist + sanitizer). -**Model picker reuse:** `PRAutoBlogger_OpenRouter_Model_Field::render()` is called -directly — no fork, no new dropdown. The stage map in `get_stages_for_setting()` was -extended to include `prautoblogger_research_model → [research, llm_research]` and -`prautoblogger_image_model → [image_a, image_b]` so cost-preview is stage-accurate. +**Security:** `manage_options` + nonce on every POST. Option names validated against +`PRAutoBlogger_Pipeline_Settings_Option_Fields::allowed_options()` (enforced per field id, +not via a POST-key allowlist, since the save handler iterates field defs directly). -**Prompt writes:** Saving a prompt creates a new immutable row in `wp_prautoblogger_prompts` -via `PRAutoBlogger_Prompt_Registry_Writer::create_version()` and activates it. -Only the keys listed in `PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys()` -may be updated through this page (allowlist enforced in the save handler). - -**Security:** `manage_options` capability check + nonce verification on every POST. -Prompt bodies are sanitized with `sanitize_textarea_field()`. - -**No new options:** M1 adds no persistent `wp_option` keys. Uninstall unchanged. +**Prompt writes (M1, unchanged):** Immutable registry rows via +`PRAutoBlogger_Prompt_Registry_Writer::create_version()`; only allowlisted prompt keys. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 3293464..d0afcd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ 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.24.0] - 2026-06-23 + +### Added +- **Pipeline Settings M2 — step option fields:** All per-step content settings are now + editable directly in the Pipeline Settings page. + - **Global Content Context** block at top of Pipeline page: editable `prautoblogger_niche_description` form (replaces the read-only preview from M1). + - **Research step:** `enabled_sources`, `target_subreddits`, `reddit_time_filter`, `reddit_posts_per_subreddit`, `pullpush_cache_ttl`, `research_prompt` moved here from Sources tab. + - **Analysis step:** `analysis_instructions`, `topic_exclusions` moved here from Content tab. + - **Writer step:** `writing_pipeline`, `tone`, `min_word_count`, `max_word_count`, `writing_instructions`, `reasoning_enabled`, `reasoning_effort` moved here from Content/AI Models tabs. + - **Editorial step:** `editor_instructions` moved here from Content tab. + - New `pipeline_action = save_step_settings` POST handler with `step_context` routing; validates context, iterates field definitions, sanitizes per field type, and persists via `update_option()`. + - New `PRAutoBlogger_Pipeline_Settings_Option_Fields` (public API: `contexts()`, `allowed_options()`, `get_fields_for_context()`, `sanitize_option()`) and companion data class `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data`. + - New `pipeline-settings-step-options.php` partial template renders type-aware field controls (textarea, select, number, toggle, checkboxes). + +### Changed / Retired +- **Settings tabs `prautoblogger_models`, `prautoblogger_content`, `prautoblogger_sources` retired.** + Removed from `PRAutoBlogger_Settings_Fields::get_sections()` and all field definitions removed from `get_core_fields()` / `PRAutoBlogger_Settings_Fields_Extended::get_fields()`. The wp_option keys are unchanged; sanitization now happens in the Pipeline page save handler. `uninstall.php` wildcard purge is unaffected. +- `PRAutoBlogger_Pipeline_Settings_Renderer::render()` now passes `global_fields`, `step_context`, and `step_fields` to the template. + +### New Files +- `includes/admin/class-pipeline-settings-option-fields.php` +- `includes/admin/class-pipeline-settings-option-fields-data.php` +- `templates/admin/pipeline-settings-step-options.php` + + +### Tests +- **PHPUnit (P1-1 QA fix):** Added `test_research_context_saves_source_settings()` and + `test_research_context_checkboxes_filters_unknown_sources()` to + `tests/unit/Admin/PipelineSettingsStepSaveTest.php`. Covers the research context + integration path end-to-end: `step_context=research` → fields fetched → POST read + → `sanitize_option()` (checkboxes type) → `update_option()` persisted. First test + asserts `status=saved` and that `prautoblogger_enabled_sources` decodes to `["reddit"]`; + also asserts `prautoblogger_reddit_time_filter` and that the posts-per-subreddit + value is an integer. Second test asserts that unknown source keys are stripped + by the choices allowlist while valid keys (`reddit`, `llm_research`) survive. +- **Parse-error fix (P1-2 QA fix):** Moved the two research test methods inside + the `PipelineSettingsStepSaveTest` class body; a premature class-closing brace + after `test_missing_nonce_field_returns_idle()` caused a PHP parse error + (CI red on all 3 PHP versions). Brace count corrected: 21 `{` = 21 `}`. + ## [0.23.0] - 2026-06-22 ### Added diff --git a/CONTEXT.md b/CONTEXT.md index c013370..5c27a5c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -105,3 +105,17 @@ Full table names are defined in `class-activator.php`. --- *See also: `ARCHITECTURE.md` §25 (checkpoint design), `CONVENTIONS.md` (coding rules).* + +--- + +## Pipeline Settings M2 (v0.24.0) + +| Term | Definition | +|------|-----------| +| **step context** | A routing key (`global\|research\|analysis\|writer\|editorial`) that identifies which group of option fields is being edited in the `save_step_settings` handler. Validated against the `PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts()` allowlist before any write. Distinct from "step" (the display unit in the step rail) — `global` is a context with no corresponding step button. | +| **Global Content Context** | The editable form block rendered above the step rail in the Pipeline Settings page (v0.24.0). Surfaces `prautoblogger_niche_description` and any future cross-step fields. Uses `step_context=global` in its save form. Not associated with an LLM step. | +| **option field** | A `wp_option`-backed field surfaced in a step panel or the Global Content Context block. Defined in `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data`. Distinct from a "prompt panel" (which edits the prompt registry). Types: `textarea`, `select`, `number`, `toggle`, `checkboxes`. | +| **step option** | Synonym for option field when it appears inside a step panel (i.e., not in the Global Content Context). | +| **`step_context`** | The POST key that routes `save_step_settings` to the correct field set. Expected values: `global`, `research`, `analysis`, `writer`, `editorial`. Validated with `sanitize_key()` before use. | +| **`pipeline_action=save_step_settings`** | POST contract key introduced in M2. Triggers `PRAutoBlogger_Pipeline_Settings_Save_Handler::handle_step_settings_save()`. Parallel to the existing `save_model`, `save_prompt`, and `reset_prompt` action values. | +| **relocated-tabs concept** | The Settings tabs `prautoblogger_models`, `prautoblogger_content`, and `prautoblogger_sources` were retired in M2; their 17 option fields moved to per-step panels in the Pipeline page. The `wp_option` keys are unchanged — only the admin UI surface that edits them moved. See `CONVENTIONS.md §Retired Settings Tabs`. | diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 1fae5c2..04c3646 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -202,6 +202,29 @@ the uninstall prefix-sweep effective), then cover it in the unit ladder tests. --- + +--- + +## Retired Settings Tabs (M2, v0.24.0) + +Settings tabs may be retired when their fields are promoted to a more contextual UI +surface (e.g. Pipeline Settings per-step panels). The retirement pattern: + +1. **Remove the section** from `PRAutoBlogger_Settings_Fields::get_sections()`. +2. **Remove field definitions** from `get_core_fields()` / `_Extended::get_fields()`. + This stops them appearing in the Settings page and stops `register_setting()` calls + for them — which is correct: the new UI surface (Pipeline page) has its own + nonce+capability+sanitize handler and does NOT use the WP Settings API. +3. **DO NOT delete the wp_option.** The underlying data key is unchanged. + `uninstall.php` purges everything via `LIKE 'prautoblogger\_%'`. +4. **Add field definitions to** `PRAutoBlogger_Pipeline_Settings_Option_Fields_Data` + under the appropriate context method. +5. **Document** the retirement in ARCHITECTURE.md #25 and CHANGELOG.md. +6. **Do not add `register_setting()`** in the Pipeline page — its save handler sanitizes + directly, so WP Settings API registration is not needed. + +Retired sections in M2: `prautoblogger_models`, `prautoblogger_content`, `prautoblogger_sources`. + ## How To: Add a New Pipeline Stage Writing stages are methods on `Content_Generator` that delegate to its `execute_stage()` diff --git a/INFORMATION-ARCHITECTURE.md b/INFORMATION-ARCHITECTURE.md index e532965..a1237c3 100644 --- a/INFORMATION-ARCHITECTURE.md +++ b/INFORMATION-ARCHITECTURE.md @@ -9,9 +9,9 @@ Maintained per DoD v1.1.0 (same-PR update rule). | Slug | Class | Template | Purpose | |------|-------|----------|---------| -| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) | +| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings: API Keys, Schedule & Budget, Publishing, Analytics, Display, Images. AI Models / Content / Sources retired to Pipeline Settings in M2 (v0.24.0). | | `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). | -| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: model picker, system instructions, agent prompts, params (M1, v0.23.0). | +| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: Global Content Context (niche), step option fields, model picker, system instructions, agent prompts, params (M1 v0.23.0; M2 v0.24.0 adds editable step options, retires AI Models/Content/Sources Settings tabs). | | `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs | | `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) | | `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation | @@ -82,4 +82,4 @@ DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1. --- -*Last updated: v0.21.0 (2026-06-14)* +*Last updated: v0.24.0 (2026-06-23)* diff --git a/includes/admin/class-pipeline-settings-option-fields-data.php b/includes/admin/class-pipeline-settings-option-fields-data.php new file mode 100644 index 0000000..a2cb67b --- /dev/null +++ b/includes/admin/class-pipeline-settings-option-fields-data.php @@ -0,0 +1,243 @@ +> + */ + public static function fields_for( string $context ): array { + switch ( $context ) { + case 'global': + return self::global_fields(); + case 'research': + return self::research_fields(); + case 'analysis': + return self::analysis_fields(); + case 'writer': + return self::writer_fields(); + case 'editorial': + return self::editorial_fields(); + default: + return array(); + } + } + + /** + * Global context: niche description fed to all pipeline stages. + * + * @return array> + */ + private static function global_fields(): array { + return array( + array( + 'id' => 'prautoblogger_niche_description', + 'label' => __( 'Niche Description', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'Describe your site\'s niche. Passed to every pipeline stage as context.', 'prautoblogger' ), + ), + ); + } + + /** + * Research step context fields (moved from Sources Settings tab). + * + * @return array> + */ + private static function research_fields(): array { + return array( + array( + 'id' => 'prautoblogger_enabled_sources', + 'label' => __( 'Enabled Sources', 'prautoblogger' ), + 'type' => 'checkboxes', + 'choices' => array( + 'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ), + 'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ), + ), + 'default' => '["reddit"]', + 'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_target_subreddits', + 'label' => __( 'Target Subreddits', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_reddit_time_filter', + 'label' => __( 'Reddit Time Window', 'prautoblogger' ), + 'type' => 'select', + 'default' => 'day', + 'options' => array( + 'day' => __( 'Past 24 hours', 'prautoblogger' ), + 'week' => __( 'Past week', 'prautoblogger' ), + 'month' => __( 'Past month', 'prautoblogger' ), + ), + 'description' => __( 'How far back to search for trending posts.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_reddit_posts_per_subreddit', + 'label' => __( 'Posts per Subreddit', 'prautoblogger' ), + 'type' => 'number', + 'default' => 25, + 'min' => 5, + 'max' => 100, + 'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_pullpush_cache_ttl', + 'label' => __( 'Research Cache (hours)', 'prautoblogger' ), + 'type' => 'number', + 'default' => 6, + 'min' => 1, + 'max' => 72, + 'description' => __( 'How long to cache Reddit research results before re-fetching.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_research_prompt', + 'label' => __( 'Research Prompt', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description.', 'prautoblogger' ), + ), + ); + } + + /** + * Analysis step context fields (moved from Content Settings tab). + * + * @return array> + */ + private static function analysis_fields(): array { + return array( + array( + 'id' => 'prautoblogger_analysis_instructions', + 'label' => __( 'Analysis Instructions', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'Custom instructions for the topic analysis LLM.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_topic_exclusions', + 'label' => __( 'Topic Exclusions', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'Comma-separated topics to never write about.', 'prautoblogger' ), + ), + ); + } + + /** + * Writer step context fields (moved from AI Models + Content Settings tabs). + * + * @return array> + */ + private static function writer_fields(): array { + return array( + array( + 'id' => 'prautoblogger_writing_pipeline', + 'label' => __( 'Writing Pipeline', 'prautoblogger' ), + 'type' => 'select', + 'default' => 'multi_step', + 'options' => array( + 'multi_step' => __( 'Multi-step (outline → draft → polish)', 'prautoblogger' ), + 'single_pass' => __( 'Single-pass (one LLM call)', 'prautoblogger' ), + ), + 'description' => __( 'Controls which writer sub-stage prompts are active.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_tone', + 'label' => __( 'Content Tone', 'prautoblogger' ), + 'type' => 'select', + 'default' => 'informational', + 'options' => array( + 'informational' => __( 'Informational', 'prautoblogger' ), + 'conversational' => __( 'Conversational', 'prautoblogger' ), + 'professional' => __( 'Professional', 'prautoblogger' ), + 'casual' => __( 'Casual', 'prautoblogger' ), + 'authoritative' => __( 'Authoritative', 'prautoblogger' ), + ), + ), + array( + 'id' => 'prautoblogger_min_word_count', + 'label' => __( 'Min Word Count', 'prautoblogger' ), + 'type' => 'number', + 'default' => 800, + 'min' => 200, + ), + array( + 'id' => 'prautoblogger_max_word_count', + 'label' => __( 'Max Word Count', 'prautoblogger' ), + 'type' => 'number', + 'default' => 2000, + 'min' => 500, + ), + array( + 'id' => 'prautoblogger_writing_instructions', + 'label' => __( 'Writing Instructions', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'Custom instructions appended to the writing LLM system prompt.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_reasoning_enabled', + 'label' => __( 'Enable Reasoning', 'prautoblogger' ), + 'type' => 'toggle', + 'default' => '0', + 'description' => __( 'Send reasoning instructions to models that support it. Reasoning tokens are billed as output tokens.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_reasoning_effort', + 'label' => __( 'Reasoning Effort', 'prautoblogger' ), + 'type' => 'select', + 'default' => 'medium', + 'options' => array( + 'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ), + 'high' => __( 'High — thorough reasoning', 'prautoblogger' ), + 'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ), + 'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ), + 'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ), + ), + 'description' => __( 'Only applies when reasoning is enabled.', 'prautoblogger' ), + ), + ); + } + + /** + * Editorial step context fields (moved from Content Settings tab). + * + * @return array> + */ + private static function editorial_fields(): array { + return array( + array( + 'id' => 'prautoblogger_editor_instructions', + 'label' => __( 'Editor Instructions', 'prautoblogger' ), + 'type' => 'textarea', + 'default' => '', + 'description' => __( 'Custom instructions for the chief editor LLM review pass.', 'prautoblogger' ), + ), + ); + } +} diff --git a/includes/admin/class-pipeline-settings-option-fields.php b/includes/admin/class-pipeline-settings-option-fields.php new file mode 100644 index 0000000..6fe2aee --- /dev/null +++ b/includes/admin/class-pipeline-settings-option-fields.php @@ -0,0 +1,125 @@ + label map of allowed values. + * choices array For 'checkboxes': key => label map. + * min/max int For 'number': enforced bounds. + * default mixed Fallback when option is not set. + * label string Human-readable field label. + * description string Help text shown below the field. + * + * @param string $context One of self::contexts(). + * @return array> + */ + public static function get_fields_for_context( string $context ): array { + return PRAutoBlogger_Pipeline_Settings_Option_Fields_Data::fields_for( $context ); + } + + /** + * Sanitize a raw POST value for a single field. + * + * Handles each field type independently: textarea → sanitize_textarea_field, + * select → key validated against the options allowlist, number → absint + + * bounds clamp, toggle → '0'|'1', checkboxes → JSON array of allowed keys. + * + * @param mixed $value Raw value from $_POST. + * @param array $field Field definition from get_fields_for_context(). + * @return mixed Sanitized value ready for update_option(). + */ + public static function sanitize_option( mixed $value, array $field ): mixed { + $type = (string) ( $field['type'] ?? 'textarea' ); + + switch ( $type ) { + case 'textarea': + return sanitize_textarea_field( (string) $value ); + + case 'toggle': + return ( '1' === (string) $value ) ? '1' : '0'; + + case 'number': + $int = absint( $value ); + if ( isset( $field['min'] ) && $int < (int) $field['min'] ) { + $int = (int) $field['min']; + } + if ( isset( $field['max'] ) && $int > (int) $field['max'] ) { + $int = (int) $field['max']; + } + return $int; + + case 'select': + $key = sanitize_key( (string) $value ); + $allowed = array_keys( (array) ( $field['options'] ?? array() ) ); + return in_array( $key, $allowed, true ) ? $key : (string) ( $field['default'] ?? '' ); + + case 'checkboxes': + if ( ! is_array( $value ) ) { + $value = array(); + } + $allowed = array_keys( (array) ( $field['choices'] ?? array() ) ); + $filtered = array(); + foreach ( $value as $item ) { + $item = sanitize_key( (string) $item ); + if ( in_array( $item, $allowed, true ) ) { + $filtered[] = $item; + } + } + return wp_json_encode( $filtered ); + + default: + return sanitize_text_field( (string) $value ); + } + } +} diff --git a/includes/admin/class-pipeline-settings-renderer.php b/includes/admin/class-pipeline-settings-renderer.php index 62fe5a7..7f3464b 100644 --- a/includes/admin/class-pipeline-settings-renderer.php +++ b/includes/admin/class-pipeline-settings-renderer.php @@ -8,29 +8,28 @@ declare(strict_types=1); * * What: Reads current option values, active prompt versions, and registry * params, then passes a typed data structure to the pipeline-settings- - * page.php template. The renderer contains NO business logic — it only - * reads and shapes data for the view. All output is escaped by the - * template or by helper methods on this class. - * Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() after - * the save handler runs. + * page.php template. Also reads current values of all per-step option + * fields (M2) so the template can render editable forms without touching + * WP globals. The renderer contains NO business logic — it only reads + * and shapes data for the view. + * Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() after save. * Dependencies: PRAutoBlogger_Pipeline_Settings_Step_Map (step list), + * PRAutoBlogger_Pipeline_Settings_Option_Fields (step option defs), * PRAutoBlogger_Prompt_Registry (active row, versions, defs), * PRAutoBlogger_OpenRouter_Model_Field (model picker render), * PRAutoBlogger_Cost_Reporter (monthly spend header). * - * @see admin/class-pipeline-settings-step-map.php — Step + key definitions. - * @see admin/fields/class-open-router-model-field.php — Model picker component. - * @see core/class-prompt-registry.php — Registry read API. - * @see templates/admin/pipeline-settings-page.php — HTML template. + * @see admin/class-pipeline-settings-step-map.php — Step + key definitions. + * @see admin/class-pipeline-settings-option-fields.php — Step option field defs. + * @see admin/fields/class-open-router-model-field.php — Model picker component. + * @see core/class-prompt-registry.php — Registry read API. + * @see templates/admin/pipeline-settings-page.php — HTML template. */ class PRAutoBlogger_Pipeline_Settings_Renderer { /** * Build the view-data array and include the page template. * - * Gathers budget and spend data here so the template receives plain scalars - * and performs no business-logic instantiation of its own. - * * @param array{status: string, message: string} $save_result Result from save handler. * @return void */ @@ -39,11 +38,15 @@ class PRAutoBlogger_Pipeline_Settings_Renderer { $steps = PRAutoBlogger_Pipeline_Settings_Step_Map::steps(); $step = PRAutoBlogger_Pipeline_Settings_Step_Map::find( $active_step ) ?? $steps[0]; - // Gather cost-header data in the renderer so the template stays logic-free. $cost_reporter = new PRAutoBlogger_Cost_Reporter(); $monthly_spend = $cost_reporter->get_monthly_spend(); $budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); + // Determine the step context for the step-options panel. + $step_context = in_array( $step['id'], PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts(), true ) + ? $step['id'] + : null; + $view = array( 'steps' => $steps, 'active_step' => $step, @@ -54,11 +57,33 @@ class PRAutoBlogger_Pipeline_Settings_Renderer { 'step_data' => $this->build_step_data( $step ), 'monthly_spend' => $monthly_spend, 'budget' => $budget, + 'global_fields' => $this->build_option_field_values( 'global' ), + 'step_context' => $step_context, + 'step_fields' => $step_context ? $this->build_option_field_values( $step_context ) : array(), ); include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-page.php'; } + /** + * Build current option values for all fields in a context. + * + * Returns an array of [ field_def_with_current_value ] — each field + * definition from Option_Fields augmented with a 'current' key holding + * the live wp_option value (or the field's default when not set). + * + * @param string $context Step context identifier. + * @return array> + */ + public function build_option_field_values( string $context ): array { + $fields = PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( $context ); + foreach ( $fields as &$field ) { + $field['current'] = get_option( $field['id'], $field['default'] ?? '' ); + } + unset( $field ); + return $fields; + } + /** * Assemble all data needed to render a single step panel. * @@ -69,21 +94,17 @@ class PRAutoBlogger_Pipeline_Settings_Renderer { $defs = PRAutoBlogger_Prompt_Registry::defs(); $active_versions_map = PRAutoBlogger_Prompt_Registry::active_versions(); - // Model info. $model_option = (string) ( $step['model_option'] ?? '' ); $model_value = '' !== $model_option ? (string) get_option( $model_option, '' ) : ''; - // System prompt. $system_key = (string) ( $step['system_key'] ?? '' ); $system_data = $this->build_prompt_panel_data( $system_key, $defs, $active_versions_map ); - // Agent/user prompts. $agent_panels = array(); foreach ( ( $step['agent_keys'] ?? array() ) as $key ) { $agent_panels[ $key ] = $this->build_prompt_panel_data( $key, $defs, $active_versions_map ); } - // Params from the def for the system key (canonical params are on system). $params = isset( $defs[ $system_key ] ) ? ( $defs[ $system_key ]['params'] ?? array() ) : array(); return array( @@ -116,8 +137,7 @@ class PRAutoBlogger_Pipeline_Settings_Renderer { $is_default = ( $body === $default_body ); $created_at = null !== $active_row ? (string) $active_row['created_at'] : ''; $author = null !== $active_row ? (string) $active_row['author'] : 'seed'; - - $versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key ); + $versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key ); return array( 'key' => $key, @@ -134,10 +154,9 @@ class PRAutoBlogger_Pipeline_Settings_Renderer { /** * Render the model picker for a step using the existing field component. * - * Public so the template can call it directly. Keeps markup generation - * in one place and avoids duplicating the picker rendering logic. + * Public so the template can call it directly. * - * @param string $model_option wp_option name (e.g. 'prautoblogger_writing_model'). + * @param string $model_option wp_option name. * @param string $model_value Current model id stored in the option. * @param array $field_args Field args forwarded to the picker. * @return void Side effect: outputs HTML. diff --git a/includes/admin/class-pipeline-settings-save-handler.php b/includes/admin/class-pipeline-settings-save-handler.php index 8c42d61..1f0977e 100644 --- a/includes/admin/class-pipeline-settings-save-handler.php +++ b/includes/admin/class-pipeline-settings-save-handler.php @@ -8,30 +8,27 @@ declare(strict_types=1); * * What: A stateless save handler for the Pipeline Settings page. On a valid * POST it verifies the nonce, checks capabilities, sanitizes inputs, - * and either: - * - saves the model option via update_option(), or - * - creates a new immutable prompt version in the registry - * (PRAutoBlogger_Prompt_Registry_Writer::create_version()). - * Saving a prompt NEVER mutates the existing version — it creates a - * new one (the registry's core invariant). A "reset to default" POST - * creates a new version with the canonical default body. - * Model values are read from the POST key that matches the option name - * (e.g. $_POST['prautoblogger_research_model']), which is the hidden - * input name that PRAutoBlogger_OpenRouter_Model_Field::render() emits. - * Prompt keys arrive as a slug-encoded form value that is matched - * against the allowlist; the raw POST value is never used as a key - * before allowlist validation. + * and dispatches to the correct sub-handler: + * - save_model — persists a step model option via update_option(). + * - save_prompt — creates a new immutable prompt version in the registry. + * - reset_prompt — creates a new version with the canonical default body. + * - save_step_settings — persists one or more relocated option fields for + * a step context (global/research/analysis/writer/editorial). + * Saving a prompt NEVER mutates the existing version — the registry's core + * invariant. Model + step-option values read from POST keys matching the + * option name. Prompt keys are slug-matched against the allowlist. * Returns a result array that render_page() passes to the template. - * Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() before - * any output is produced. + * Who calls it: PRAutoBlogger_Pipeline_Settings_Page::render_page() before output. * Dependencies: PRAutoBlogger_Pipeline_Settings_Step_Map (key allowlists), + * PRAutoBlogger_Pipeline_Settings_Option_Fields (step option allowlist), * PRAutoBlogger_Prompt_Registry_Writer (create_version), * PRAutoBlogger_Prompt_Registry (default_body, flush_cache). * - * @see admin/class-pipeline-settings-step-map.php — Allowed key definitions. - * @see admin/fields/class-open-router-model-field.php — Emits name="$option_name" hidden input. - * @see core/class-prompt-registry-writer.php — Immutable version writes. - * @see core/class-prompt-registry.php — Read side + default_body. + * @see admin/class-pipeline-settings-step-map.php — Model/prompt key allowlists. + * @see admin/class-pipeline-settings-option-fields.php — Step option allowlist + sanitizer. + * @see admin/fields/class-open-router-model-field.php — Emits name="$option_name". + * @see core/class-prompt-registry-writer.php — Immutable version writes. + * @see core/class-prompt-registry.php — Read side + default_body. */ class PRAutoBlogger_Pipeline_Settings_Save_Handler { @@ -39,15 +36,11 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { * Inspect the current request and process the save when it is a valid * Pipeline Settings form submission. * - * Side effects: may call update_option() or - * PRAutoBlogger_Prompt_Registry_Writer::create_version(); - * returns error on nonce/cap failure. - * * @return array{status: string, message: string} 'idle'|'saved'|'error'. */ public static function maybe_process_save(): array { $idle = array( - 'status' => 'idle', + 'status' => 'idle', 'message' => '', ); @@ -59,19 +52,17 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { return $idle; } - // Capability check. if ( ! current_user_can( 'manage_options' ) ) { return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'You do not have permission to save settings.', 'prautoblogger' ), ); } - // Nonce verification. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( ! wp_verify_nonce( wp_unslash( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ), PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION ) ) { return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'Security check failed. Please reload and try again.', 'prautoblogger' ), ); } @@ -81,6 +72,9 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { if ( 'save_model' === $action ) { return self::handle_model_save(); } + if ( 'save_step_settings' === $action ) { + return self::handle_step_settings_save(); + } if ( 'save_prompt' === $action || 'reset_prompt' === $action ) { return self::handle_prompt_save( $action ); } @@ -88,14 +82,53 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { return $idle; } + /** + * Persist option fields for a step context. + * + * Reads step_context from POST, validates it, then for each field in that + * context reads the corresponding POST value, sanitizes via + * PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option(), and + * calls update_option(). Only allowlisted option names are written. + * + * @return array{status: string, message: string} + */ + private static function handle_step_settings_save(): array { + $context = isset( $_POST['step_context'] ) ? sanitize_key( $_POST['step_context'] ) : ''; + + if ( ! in_array( $context, PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts(), true ) ) { + return array( + 'status' => 'error', + 'message' => __( 'Unknown step context.', 'prautoblogger' ), + ); + } + + $fields = PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( $context ); + + foreach ( $fields as $field ) { + $id = (string) $field['id']; + $raw = isset( $_POST[ $id ] ) ? wp_unslash( $_POST[ $id ] ) : ( $field['default'] ?? '' ); + // For checkboxes the POST value is an array (or absent = empty array). + if ( 'checkboxes' === ( $field['type'] ?? '' ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $raw = isset( $_POST[ $id ] ) && is_array( $_POST[ $id ] ) ? $_POST[ $id ] : array(); + } + $sanitized = PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( $raw, $field ); + update_option( $id, $sanitized ); + } + + return array( + 'status' => 'saved', + 'message' => __( 'Settings saved.', 'prautoblogger' ), + ); + } + /** * Persist a model selection for one step. * * The model value is read from $_POST[$option_name] — the same key that * PRAutoBlogger_OpenRouter_Model_Field::render($option_name, ...) emits as * . model-picker.js writes to - * that hidden input directly via DOM id, so the value is present under the - * option-name key in the submitted POST body. + * that hidden input directly via DOM id. * * Only allowlisted model option names from Step_Map may be updated. * @@ -106,22 +139,15 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { if ( ! in_array( $option_name, PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options(), true ) ) { return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'Unknown model option.', 'prautoblogger' ), ); } - // Read the value from the named hidden input that render() produced. - // PRAutoBlogger_OpenRouter_Model_Field::render($id, ...) emits: - // - // model-picker.js writes the chosen model to that input by DOM id, - // so the form posts under the option name, not a separate 'model_id' key. $model_id = isset( $_POST[ $option_name ] ) ? sanitize_text_field( wp_unslash( $_POST[ $option_name ] ) ) : ''; - // For the image model, delegate to the same sanitizer logic as - // PRAutoBlogger_Settings_Sanitizer to derive + store provider consistently. if ( 'prautoblogger_image_model' === $option_name ) { $candidate = sanitize_text_field( $model_id ); $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); @@ -129,19 +155,19 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { update_option( 'prautoblogger_image_provider', $provider ); update_option( $option_name, $candidate ); return array( - 'status' => 'saved', + 'status' => 'saved', 'message' => __( 'Model saved.', 'prautoblogger' ), ); } return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'Image model not recognised. Selection unchanged.', 'prautoblogger' ), ); } update_option( $option_name, sanitize_text_field( $model_id ) ); return array( - 'status' => 'saved', + 'status' => 'saved', 'message' => __( 'Model saved.', 'prautoblogger' ), ); } @@ -151,25 +177,17 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { * * The prompt key is received as a slug (dots replaced with hyphens in the * form value). We match the slug against the allowlist by resolving each - * allowed key to its slug form — this avoids any reverse-parsing ambiguity - * for keys that contain underscores (e.g. content.single_pass). - * - * Prompt bodies are treated as privileged — only sanitize_textarea_field() - * is applied to strip any script injection, but content is not further - * mangled — the operator is intentionally writing LLM instructions. + * allowed key to its slug form — avoiding reverse-parsing ambiguity. * * @param string $action 'save_prompt' or 'reset_prompt'. * @return array{status: string, message: string} */ private static function handle_prompt_save( string $action ): array { - // The form sends the key with dots replaced by hyphens (HTML-safe slug). - $slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : ''; - - // Resolve slug back to the real registry key via allowlist lookup. + $slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : ''; $prompt_key = self::resolve_key_from_slug( $slug ); if ( null === $prompt_key ) { return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'Unknown prompt key.', 'prautoblogger' ), ); } @@ -178,7 +196,7 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { $body = PRAutoBlogger_Prompt_Registry::default_body( $prompt_key ); if ( null === $body ) { return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'No default found for this prompt key.', 'prautoblogger' ), ); } @@ -200,7 +218,7 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { if ( 0 === $version ) { return array( - 'status' => 'error', + 'status' => 'error', 'message' => __( 'Failed to save prompt. Is the prompts table available?', 'prautoblogger' ), ); } @@ -215,10 +233,6 @@ class PRAutoBlogger_Pipeline_Settings_Save_Handler { /** * Resolve a URL/form slug back to the canonical registry key. * - * Each allowed key is converted to a slug (dots → hyphens, everything - * lowercased by sanitize_key) and compared against the submitted value. - * No string-reverse parsing — only allowlist membership determines a match. - * * @param string $slug Slug as received from the form (after sanitize_key). * @return string|null Canonical key, or null when not in the allowlist. */ diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 314f5a0..1b6e39a 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -11,17 +11,23 @@ declare(strict_types=1); * Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE constants, * PRAutoBlogger_Article_Typography (font choices), PRAutoBlogger_Image_Model_Registry. * - * @see admin/class-settings-fields.php — Core fields and sections; calls get_extended_fields(). - * @see admin/class-admin-page.php — Renders fields registered from these definitions. - * @see admin/class-image-model-registry.php — Static image model list extracted for 300-line compliance. - * @see frontend/class-article-typography.php — Reads Display settings on the frontend. - * @see CONVENTIONS.md — "How To: Add a New Admin Setting". + * M2 change: AI Models (reasoning_enabled, reasoning_effort) and Sources + * (research_model, research_prompt) extended fields have been retired from + * Settings — they now live in Pipeline Settings per-step panels. The + * underlying wp_options are unchanged. See CONVENTIONS.md §Retired Settings Tabs. + * + * @see admin/class-settings-fields.php — Core fields and sections. + * @see admin/class-admin-page.php — Renders fields from these definitions. + * @see admin/class-image-model-registry.php — Static image model list. + * @see admin/class-pipeline-settings-option-fields.php — Now owns Models + Sources fields. + * @see frontend/class-article-typography.php — Reads Display settings on frontend. + * @see CONVENTIONS.md §Retired Settings Tabs — Retirement pattern. + * @see CONVENTIONS.md §How To: Add a New Admin Setting. */ class PRAutoBlogger_Settings_Fields_Extended { /** * Static image model list for the admin model picker. - * Delegates to Image_Model_Registry (extracted for 300-line file limit). * * @return array> */ @@ -30,58 +36,16 @@ class PRAutoBlogger_Settings_Fields_Extended { } /** - * Get schedule, publishing, analytics, and image settings fields. + * Get schedule, publishing, analytics, display, and image settings fields. + * + * Board settings, schedule, publishing, analytics, display, and images only. + * AI Models and Sources fields are retired from Settings — now in Pipeline Settings. * * @return array> */ public static function get_fields(): array { return array_merge( array( - // ── Sources (extended) ────────────────────────────────────── - array( - 'id' => 'prautoblogger_research_model', - 'label' => __( 'Research Model', 'prautoblogger' ), - 'type' => 'model_select', - 'section' => 'prautoblogger_sources', - 'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL, - 'capability' => 'text→text', - 'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ), - 'badge' => __( 'Reasoning', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_research_prompt', - 'label' => __( 'Research Prompt', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_sources', - 'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.', - 'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ), - ), - - // ── AI Models (extended) ──────────────────────────────────── - array( - 'id' => 'prautoblogger_reasoning_enabled', - 'label' => __( 'Enable Reasoning', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_models', - 'default' => '0', - 'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_reasoning_effort', - 'label' => __( 'Reasoning Effort', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_models', - 'default' => 'medium', - 'options' => array( - 'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ), - 'high' => __( 'High — thorough reasoning', 'prautoblogger' ), - 'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ), - '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.', 'prautoblogger' ), - ), - // ── Board Settings ───────────────────────────────────────── array( 'id' => 'prautoblogger_board_poll_interval', diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php index 9c71398..4540433 100755 --- a/includes/admin/class-settings-fields.php +++ b/includes/admin/class-settings-fields.php @@ -8,21 +8,31 @@ declare(strict_types=1); * * Centralizes all field and section definitions in one place, making it trivial * to add new settings (just add one array entry). Decoupled from page rendering logic. - * Core fields (API, Models, Content, Sources) live here; operational fields - * (Schedule, Publishing, Analytics, Images) are in Settings_Fields_Extended. + * Core fields (API Keys) live here; operational fields (Schedule, Publishing, Analytics, + * Display, Images) are in Settings_Fields_Extended. + * + * M2 change: AI Models, Content, and Sources sections were retired. Those fields + * are now edited exclusively in Pipeline Settings per-step panels. The underlying + * wp_options are unchanged — only the UI surface has moved. See CONVENTIONS.md + * §Retired Settings Tabs for the retirement pattern. * * Triggered by: PRAutoBlogger_Admin_Page::on_register_settings() calls static methods here. * Dependencies: PRAutoBlogger_Settings_Fields_Extended for operational field definitions. * - * @see admin/class-settings-fields-extended.php — Schedule, publishing, analytics, images fields. - * @see admin/class-admin-page.php — Calls get_sections() and get_fields() to register settings. - * @see CONVENTIONS.md — "How To: Add a New Admin Setting". + * @see admin/class-settings-fields-extended.php — Schedule, publishing, analytics, images. + * @see admin/class-admin-page.php — Calls get_sections() + get_fields(). + * @see admin/class-pipeline-settings-option-fields.php — Now owns AI Models/Content/Sources fields. + * @see CONVENTIONS.md §Retired Settings Tabs — Retirement pattern. + * @see CONVENTIONS.md §How To: Add a New Admin Setting — Extension guide. */ class PRAutoBlogger_Settings_Fields { /** * Get all settings sections. Each section maps to a tab in the admin UI. * + * AI Models, Content, and Sources sections were retired in M2 — those fields + * moved to Pipeline Settings per-step panels. + * * @return array */ public static function get_sections(): array { @@ -32,21 +42,6 @@ class PRAutoBlogger_Settings_Fields { 'icon' => 'dashicons-admin-network', 'description' => __( 'Connect your external services. Keys are encrypted at rest.', 'prautoblogger' ), ), - 'prautoblogger_models' => array( - 'title' => __( 'AI Models', 'prautoblogger' ), - 'icon' => 'dashicons-superhero-alt', - 'description' => __( 'Choose which models power each stage of the pipeline.', 'prautoblogger' ), - ), - 'prautoblogger_content' => array( - 'title' => __( 'Content', 'prautoblogger' ), - 'icon' => 'dashicons-edit-large', - 'description' => __( 'Control tone, length, pipeline mode, and topic guardrails.', 'prautoblogger' ), - ), - 'prautoblogger_sources' => array( - 'title' => __( 'Sources', 'prautoblogger' ), - 'icon' => 'dashicons-rss', - 'description' => __( 'Configure where PRAutoBlogger finds trending topics.', 'prautoblogger' ), - ), 'prautoblogger_schedule' => array( 'title' => __( 'Schedule & Budget', 'prautoblogger' ), 'icon' => 'dashicons-calendar-alt', @@ -85,7 +80,10 @@ class PRAutoBlogger_Settings_Fields { } /** - * Core fields: API Keys, AI Models, Content, and Sources. + * Core fields: API Keys section only. + * + * AI Models, Content, and Sources fields have moved to Pipeline Settings + * per-step panels (M2). Do not re-add them here. * * @return array> */ @@ -127,173 +125,6 @@ class PRAutoBlogger_Settings_Fields { 'max' => 2592000, 'description' => __( 'How long Cloudflare may serve cached responses for identical LLM calls. 0 disables caching. Only used when a gateway URL is set above. Safe values: 0 for article generation (always fresh), 3600+ for repeated classification/scoring calls.', 'prautoblogger' ), ), - - // ── AI Models ─────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_analysis_model', - 'label' => __( 'Analysis Model', 'prautoblogger' ), - 'type' => 'model_select', - 'section' => 'prautoblogger_models', - 'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL, - 'capability' => 'text→text', - 'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ), - 'badge' => __( 'Low cost', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_writing_model', - 'label' => __( 'Writing Model', 'prautoblogger' ), - 'type' => 'model_select', - 'section' => 'prautoblogger_models', - 'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL, - 'capability' => 'text→text', - 'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ), - 'badge' => __( 'Quality', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_editor_model', - 'label' => __( 'Editor Model', 'prautoblogger' ), - 'type' => 'model_select', - 'section' => 'prautoblogger_models', - 'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL, - 'capability' => 'text→text', - 'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ), - 'badge' => __( 'Quality', 'prautoblogger' ), - ), - - // ── Content ───────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_niche_description', - 'label' => __( 'Niche Description', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_content', - 'description' => __( 'Describe your site\'s niche. Guides content analysis and generation.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_analysis_instructions', - 'label' => __( 'Analysis Instructions', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_content', - 'default' => '', - 'description' => __( 'Custom instructions for the topic analysis LLM. Steers how source data is evaluated and which ideas get surfaced — e.g. "Prioritize questions over news. Ignore price-related discussions."', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_tone', - 'label' => __( 'Content Tone', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_content', - 'default' => 'informational', - 'options' => array( - 'informational' => __( 'Informational', 'prautoblogger' ), - 'conversational' => __( 'Conversational', 'prautoblogger' ), - 'professional' => __( 'Professional', 'prautoblogger' ), - 'casual' => __( 'Casual', 'prautoblogger' ), - 'authoritative' => __( 'Authoritative', 'prautoblogger' ), - ), - ), - array( - 'id' => 'prautoblogger_writing_pipeline', - 'label' => __( 'Writing Pipeline', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_content', - 'default' => 'multi_step', - 'options' => array( - 'multi_step' => __( 'Multi-step (outline → draft → polish)', 'prautoblogger' ), - 'single_pass' => __( 'Single-pass (one LLM call)', 'prautoblogger' ), - ), - ), - array( - 'id' => 'prautoblogger_min_word_count', - 'label' => __( 'Min Word Count', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_content', - 'default' => 800, - 'min' => 200, - ), - array( - 'id' => 'prautoblogger_max_word_count', - 'label' => __( 'Max Word Count', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_content', - 'default' => 2000, - 'min' => 500, - ), - array( - 'id' => 'prautoblogger_writing_instructions', - 'label' => __( 'Writing Instructions', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_content', - 'default' => '', - 'description' => __( 'Custom instructions appended to the LLM\'s system prompt when writing articles. Use this to steer style, structure, voice, and formatting — e.g. "Always open with a hook question. Use short paragraphs. Cite at least two studies."', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_editor_instructions', - 'label' => __( 'Editor Instructions', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_content', - 'default' => '', - 'description' => __( 'Custom instructions for the chief editor LLM review pass. Steers what the editor looks for and how it revises — e.g. "Be strict about medical disclaimers. Reject any article under 800 words."', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_topic_exclusions', - 'label' => __( 'Topic Exclusions', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_content', - 'description' => __( 'Comma-separated topics to never write about.', 'prautoblogger' ), - ), - - // ── Sources ───────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_target_subreddits', - 'label' => __( 'Target Subreddits', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_sources', - 'description' => __( 'Comma-separated, without r/. E.g.: peptides, Nootropics, biohackers', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_enabled_sources', - 'label' => __( 'Enabled Sources', 'prautoblogger' ), - 'type' => 'checkboxes', - 'section' => 'prautoblogger_sources', - 'options' => array( - 'reddit' => __( 'Reddit (RSS + .json)', 'prautoblogger' ), - 'llm_research' => __( 'LLM Deep Research (reasoning model)', 'prautoblogger' ), - ), - 'default' => '["reddit"]', - 'description' => __( 'Select which platforms to monitor for topics.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_pullpush_cache_ttl', - 'label' => __( 'Research Cache (hours)', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_sources', - 'default' => 6, - 'min' => 1, - 'max' => 72, - 'description' => __( 'How long to cache Reddit research results before re-fetching. Saves bandwidth on low-traffic sites.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_reddit_time_filter', - 'label' => __( 'Reddit Time Window', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_sources', - 'default' => 'day', - 'options' => array( - 'day' => __( 'Past 24 hours', 'prautoblogger' ), - 'week' => __( 'Past week', 'prautoblogger' ), - 'month' => __( 'Past month', 'prautoblogger' ), - ), - 'description' => __( 'How far back to search for trending posts and comments.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_reddit_posts_per_subreddit', - 'label' => __( 'Posts per Subreddit', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_sources', - 'default' => 25, - 'min' => 5, - 'max' => 100, - 'description' => __( 'Maximum posts to fetch per subreddit per collection run.', 'prautoblogger' ), - ), ); } } diff --git a/prautoblogger.php b/prautoblogger.php index 2cd6066..f852f69 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.23.0 + * Version: 0.24.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.23.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.24.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 ); define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 ); diff --git a/templates/admin/pipeline-settings-page.php b/templates/admin/pipeline-settings-page.php index 4df5db7..306f776 100644 --- a/templates/admin/pipeline-settings-page.php +++ b/templates/admin/pipeline-settings-page.php @@ -12,26 +12,30 @@ * array $view['step_data'] — assembled view data for the active step. * float $view['monthly_spend']— current month's LLM spend in USD. * float $view['budget'] — configured monthly budget cap in USD. + * array $view['global_fields']— field defs + current values for global context. + * string $view['step_context'] — context id matching the active step, or null. + * array $view['step_fields'] — field defs + current values for active step context. * - * Prompt panels are rendered via the pipeline-settings-prompt-panel.php partial. - * - * @see admin/class-pipeline-settings-renderer.php — Provides $view. - * @see admin/class-pipeline-settings-page.php — Includes this template. - * @see templates/admin/pipeline-settings-prompt-panel.php — Prompt editor partial. + * @see admin/class-pipeline-settings-renderer.php — Provides $view. + * @see templates/admin/pipeline-settings-prompt-panel.php — Prompt editor partial. + * @see templates/admin/pipeline-settings-step-options.php — Step option fields partial. */ if ( ! defined( 'ABSPATH' ) ) { exit; } -$steps = $view['steps']; -$active_step = $view['active_step']; -$save_result = $view['save_result']; -$nonce_field = $view['nonce_field']; -$nonce_action = $view['nonce_action']; -$page_slug = $view['page_slug']; -$step_data = $view['step_data']; +$steps = $view['steps']; +$active_step = $view['active_step']; +$save_result = $view['save_result']; +$nonce_field = $view['nonce_field']; +$nonce_action = $view['nonce_action']; +$page_slug = $view['page_slug']; +$step_data = $view['step_data']; $monthly_spend = $view['monthly_spend']; $budget = $view['budget']; +$global_fields = $view['global_fields']; +$step_context = $view['step_context']; +$step_fields = $view['step_fields']; $base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) ); ?> @@ -80,23 +84,13 @@ $base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) ); -
- - - - - - -
@@ -122,6 +116,17 @@ $base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) );
+ +

diff --git a/templates/admin/pipeline-settings-step-options.php b/templates/admin/pipeline-settings-step-options.php new file mode 100644 index 0000000..f8545b4 --- /dev/null +++ b/templates/admin/pipeline-settings-step-options.php @@ -0,0 +1,127 @@ + +
+

+
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + + /> + + + + + + +
+ $label ) : ?> +
+ +
+ + + +

+ +
+ +

+ +

+
+
diff --git a/tests/unit/Admin/PipelineSettingsOptionFieldsTest.php b/tests/unit/Admin/PipelineSettingsOptionFieldsTest.php new file mode 100644 index 0000000..8c50291 --- /dev/null +++ b/tests/unit/Admin/PipelineSettingsOptionFieldsTest.php @@ -0,0 +1,214 @@ +alias( + static function ( $val ) { + return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) ); + } + ); + Functions\when( 'sanitize_text_field' )->alias( + static function ( $val ) { return trim( (string) $val ); } + ); + Functions\when( 'sanitize_textarea_field' )->alias( + static function ( $val ) { return trim( strip_tags( (string) $val ) ); } + ); + Functions\when( 'absint' )->alias( + static function ( $val ) { return abs( (int) $val ); } + ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + } + + // ========================================================================= + // § CONTEXTS AND ALLOWLIST + // ========================================================================= + + public function test_every_context_yields_fields(): void { + foreach ( \PRAutoBlogger_Pipeline_Settings_Option_Fields::contexts() as $ctx ) { + $fields = \PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( $ctx ); + $this->assertNotEmpty( $fields, "Context '$ctx' returned no fields." ); + } + } + + public function test_unknown_context_returns_empty(): void { + $fields = \PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( 'nonexistent' ); + $this->assertSame( array(), $fields ); + } + + public function test_allowed_options_no_duplicates(): void { + $opts = \PRAutoBlogger_Pipeline_Settings_Option_Fields::allowed_options(); + $unique = array_unique( $opts ); + $this->assertCount( count( $unique ), $opts, 'Duplicate option names found.' ); + } + + public function test_allowlist_contains_all_expected_keys(): void { + $opts = \PRAutoBlogger_Pipeline_Settings_Option_Fields::allowed_options(); + foreach ( array( + 'prautoblogger_niche_description', + 'prautoblogger_enabled_sources', + 'prautoblogger_research_prompt', + 'prautoblogger_analysis_instructions', + 'prautoblogger_topic_exclusions', + 'prautoblogger_writing_pipeline', + 'prautoblogger_tone', + 'prautoblogger_min_word_count', + 'prautoblogger_max_word_count', + 'prautoblogger_reasoning_enabled', + 'prautoblogger_reasoning_effort', + 'prautoblogger_editor_instructions', + ) as $key ) { + $this->assertContains( $key, $opts, "Key '$key' missing from allowlist." ); + } + } + + // ========================================================================= + // § SANITIZE — textarea + // ========================================================================= + + public function test_sanitize_textarea_strips_tags(): void { + $field = array( 'type' => 'textarea' ); + $result = \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 'hello', $field ); + $this->assertSame( 'hello', $result ); + } + + // ========================================================================= + // § SANITIZE — toggle + // ========================================================================= + + public function test_sanitize_toggle_one_returns_one(): void { + $field = array( 'type' => 'toggle' ); + $this->assertSame( '1', \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( '1', $field ) ); + } + + public function test_sanitize_toggle_non_one_returns_zero(): void { + $field = array( 'type' => 'toggle' ); + $this->assertSame( '0', \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 'yes', $field ) ); + } + + // ========================================================================= + // § SANITIZE — number + // ========================================================================= + + public function test_sanitize_number_clamps_below_min(): void { + $field = array( 'type' => 'number', 'min' => 200 ); + $this->assertSame( 200, \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 50, $field ) ); + } + + public function test_sanitize_number_clamps_above_max(): void { + $field = array( 'type' => 'number', 'min' => 1, 'max' => 72 ); + $this->assertSame( 72, \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 100, $field ) ); + } + + public function test_sanitize_number_valid_value_unchanged(): void { + $field = array( 'type' => 'number', 'min' => 5, 'max' => 100 ); + $this->assertSame( 25, \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 25, $field ) ); + } + + // ========================================================================= + // § SANITIZE — select + // ========================================================================= + + public function test_sanitize_select_valid_value_accepted(): void { + $field = array( + 'type' => 'select', + 'default' => 'day', + 'options' => array( 'day' => 'Day', 'week' => 'Week', 'month' => 'Month' ), + ); + $this->assertSame( 'week', \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 'week', $field ) ); + } + + public function test_sanitize_select_invalid_falls_back_to_default(): void { + $field = array( + 'type' => 'select', + 'default' => 'day', + 'options' => array( 'day' => 'Day', 'week' => 'Week' ), + ); + $this->assertSame( 'day', \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 'evil', $field ) ); + } + + // ========================================================================= + // § SANITIZE — checkboxes + // ========================================================================= + + public function test_sanitize_checkboxes_filters_unknown_values(): void { + $field = array( + 'type' => 'checkboxes', + 'choices' => array( 'reddit' => 'Reddit', 'llm_research' => 'LLM' ), + ); + $result = \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( array( 'reddit', 'evil' ), $field ); + $decoded = json_decode( $result, true ); + $this->assertSame( array( 'reddit' ), $decoded ); + } + + public function test_sanitize_checkboxes_non_array_returns_json_empty(): void { + $field = array( + 'type' => 'checkboxes', + 'choices' => array( 'reddit' => 'Reddit' ), + ); + $result = \PRAutoBlogger_Pipeline_Settings_Option_Fields::sanitize_option( 'reddit', $field ); + $this->assertSame( '[]', $result ); + } + + // ========================================================================= + // § FIELD TYPE ASSERTIONS PER CONTEXT + // ========================================================================= + + public function test_writer_context_reasoning_enabled_is_toggle(): void { + $fields = \PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( 'writer' ); + foreach ( $fields as $f ) { + if ( 'prautoblogger_reasoning_enabled' === $f['id'] ) { + $this->assertSame( 'toggle', $f['type'] ); + return; + } + } + $this->fail( 'prautoblogger_reasoning_enabled not found in writer context.' ); + } + + public function test_research_context_enabled_sources_is_checkboxes(): void { + $fields = \PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( 'research' ); + foreach ( $fields as $f ) { + if ( 'prautoblogger_enabled_sources' === $f['id'] ) { + $this->assertSame( 'checkboxes', $f['type'] ); + return; + } + } + $this->fail( 'prautoblogger_enabled_sources not found in research context.' ); + } + + public function test_writer_context_tone_is_select_with_five_options(): void { + $fields = \PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context( 'writer' ); + foreach ( $fields as $f ) { + if ( 'prautoblogger_tone' === $f['id'] ) { + $this->assertSame( 'select', $f['type'] ); + $this->assertCount( 5, $f['options'] ); + return; + } + } + $this->fail( 'prautoblogger_tone not found in writer context.' ); + } +} diff --git a/tests/unit/Admin/PipelineSettingsStepSaveTest.php b/tests/unit/Admin/PipelineSettingsStepSaveTest.php new file mode 100644 index 0000000..fe91393 --- /dev/null +++ b/tests/unit/Admin/PipelineSettingsStepSaveTest.php @@ -0,0 +1,294 @@ + Captured update_option() calls. */ + private array $saved_options = array(); + + protected function setUp(): void { + parent::setUp(); + + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_POST = array(); + + Functions\when( 'wp_verify_nonce' )->justReturn( true ); + Functions\when( 'wp_unslash' )->returnArg(); + Functions\when( 'sanitize_key' )->alias( + static function ( $val ) { + return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) ); + } + ); + Functions\when( 'sanitize_text_field' )->alias( + static function ( $val ) { return trim( (string) $val ); } + ); + Functions\when( 'sanitize_textarea_field' )->alias( + static function ( $val ) { return trim( strip_tags( (string) $val ) ); } + ); + Functions\when( 'absint' )->alias( + static function ( $val ) { return abs( (int) $val ); } + ); + Functions\when( 'wp_json_encode' )->alias( 'json_encode' ); + Functions\when( 'current_user_can' )->justReturn( true ); + Functions\when( 'esc_html' )->alias( + static function ( $v ) { return htmlspecialchars( (string) $v, ENT_QUOTES ); } + ); + Functions\when( 'update_option' )->alias( + function ( $name, $value ) { + $this->saved_options[ $name ] = $value; + return true; + } + ); + } + + protected function tearDown(): void { + $_POST = array(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->saved_options = array(); + parent::tearDown(); + } + + // ========================================================================= + // § CONTEXT VALIDATION + // ========================================================================= + + public function test_unknown_context_returns_error_and_no_saves(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'bad_context', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertEmpty( $this->saved_options ); + } + + // ========================================================================= + // § GLOBAL CONTEXT + // ========================================================================= + + public function test_global_context_saves_niche_description(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'global', + 'prautoblogger_niche_description' => 'Peptides and biohacking', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $this->assertSame( 'Peptides and biohacking', $this->saved_options['prautoblogger_niche_description'] ); + } + + // ========================================================================= + // § ANALYSIS CONTEXT + // ========================================================================= + + public function test_analysis_context_saves_both_fields(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'analysis', + 'prautoblogger_analysis_instructions' => 'Focus on safety topics.', + 'prautoblogger_topic_exclusions' => 'politics, religion', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $this->assertArrayHasKey( 'prautoblogger_analysis_instructions', $this->saved_options ); + $this->assertArrayHasKey( 'prautoblogger_topic_exclusions', $this->saved_options ); + } + + // ========================================================================= + // § EDITORIAL CONTEXT + // ========================================================================= + + public function test_editorial_context_saves_editor_instructions(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'editorial', + 'prautoblogger_editor_instructions' => 'Be strict about disclaimers.', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $this->assertSame( 'Be strict about disclaimers.', $this->saved_options['prautoblogger_editor_instructions'] ); + } + + // ========================================================================= + // § WRITER CONTEXT + // ========================================================================= + + public function test_writer_context_saves_tone_and_pipeline(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'writer', + 'prautoblogger_tone' => 'authoritative', + 'prautoblogger_writing_pipeline' => 'single_pass', + 'prautoblogger_min_word_count' => '600', + 'prautoblogger_max_word_count' => '1800', + 'prautoblogger_reasoning_enabled' => '0', + 'prautoblogger_reasoning_effort' => 'medium', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $this->assertSame( 'authoritative', $this->saved_options['prautoblogger_tone'] ); + $this->assertSame( 'single_pass', $this->saved_options['prautoblogger_writing_pipeline'] ); + } + + public function test_writer_context_number_fields_are_integers(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'writer', + 'prautoblogger_tone' => 'informational', + 'prautoblogger_writing_pipeline' => 'multi_step', + 'prautoblogger_min_word_count' => '1000', + 'prautoblogger_max_word_count' => '2500', + 'prautoblogger_reasoning_enabled' => '0', + 'prautoblogger_reasoning_effort' => 'medium', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $this->assertIsInt( $this->saved_options['prautoblogger_min_word_count'] ); + $this->assertIsInt( $this->saved_options['prautoblogger_max_word_count'] ); + } + + // ========================================================================= + // § SECURITY GATES + // ========================================================================= + + public function test_invalid_nonce_prevents_save(): void { + Functions\when( 'wp_verify_nonce' )->justReturn( false ); + + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'bad', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'global', + 'prautoblogger_niche_description' => 'Should not save.', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertEmpty( $this->saved_options ); + } + + public function test_insufficient_capability_prevents_save(): void { + Functions\when( 'current_user_can' )->justReturn( false ); + + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'global', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'error', $result['status'] ); + $this->assertEmpty( $this->saved_options ); + } + + public function test_missing_nonce_field_returns_idle(): void { + $_POST = array( + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'global', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'idle', $result['status'] ); + $this->assertEmpty( $this->saved_options ); + } + + // ========================================================================= + // § RESEARCH CONTEXT + // ========================================================================= + + /** + * The research context is the only step context containing a `checkboxes`-type + * field (prautoblogger_enabled_sources). Its save path is distinct: the POST + * value is an array → each item sanitize_key'd → filtered against the choices + * allowlist → JSON-encoded string persisted via update_option(). + * + * This test exercises the full integration path for that field and asserts + * that an unknown source key is stripped and only allowlisted keys survive. + */ + public function test_research_context_saves_source_settings(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'research', + 'prautoblogger_enabled_sources' => array( 'reddit' ), + 'prautoblogger_target_subreddits' => 'peptides, Nootropics', + 'prautoblogger_reddit_time_filter' => 'week', + 'prautoblogger_reddit_posts_per_subreddit' => '20', + 'prautoblogger_pullpush_cache_ttl' => '12', + 'prautoblogger_research_prompt' => 'Find trending topics in {niche}.', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $decoded = json_decode( $this->saved_options['prautoblogger_enabled_sources'], true ); + $this->assertSame( array( 'reddit' ), $decoded ); + $this->assertSame( 'week', $this->saved_options['prautoblogger_reddit_time_filter'] ); + $this->assertIsInt( $this->saved_options['prautoblogger_reddit_posts_per_subreddit'] ); + } + + /** + * Unknown source keys supplied in the checkboxes array must be stripped — + * only values present in the field's `choices` allowlist survive. + */ + public function test_research_context_checkboxes_filters_unknown_sources(): void { + $_POST = array( + \PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD => 'valid', + 'pipeline_action' => 'save_step_settings', + 'step_context' => 'research', + 'prautoblogger_enabled_sources' => array( 'reddit', 'unknown_source', 'llm_research' ), + 'prautoblogger_target_subreddits' => '', + 'prautoblogger_reddit_time_filter' => 'day', + 'prautoblogger_reddit_posts_per_subreddit' => '25', + 'prautoblogger_pullpush_cache_ttl' => '6', + 'prautoblogger_research_prompt' => '', + ); + + $result = \PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $this->assertSame( 'saved', $result['status'] ); + $decoded = json_decode( $this->saved_options['prautoblogger_enabled_sources'], true ); + $this->assertContains( 'reddit', $decoded ); + $this->assertContains( 'llm_research', $decoded ); + $this->assertNotContains( 'unknown_source', $decoded ); + } +} diff --git a/tests/unit/Admin/SettingsFieldsExtendedTest.php b/tests/unit/Admin/SettingsFieldsExtendedTest.php index 4f71e3a..774b162 100644 --- a/tests/unit/Admin/SettingsFieldsExtendedTest.php +++ b/tests/unit/Admin/SettingsFieldsExtendedTest.php @@ -28,19 +28,21 @@ class SettingsFieldsExtendedTest extends BaseTestCase { } /** - * get_fields() must return exactly 29 fields (18 local + 11 image). + * get_fields() must return exactly 25 fields (14 local + 11 image). * - * This pins the array_merge fix: if a + operator re-appears the image - * fields are silently dropped to 18 and this test fails CI. - * Count breakdown: 18 schedule/publishing/analytics/display/sources fields + * M2: 4 fields removed (reasoning_enabled, reasoning_effort from AI Models; + * research_model, research_prompt from Sources) — now owned by Pipeline Settings. + * This pins the array_merge fix: if a + operator re-appears the image fields + * are silently dropped to 14 and this test fails CI. + * Count breakdown: 14 schedule/publishing/analytics/display fields * from the local array + 11 image fields from Settings_Fields_Images. */ - public function test_get_fields_returns_29_fields(): void { + public function test_get_fields_returns_25_fields(): void { $fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields(); $this->assertCount( - 29, + 25, $fields, - 'Expected 29 fields (18 local + 11 image). A + union instead of array_merge would return only 18.' + 'Expected 25 fields (14 local + 11 image). M2 retired AI Models + Sources from Extended.' ); }