feat: Pipeline Settings M2 — decompose Content/AI Models/Sources into per-step panels + Global Content Context (#172, v0.24.0)

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
This commit is contained in:
peptiderepo
2026-06-23 12:03:03 +07:00
committed by GitHub
parent 37cfb2f244
commit 8b789b3f1b
17 changed files with 1308 additions and 380 deletions

View File

@@ -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.
---

View File

@@ -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

View File

@@ -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`. |

View File

@@ -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()`

View File

@@ -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)*

View File

@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Raw field-array data for PRAutoBlogger_Pipeline_Settings_Option_Fields.
*
* What: Declarative arrays of field definitions per step context. Extracted
* from Option_Fields to satisfy the 300-line/file rule. Contains no
* logic — only data. Option keys are the same wp_option names that were
* previously registered in the retired AI Models, Content, and Sources
* Settings tabs.
* Who calls it: PRAutoBlogger_Pipeline_Settings_Option_Fields::get_fields_for_context().
* Dependencies: WP i18n helpers (__) only.
*
* @see admin/class-pipeline-settings-option-fields.php — Public API wrapper + sanitizer.
* @see CONVENTIONS.md §Retired Settings Tabs — retirement pattern.
*/
class PRAutoBlogger_Pipeline_Settings_Option_Fields_Data {
/**
* Route a context string to its field definitions array.
*
* @param string $context One of: global|research|analysis|writer|editorial.
* @return array<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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' ),
),
);
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Per-step-context option field definitions for the Pipeline Settings page.
*
* What: Central registry mapping step context identifiers to their editable
* wp_option fields. Provides allowlist enforcement, field-type metadata,
* and a single sanitize_option() entry point used by the save handler.
* Fields were previously under Settings tabs (AI Models, Content, Sources)
* retired in M2 — the wp_options themselves are unchanged, only the UI
* surface that edits them has moved.
* Who calls it: PRAutoBlogger_Pipeline_Settings_Save_Handler (save_step_settings),
* PRAutoBlogger_Pipeline_Settings_Renderer (view data assembly).
* Dependencies: PRAutoBlogger_Pipeline_Settings_Option_Fields_Data (field arrays).
*
* @see admin/class-pipeline-settings-option-fields-data.php — Raw field arrays per context.
* @see admin/class-pipeline-settings-save-handler.php — save_step_settings dispatch.
* @see admin/class-pipeline-settings-renderer.php — passes field values to template.
* @see CONVENTIONS.md §Retired Settings Tabs — retirement pattern.
*/
class PRAutoBlogger_Pipeline_Settings_Option_Fields {
/**
* All step context identifiers.
*
* @return string[]
*/
public static function contexts(): array {
return array( 'global', 'research', 'analysis', 'writer', 'editorial' );
}
/**
* All allowed option names across every context (for allowlist enforcement).
*
* @return string[]
*/
public static function allowed_options(): array {
$names = array();
foreach ( self::contexts() as $ctx ) {
foreach ( self::get_fields_for_context( $ctx ) as $field ) {
$names[] = $field['id'];
}
}
return array_unique( $names );
}
/**
* Field definitions for a given step context.
*
* Each field definition array contains at minimum:
* id string wp_option name.
* type string 'textarea'|'select'|'number'|'toggle'|'checkboxes'.
* Additional keys per type:
* options array For 'select': key => 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<int, array<string, mixed>>
*/
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<string, mixed> $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 );
}
}
}

View File

@@ -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<int, array<string, mixed>>
*/
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<string, mixed> $field_args Field args forwarded to the picker.
* @return void Side effect: outputs HTML.

View File

@@ -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
* <input type="hidden" name="$option_name" ...>. 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:
// <input type="hidden" id="$id" name="$id" value="..." />
// 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.
*/

View File

@@ -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<int, array<string, mixed>>
*/
@@ -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<int, array<string, mixed>>
*/
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',

View File

@@ -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<string, array{title: string, icon: string, description: string}>
*/
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<int, array<string, mixed>>
*/
@@ -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' ),
),
);
}
}

View File

@@ -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 );

View File

