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:
@@ -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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
40
CHANGELOG.md
40
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
|
||||
|
||||
14
CONTEXT.md
14
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`. |
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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)*
|
||||
|
||||
243
includes/admin/class-pipeline-settings-option-fields-data.php
Normal file
243
includes/admin/class-pipeline-settings-option-fields-data.php
Normal 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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
125
includes/admin/class-pipeline-settings-option-fields.php
Normal file
125
includes/admin/class-pipeline-settings-option-fields.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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>
|
||||
|
||||
127
templates/admin/pipeline-settings-step-options.php
Normal file
127
templates/admin/pipeline-settings-step-options.php
Normal 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>
|
||||
214
tests/unit/Admin/PipelineSettingsOptionFieldsTest.php
Normal file
214
tests/unit/Admin/PipelineSettingsOptionFieldsTest.php
Normal 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.' );
|
||||
}
|
||||
}
|
||||
294
tests/unit/Admin/PipelineSettingsStepSaveTest.php
Normal file
294
tests/unit/Admin/PipelineSettingsStepSaveTest.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user