@@ -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 ) );
<?php endif; ?>
<?php
$niche = (string) get_option( 'prautoblogger_niche_description', '' );
$niche_preview = '' !== $niche ? mb_strimwidth( $niche, 0, 80, '…' ) : '';
// ── Global Content Context block (editable — M2) ─────────────────────────
$context = 'global';
$section_title = __( 'Global Content Context', 'prautoblogger' );
$step_id = 'global';
$fields = $global_fields;
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-step-options.php';
?>
<div class="pab-global-context-note">
<span class="dashicons dashicons-info-outline"></span>
<?php if ( '' !== $niche_preview ) : ?>
<?php
printf(
/* translators: %s = truncated niche description */
esc_html__( 'Niche context (used by all stages): "%s"', 'prautoblogger' ),
esc_html( $niche_preview )
);
?>
<?php else : ?>
<?php esc_html_e( 'Niche description not set — edit in Settings → Content.', 'prautoblogger' ); ?>
<?php endif; ?>
</div>
<div class="pab-pipeline-layout">
@@ -122,6 +116,17 @@ $base_url = admin_url( 'admin.php?page=' . rawurlencode( $page_slug ) );
<?php endif; ?>
</div>
<?php
// ── Step option fields (M2) ───────────────────────────────────────
if ( null !== $step_context && ! empty( $step_fields ) ) :
$context = $step_context;
$section_title = __( 'Step Settings', 'prautoblogger' );
$step_id = $active_step['id'];
$fields = $step_fields;
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-step-options.php';
endif;
?>
<?php if ( ! empty( $step_data['model_option'] ) ) : ?>
<div class="pab-section">
<h3 class="pab-section-title"><?php esc_html_e( 'Model', 'prautoblogger' ); ?></h3>

View File

@@ -0,0 +1,127 @@
<?php
/**
* Partial: Pipeline Settings — editable step-option fields.
*
* Renders a save form containing every option field for the current step
* context (research / analysis / writer / editorial) or the global context.
*
* Variables expected in scope (provided by pipeline-settings-page.php):
* string $nonce_action — nonce action string.
* string $nonce_field — nonce field name.
* string $step_id — active step id (for form action URL, e.g. 'writer').
* string $base_url — admin.php?page=prautoblogger-pipeline base URL.
* string $context — step context key passed as step_context POST field.
* array $fields — array of field defs with 'current' key (from renderer).
* string $section_title — h3 title for this options block.
*
* @see admin/class-pipeline-settings-option-fields.php — Field definitions + sanitizer.
* @see admin/class-pipeline-settings-renderer.php — Provides $fields via build_option_field_values().
* @see templates/admin/pipeline-settings-page.php — Includes this partial.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
?>
<div class="pab-section">
<h3 class="pab-section-title"><?php echo esc_html( $section_title ); ?></h3>
<form method="post"
action="<?php echo esc_url( add_query_arg( 'step', $step_id, $base_url ) ); ?>"
class="pab-step-options-form">
<?php wp_nonce_field( $nonce_action, $nonce_field ); ?>
<input type="hidden" name="pipeline_action" value="save_step_settings" />
<input type="hidden" name="step_context" value="<?php echo esc_attr( $context ); ?>" />
<table class="form-table pab-options-table">
<tbody>
<?php foreach ( $fields as $field ) : ?>
<?php
$fid = (string) $field['id'];
$ftype = (string) ( $field['type'] ?? 'textarea' );
$current = $field['current'] ?? ( $field['default'] ?? '' );
?>
<tr>
<th scope="row">
<label for="pab-opt-<?php echo esc_attr( $fid ); ?>">
<?php echo esc_html( $field['label'] ?? $fid ); ?>
</label>
</th>
<td>
<?php if ( 'textarea' === $ftype ) : ?>
<textarea id="pab-opt-<?php echo esc_attr( $fid ); ?>"
name="<?php echo esc_attr( $fid ); ?>"
rows="5"
class="large-text code"><?php echo esc_textarea( (string) $current ); ?></textarea>
<?php elseif ( 'select' === $ftype ) : ?>
<select id="pab-opt-<?php echo esc_attr( $fid ); ?>"
name="<?php echo esc_attr( $fid ); ?>">
<?php foreach ( (array) ( $field['options'] ?? array() ) as $val => $label ) : ?>
<option value="<?php echo esc_attr( (string) $val ); ?>"
<?php selected( (string) $current, (string) $val ); ?>>
<?php echo esc_html( (string) $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php elseif ( 'number' === $ftype ) : ?>
<input type="number"
id="pab-opt-<?php echo esc_attr( $fid ); ?>"
name="<?php echo esc_attr( $fid ); ?>"
value="<?php echo esc_attr( (string) $current ); ?>"
class="small-text"
<?php echo isset( $field['min'] ) ? 'min="' . esc_attr( (string) $field['min'] ) . '"' : ''; ?>
<?php echo isset( $field['max'] ) ? 'max="' . esc_attr( (string) $field['max'] ) . '"' : ''; ?>
/>
<?php elseif ( 'toggle' === $ftype ) : ?>
<label class="pab-toggle-wrap">
<input type="checkbox"
id="pab-opt-<?php echo esc_attr( $fid ); ?>"
name="<?php echo esc_attr( $fid ); ?>"
value="1"
<?php checked( '1', (string) $current ); ?> />
<span class="pab-toggle-label">
<?php esc_html_e( 'Enabled', 'prautoblogger' ); ?>
</span>
</label>
<?php elseif ( 'checkboxes' === $ftype ) : ?>
<?php
// current is stored as JSON array string.
$checked_vals = array();
if ( is_string( $current ) && '' !== $current ) {
$decoded = json_decode( $current, true );
if ( is_array( $decoded ) ) {
$checked_vals = $decoded;
}
}
?>
<fieldset>
<?php foreach ( (array) ( $field['choices'] ?? array() ) as $val => $label ) : ?>
<label>
<input type="checkbox"
name="<?php echo esc_attr( $fid ); ?>[]"
value="<?php echo esc_attr( (string) $val ); ?>"
<?php checked( in_array( (string) $val, array_map( 'strval', $checked_vals ), true ) ); ?> />
<?php echo esc_html( (string) $label ); ?>
</label><br />
<?php endforeach; ?>
</fieldset>
<?php endif; ?>
<?php if ( ! empty( $field['description'] ) ) : ?>
<p class="description"><?php echo esc_html( $field['description'] ); ?></p>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="submit">
<button type="submit" class="ab-btn ab-btn-secondary ab-btn-sm">
<?php esc_html_e( 'Save Settings', 'prautoblogger' ); ?>
</button>
</p>
</form>
</div>

View File

@@ -0,0 +1,214 @@
<?php
/**
* Unit tests for PRAutoBlogger_Pipeline_Settings_Option_Fields (and _Data).
*
* Locks the contracts for:
* (1) Allowlist completeness — every context yields at least one field; no duplicates.
* (2) sanitize_option() per type — textarea, select, number, toggle, checkboxes.
* (3) select out-of-range falls back to default.
* (4) number bounds clamping.
* (5) checkboxes unknown values are filtered out.
* (6) Field type assertions per context.
*
* Save-handler integration tests are in PipelineSettingsStepSaveTest.php.
*
* @see admin/class-pipeline-settings-option-fields.php
* @see admin/class-pipeline-settings-option-fields-data.php
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class PipelineSettingsOptionFieldsTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
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' );
}
// =========================================================================
// § 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( '<b>hello</b>', $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.' );
}
}

View File

@@ -0,0 +1,294 @@
<?php
/**
* Integration tests for save_step_settings action in the Pipeline Settings save handler.
*
* Covers:
* (1) Unknown step_context is rejected before any update_option().
* (2) Valid contexts (global, analysis, editorial, writer) persist the right options.
* (3) Failed nonce prevents saves.
* (4) Insufficient capability prevents saves.
* (5) Missing nonce field returns idle (not error).
*
* @see admin/class-pipeline-settings-save-handler.php
* @see admin/class-pipeline-settings-option-fields.php
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class PipelineSettingsStepSaveTest extends BaseTestCase {
/** @var array<string, mixed> 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 );
}
}

View File

@@ -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.'
);
}