From 110c681c57bf97cd31fda50c3cb848e86391de86 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:01:45 +0700 Subject: [PATCH] Adopt reusable CI standard + 300-line splits (#167) ci: adopt estate reusable CI + split 300-line violations (v0.22.0) Squash merge of PR #167 (claude/adopt-ci-20260616) Head: dc5145cd92992cd1969ca592df374f7c12b7c36a Agent-Session: deploy-operator-2026-06-16 --- .../prautoblogger-deploy-receiver.php | 87 ++-- .github/workflows/ci.yml | 133 ++---- .github/workflows/deploy.yml | 26 +- CHANGELOG.md | 39 ++ composer.json | 45 +- includes/admin/class-admin-page.php | 80 +--- .../admin/class-settings-fields-extended.php | 452 +++++++----------- .../admin/class-settings-fields-images.php | 123 +++++ includes/admin/class-settings-sanitizer.php | 107 +++++ includes/core/class-idea-scorer.php | 134 +----- includes/core/class-keyword-extractor.php | 156 ++++++ includes/core/class-semantic-dedup.php | 129 +---- .../providers/class-image-aspect-ratio.php | 88 ++++ .../class-open-router-image-batch.php | 51 +- .../class-open-router-image-provider.php | 68 +-- .../providers/class-runware-image-batch.php | 14 +- .../class-runware-image-provider.php | 67 +-- .../class-runware-image-validator.php | 89 ++++ prautoblogger.php | 4 +- scripts/check-constants.php | 24 +- templates/admin/ideas-browser.php | 27 +- templates/admin/log-viewer.php | 39 +- templates/admin/metabox-generation-info.php | 18 +- templates/admin/review-queue.php | 8 +- .../unit/Admin/SettingsFieldsExtendedTest.php | 77 +++ 25 files changed, 1059 insertions(+), 1026 deletions(-) create mode 100644 includes/admin/class-settings-fields-images.php create mode 100644 includes/admin/class-settings-sanitizer.php create mode 100644 includes/core/class-keyword-extractor.php create mode 100644 includes/providers/class-image-aspect-ratio.php create mode 100644 includes/providers/class-runware-image-validator.php create mode 100644 tests/unit/Admin/SettingsFieldsExtendedTest.php diff --git a/.github/mu-plugins/prautoblogger-deploy-receiver.php b/.github/mu-plugins/prautoblogger-deploy-receiver.php index 007ea77..0ba4f83 100644 --- a/.github/mu-plugins/prautoblogger-deploy-receiver.php +++ b/.github/mu-plugins/prautoblogger-deploy-receiver.php @@ -35,25 +35,33 @@ add_action( 'rest_api_init', 'prautoblogger_deploy_register_route' ); * Register the deploy REST route. */ function prautoblogger_deploy_register_route(): void { - register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [ - 'methods' => 'POST', - 'callback' => 'prautoblogger_deploy_handle', - 'permission_callback' => 'prautoblogger_deploy_check_auth', - ] ); + register_rest_route( + 'prautoblogger-deploy/v1', + '/deploy', + array( + 'methods' => 'POST', + 'callback' => 'prautoblogger_deploy_handle', + 'permission_callback' => 'prautoblogger_deploy_check_auth', + ) + ); // Health check endpoint (GET) — useful for monitoring. - register_rest_route( 'prautoblogger-deploy/v1', '/status', [ - 'methods' => 'GET', - 'callback' => function () { - return [ - 'status' => 'ready', - 'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed', - 'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ), - 'version' => prautoblogger_deploy_get_installed_version(), - ]; - }, - 'permission_callback' => 'prautoblogger_deploy_check_auth', - ] ); + register_rest_route( + 'prautoblogger-deploy/v1', + '/status', + array( + 'methods' => 'GET', + 'callback' => function () { + return array( + 'status' => 'ready', + 'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed', + 'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ), + 'version' => prautoblogger_deploy_get_installed_version(), + ); + }, + 'permission_callback' => 'prautoblogger_deploy_check_auth', + ) + ); } /** @@ -78,7 +86,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon return new WP_Error( 'rate_limited', 'Deploy in progress or recently completed. Wait 60 seconds.', - [ 'status' => 429 ] + array( 'status' => 429 ) ); } set_transient( 'prautoblogger_deploy_lock', time(), 60 ); @@ -87,7 +95,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon $files = $request->get_file_params(); if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) { delete_transient( 'prautoblogger_deploy_lock' ); - return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] ); + return new WP_Error( 'no_file', 'No plugin zip uploaded.', array( 'status' => 400 ) ); } $tmp_file = $files['plugin']['tmp_name']; @@ -96,11 +104,11 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon $zip = new ZipArchive(); if ( true !== $zip->open( $tmp_file ) ) { delete_transient( 'prautoblogger_deploy_lock' ); - return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] ); + return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', array( 'status' => 400 ) ); } $has_main_file = false; - for ( $i = 0; $i < $zip->numFiles; $i++ ) { + for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) { $has_main_file = true; break; @@ -113,7 +121,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon return new WP_Error( 'invalid_plugin', 'Zip must contain prautoblogger/prautoblogger.php at root.', - [ 'status' => 400 ] + array( 'status' => 400 ) ); } @@ -143,7 +151,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon activate_plugin( 'prautoblogger/prautoblogger.php' ); } delete_transient( 'prautoblogger_deploy_lock' ); - return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] ); + return new WP_Error( 'backup_failed', 'Could not backup current plugin.', array( 'status' => 500 ) ); } } @@ -161,7 +169,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon } delete_transient( 'prautoblogger_deploy_lock' ); error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' ); - return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] ); + return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', array( 'status' => 500 ) ); } // 7. Activate plugin. @@ -178,7 +186,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon return new WP_Error( 'activation_failed', 'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.', - [ 'status' => 500 ] + array( 'status' => 500 ) ); } @@ -188,18 +196,23 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon } $old_version = prautoblogger_deploy_get_installed_version(); - error_log( sprintf( - '[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s', - $incoming_version, - $old_version ?: 'fresh install', - gmdate( 'Y-m-d H:i:s' ) - ) ); + error_log( + sprintf( + '[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s', + $incoming_version, + $old_version ? $old_version : 'fresh install', + gmdate( 'Y-m-d H:i:s' ) + ) + ); - return new WP_REST_Response( [ - 'success' => true, - 'version' => $incoming_version, - 'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.", - ], 200 ); + return new WP_REST_Response( + array( + 'success' => true, + 'version' => $incoming_version, + 'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.", + ), + 200 + ); } /** @@ -210,7 +223,7 @@ function prautoblogger_deploy_get_installed_version(): string { if ( ! file_exists( $main_file ) ) { return ''; } - $data = get_file_data( $main_file, [ 'Version' => 'Version' ] ); + $data = get_file_data( $main_file, array( 'Version' => 'Version' ) ); return $data['Version'] ?? ''; } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f853c39..86944d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,114 +1,34 @@ -name: CI — Lint & Test - -# Runs on every PR and every push to main. Validates PHPUnit tests + lint + -# coding standards before deploy can happen. deploy.yml on main continues to -# run its own validate job as a belt-and-suspenders check, but this CI workflow -# is what gives PRs an immediate signal so fixes happen before merge. +## +# CI caller — PRAutoBlogger +# +# Thin caller that delegates to the reusable estate CI workflow in peptide-e2e. +# workflow_call is required so deploy.yml can gate on this workflow via +# `uses: ./.github/workflows/ci.yml` (the workflow_call trigger enables that). +# +# Permissions: contents: write is required at the caller level so the reusable +# workflow's phpcbf auto-commit job can push fixes back to the PR branch. +# (GitHub enforces that callers cannot be escalated beyond their declared scope.) +# +# tests: brain-monkey — no WP install or DB required; Brain Monkey mocking only. +# line_limit_paths: includes — estate hard 300-line rule applied to includes/. +## +name: CI on: + pull_request: {} push: branches: [main] - pull_request: - branches: [main] + workflow_call: {} permissions: - contents: read + contents: write jobs: - php-lint: - name: PHP Lint (${{ matrix.php }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ['8.1', '8.2', '8.3'] - steps: - - uses: actions/checkout@v4 - - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: none - - name: Syntax check all PHP files - # Exclude .github/mu-plugins — those run on the production server (PHP 8+) - # and may use PHP 8+ syntax (union types etc.) that older versions reject. - run: | - set -e - find . -name '*.php' \ - -not -path './vendor/*' \ - -not -path './node_modules/*' \ - -not -path './.git/*' \ - -not -path './.github/*' \ - -print0 | xargs -0 -n1 -P4 php -l > /dev/null - - phpcs: - name: PHPCS (WordPress standards) - runs-on: ubuntu-latest - continue-on-error: false # strict after PHPCS cleanup in v0.8.3 - permissions: - contents: write # allows phpcbf auto-fix commits back to the PR branch - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref || github.ref_name }} - token: ${{ secrets.GITHUB_TOKEN }} - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - coverage: none - tools: composer:v2, phpcs - - name: Install WordPress Coding Standards - run: | - composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true - composer global require --no-interaction \ - "dealerdirect/phpcodesniffer-composer-installer:^1.0" \ - "wp-coding-standards/wpcs:^3.0" \ - "phpcsstandards/phpcsutils:^1.0" \ - "phpcsstandards/phpcsextra:^1.0" - - name: Auto-fix with phpcbf (commit back to branch if changed) - # phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here. - # Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops). - run: | - phpcbf --standard=phpcs.xml \ - --extensions=php \ - --ignore=vendor/*,tests/*,node_modules/* \ - includes/ prautoblogger.php uninstall.php || true - if ! git diff --quiet; then - git config user.name "peptiderepo" - git config user.email "peptiderepo@users.noreply.github.com" - git add -A - git commit -m "style: auto-fix PHPCS violations via phpcbf" - git push - fi - - name: Run PHPCS (only non-auto-fixable violations can fail here) - run: | - phpcs --standard=phpcs.xml \ - --extensions=php \ - --ignore=vendor/*,tests/*,node_modules/* \ - --warning-severity=0 \ - --report=full \ - includes/ prautoblogger.php uninstall.php - - phpunit: - name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ['8.1', '8.2', '8.3'] - steps: - - uses: actions/checkout@v4 - - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - coverage: none - tools: composer:v2 - - name: Install composer dependencies - run: composer install --no-progress --no-interaction --prefer-dist - - name: Run PHPUnit (Brain Monkey — no WP install required) - run: vendor/bin/phpunit --testdox + ci: + uses: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main + with: + tests: brain-monkey + secrets: inherit check-constants: name: Check PRAUTOBLOGGER_* constants @@ -126,7 +46,8 @@ jobs: # by defined() at the reference site. Prevents the v0.19.0 incident # class (constant deleted from bootstrap, test fallback masks the gap). run: php scripts/check-constants.php - - name: Self-test check-constants guard (v0.21.0) - # Verifies the guard script itself catches a synthetic missing constant. - # Exit 2 = self-test failure (scanner is broken). Exit 0 = scanner works. + - name: Self-test check-constants scanner + # Verifies the scanner itself can detect the failure cases it was + # designed to catch. If the scanner regresses, this step fails CI. + # Added v0.21.0; restored in v0.22.0 (was accidentally dropped in rewrite). run: php scripts/check-constants.php --self-test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5b5f472..dba31ea 100755 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,28 +9,16 @@ concurrency: cancel-in-progress: true jobs: - validate: - name: PHP Lint Gate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.1' - coverage: none - - name: PHP Lint Check - run: | - set -e - find . -name '*.php' \ - -not -path './vendor/*' \ - -not -path './node_modules/*' \ - -not -path './.git/*' \ - -not -path './.github/*' \ - -print0 | xargs -0 -n1 -P4 php -l > /dev/null + ci: + name: CI Gate (reusable) + uses: ./.github/workflows/ci.yml + permissions: + contents: write + secrets: inherit deploy: name: Deploy (shared pipeline) - needs: validate + needs: ci uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main with: target_path: wp-content/plugins/prautoblogger diff --git a/CHANGELOG.md b/CHANGELOG.md index 720f3fe..e774d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,45 @@ and this project uses [Semantic Versioning](https://semver.org/). ### Internal - CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class. +## [0.22.0] - 2026-06-16 + +### Changed +- **CI adopted estate standard (reusable workflow)**: `ci.yml` is now a thin + caller of `peptiderepo/peptide-e2e/.github/workflows/ci.yml@main` with + `tests: brain-monkey`. Removes duplicate per-repo lint, PHPCS, and PHPUnit + jobs (now provided by the reusable workflow). The phpcbf auto-fix commit-back + pattern (prautoblogger gold standard) is now the reusable implementation. +- **Deploy gate upgraded**: `deploy.yml` no longer runs a standalone PHP-lint + validate job; it calls `./.github/workflows/ci.yml` (which includes the full + reusable suite) via `workflow_call`. This closes the gap where deploy's lint + gate was weaker than CI's PHPCS gate. +- **Constants guard retained**: `check-constants` job kept as a second parallel + job in `ci.yml` alongside the reusable `ci` job. No regression from the + v0.19.0 protection. +- **`composer.json` test script added**: `composer test` now runs + `vendor/bin/phpunit --testdox` (required by the reusable workflow's phpunit job). + +### Refactored (300-line rule compliance) +- `PRAutoBlogger_Keyword_Extractor` (new, `core/class-keyword-extractor.php`): + shared keyword tokeniser extracted from `Idea_Scorer` and `Semantic_Dedup`; + both now delegate to `PRAutoBlogger_Keyword_Extractor::extract()`. +- `PRAutoBlogger_Image_Aspect_Ratio` (new, `providers/class-image-aspect-ratio.php`): + aspect-ratio snap utility extracted from `OpenRouter_Image_Provider` and + `OpenRouter_Image_Batch`; both now call `PRAutoBlogger_Image_Aspect_Ratio::snap()`. +- `PRAutoBlogger_Settings_Sanitizer` (new, `admin/class-settings-sanitizer.php`): + all option sanitization logic extracted from `Admin_Page::sanitize_field()`; + `Admin_Page` delegates via `PRAutoBlogger_Settings_Sanitizer::sanitize_field()`. +- `PRAutoBlogger_Settings_Fields_Images` (new, `admin/class-settings-fields-images.php`): + image-section field definitions extracted from `Settings_Fields_Extended`; + `Settings_Fields_Extended::get_fields()` merges via array union. +- `PRAutoBlogger_Runware_Validator` (new, `providers/class-runware-image-validator.php`): + `validate_credentials_detailed()` extracted from `Runware_Image_Provider`; + provider delegates via `PRAutoBlogger_Runware_Validator::validate()`. +- `class-runware-image-batch.php`: 15 individual `phpcs:disable` lines collapsed + to a single grouped suppression (equivalent effect, saves 14 lines). +- `class-open-router-image-provider.php`: `snap_aspect_ratio` private method and + `STANDARD_ASPECTS` constant removed (now handled by `Image_Aspect_Ratio`). + ## [0.21.0] - 2026-06-14 ### Added diff --git a/composer.json b/composer.json index d874712..87c9966 100755 --- a/composer.json +++ b/composer.json @@ -1,23 +1,26 @@ { -"name": "peptiderepo/prautoblogger", -"description": "WordPress plugin that auto-researches and publishes blog content using AI.", -"type": "wordpress-plugin", -"license": "GPL-2.0-or-later", -"require": { -"php": ">=7.4" -}, -"require-dev": { -"phpunit/phpunit": "^9.5", -"brain/monkey": "^2.6" -}, -"autoload": { -"classmap": [ -"includes/" -] -}, -"autoload-dev": { -"psr-4": { -"PRAutoBlogger\\Tests\\": "tests/unit/" + "name": "peptiderepo/prautoblogger", + "description": "WordPress plugin that auto-researches and publishes blog content using AI.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "brain/monkey": "^2.6" + }, + "autoload": { + "classmap": [ + "includes/" + ] + }, + "autoload-dev": { + "psr-4": { + "PRAutoBlogger\\Tests\\": "tests/unit/" + } + }, + "scripts": { + "test": "vendor/bin/phpunit --testdox" + } } -} -} \ No newline at end of file diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index a8bbfad..4a7e25f 100644 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -270,84 +270,14 @@ class PRAutoBlogger_Admin_Page { /** * Sanitize a settings field value. * + * Delegates to PRAutoBlogger_Settings_Sanitizer (extracted for 300-line compliance). + * * @param mixed $value The submitted value. * @return mixed Sanitized value. + * + * @see admin/class-settings-sanitizer.php */ public function sanitize_field( $value ) { - $option_name = ''; - $filter = current_filter(); - if ( 0 === strpos( $filter, 'sanitize_option_' ) ) { - $option_name = substr( $filter, strlen( 'sanitize_option_' ) ); - } - - $encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ); - if ( in_array( $option_name, $encrypted, true ) ) { - // Empty value means password field wasn't touched — keep existing. - if ( '' === $value ) { - return get_option( $option_name, '' ); - } - - // Already encrypted (has "enc:" prefix) — return as-is. - // This is the primary defence against double-encryption: - // PRAutoBlogger_Encryption::encrypt() also checks for this prefix, - // so even if this callback is called multiple times, the value - // is encrypted exactly once and never re-encrypted. - if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) { - return $value; - } - - // New plaintext value — encrypt it (adds "enc:" prefix). - return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) ); - } - - $json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' ); - if ( in_array( $option_name, $json_fields, true ) ) { - // PHP array from checkboxes — sanitize each item and re-encode. - if ( is_array( $value ) ) { - return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) ); - } - - $trimmed = trim( (string) $value ); - - // Already JSON-encoded array — decode, sanitize each item, re-encode. - // This prevents sanitize_text_field() from mangling the JSON string, - // which caused json_decode() on read to return null → empty subreddits. - if ( '[' === substr( $trimmed, 0, 1 ) ) { - $decoded = json_decode( $trimmed, true ); - if ( is_array( $decoded ) ) { - return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) ); - } - } - - // Comma-separated plain text — split, sanitize, encode as JSON array. - $items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) ); - return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) ); - } - - $numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days', 'prautoblogger_board_column_limit', 'prautoblogger_ideas_per_page' ); - if ( in_array( $option_name, $numeric, true ) ) { - return is_numeric( $value ) ? $value : 0; - } - if ( 'prautoblogger_image_model' === $option_name ) { - return $this->sanitize_image_model( (string) $value ); - } - if ( 'prautoblogger_image_style_template' === $option_name ) { - // Multi-line template: validation + textarea-safe sanitisation - // (incl. the single-token check, brief A5) lives in the filler. - return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value ); - } - return sanitize_text_field( (string) $value ); - } - - /** Validate model id against the registry and persist the derived provider. */ - private function sanitize_image_model( string $value ): string { - $candidate = sanitize_text_field( $value ); - $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); - if ( '' !== $provider ) { - update_option( 'prautoblogger_image_provider', $provider ); - return $candidate; - } - add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) ); - return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL ); + return PRAutoBlogger_Settings_Sanitizer::sanitize_field( $value ); } } diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index 20a0768..314f5a0 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -35,282 +35,194 @@ class PRAutoBlogger_Settings_Fields_Extended { * @return array> */ public static function get_fields(): array { - return array( - // ── Sources (extended) ────────────────────────────────────── + return array_merge( 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' ), + // ── 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' ), ), - 'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ), - ), - // ── Board Settings ───────────────────────────────────────── - array( - 'id' => 'prautoblogger_board_poll_interval', - 'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_schedule', - 'default' => 5, - 'min' => 3, - 'max' => 60, - 'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_board_published_window_days', - 'label' => __( 'Published Column Window (days)', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_schedule', - 'default' => 7, - 'min' => 1, - 'max' => 90, - 'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ), - ), + // ── 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' ), + ), - // ── Schedule & Budget ─────────────────────────────────────── - array( - 'id' => 'prautoblogger_daily_article_target', - 'label' => __( 'Articles per Day', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_schedule', - 'default' => 1, - 'min' => 1, - 'max' => 10, - ), - array( - 'id' => 'prautoblogger_schedule_time', - 'label' => __( 'Generation Time', 'prautoblogger' ), - 'type' => 'time', - 'section' => 'prautoblogger_schedule', - 'default' => '03:00', - 'description' => sprintf( - /* translators: %s: site timezone string, e.g. "Asia/Singapore". */ - esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ), - esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' ) + // ── Board Settings ───────────────────────────────────────── + array( + 'id' => 'prautoblogger_board_poll_interval', + 'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_schedule', + 'default' => 5, + 'min' => 3, + 'max' => 60, + 'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_board_published_window_days', + 'label' => __( 'Published Column Window (days)', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_schedule', + 'default' => 7, + 'min' => 1, + 'max' => 90, + 'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ), + ), + + // ── Schedule & Budget ─────────────────────────────────────── + array( + 'id' => 'prautoblogger_daily_article_target', + 'label' => __( 'Articles per Day', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_schedule', + 'default' => 1, + 'min' => 1, + 'max' => 10, + ), + array( + 'id' => 'prautoblogger_schedule_time', + 'label' => __( 'Generation Time', 'prautoblogger' ), + 'type' => 'time', + 'section' => 'prautoblogger_schedule', + 'default' => '03:00', + 'description' => sprintf( + /* translators: %s: site timezone string, e.g. "Asia/Singapore". */ + esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ), + esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' ) + ), + ), + array( + 'id' => 'prautoblogger_monthly_budget_usd', + 'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_schedule', + 'default' => 50, + 'min' => 0, + 'step' => '0.01', + 'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ), + ), + + // ── Publishing ────────────────────────────────────────────── + array( + 'id' => 'prautoblogger_auto_publish', + 'label' => __( 'Auto-Publish', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_publishing', + 'default' => '0', + 'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_default_author', + 'label' => __( 'Default Author', 'prautoblogger' ), + 'type' => 'author_select', + 'section' => 'prautoblogger_publishing', + 'default' => 0, + 'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_default_category', + 'label' => __( 'Default Category', 'prautoblogger' ), + 'type' => 'category_select', + 'section' => 'prautoblogger_publishing', + 'default' => 0, + 'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_log_level', + 'label' => __( 'Log Level', 'prautoblogger' ), + 'type' => 'select', + 'section' => 'prautoblogger_publishing', + 'default' => 'info', + 'options' => array( + 'error' => __( 'Error — only failures', 'prautoblogger' ), + 'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ), + 'info' => __( 'Info — key events (recommended)', 'prautoblogger' ), + 'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ), + ), + 'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ), + ), + + // ── Analytics ─────────────────────────────────────────────── + array( + 'id' => 'prautoblogger_ga4_property_id', + 'label' => __( 'GA4 Property ID', 'prautoblogger' ), + 'type' => 'text', + 'section' => 'prautoblogger_analytics', + 'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_ga4_credentials_json', + 'label' => __( 'Service Account JSON', 'prautoblogger' ), + 'type' => 'password', + 'section' => 'prautoblogger_analytics', + 'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ), + ), + + // ── Display ──────────────────────────────────────────────── + array( + 'id' => 'prautoblogger_article_font_family', + 'label' => __( 'Article Font', 'prautoblogger' ), + 'type' => 'select', + 'section' => 'prautoblogger_display', + 'default' => 'default', + 'options' => PRAutoBlogger_Article_Typography::get_font_choices(), + 'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_article_font_size', + 'label' => __( 'Article Font Size (px)', 'prautoblogger' ), + 'type' => 'number', + 'section' => 'prautoblogger_display', + 'default' => 0, + 'min' => 0, + 'max' => 32, + 'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_table_borders', + 'label' => __( 'Table Borders', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_display', + 'default' => '1', + 'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ), ), ), - array( - 'id' => 'prautoblogger_monthly_budget_usd', - 'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_schedule', - 'default' => 50, - 'min' => 0, - 'step' => '0.01', - 'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ), - ), - - // ── Publishing ────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_auto_publish', - 'label' => __( 'Auto-Publish', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_publishing', - 'default' => '0', - 'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_default_author', - 'label' => __( 'Default Author', 'prautoblogger' ), - 'type' => 'author_select', - 'section' => 'prautoblogger_publishing', - 'default' => 0, - 'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_default_category', - 'label' => __( 'Default Category', 'prautoblogger' ), - 'type' => 'category_select', - 'section' => 'prautoblogger_publishing', - 'default' => 0, - 'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_log_level', - 'label' => __( 'Log Level', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_publishing', - 'default' => 'info', - 'options' => array( - 'error' => __( 'Error — only failures', 'prautoblogger' ), - 'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ), - 'info' => __( 'Info — key events (recommended)', 'prautoblogger' ), - 'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ), - ), - 'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ), - ), - - // ── Analytics ─────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_ga4_property_id', - 'label' => __( 'GA4 Property ID', 'prautoblogger' ), - 'type' => 'text', - 'section' => 'prautoblogger_analytics', - 'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_ga4_credentials_json', - 'label' => __( 'Service Account JSON', 'prautoblogger' ), - 'type' => 'password', - 'section' => 'prautoblogger_analytics', - 'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ), - ), - - // ── Display ──────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_article_font_family', - 'label' => __( 'Article Font', 'prautoblogger' ), - 'type' => 'select', - 'section' => 'prautoblogger_display', - 'default' => 'default', - 'options' => PRAutoBlogger_Article_Typography::get_font_choices(), - 'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_article_font_size', - 'label' => __( 'Article Font Size (px)', 'prautoblogger' ), - 'type' => 'number', - 'section' => 'prautoblogger_display', - 'default' => 0, - 'min' => 0, - 'max' => 32, - 'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_table_borders', - 'label' => __( 'Table Borders', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_display', - 'default' => '1', - 'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ), - ), - - // ── Images ───────────────────────────────────────────────── - array( - 'id' => 'prautoblogger_image_enabled', - 'label' => __( 'Enable Image Generation', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_images', - 'default' => '0', - 'description' => __( 'Generate images for each published article.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_b_enabled', - 'label' => __( 'Generate Second Image (B)', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_images', - 'default' => '1', - 'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_model', - 'label' => __( 'Image Model', 'prautoblogger' ), - 'type' => 'model_select', - 'section' => 'prautoblogger_images', - 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, - 'capability' => 'image_generation', - 'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), - 'badge' => __( 'Quality', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_runware_api_key', - 'label' => __( 'Runware API Key', 'prautoblogger' ), - 'type' => 'password', - 'section' => 'prautoblogger_images', - 'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ), - 'icon' => '🔑', - ), - array( - 'id' => 'prautoblogger_image_style_template', - 'label' => __( 'Style Template', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_images', - 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE, - 'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_prompt_instructions', - 'label' => __( 'Image Prompt Instructions', 'prautoblogger' ), - 'type' => 'textarea', - 'section' => 'prautoblogger_images', - 'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT, - 'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_nsfw_retry', - 'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_images', - 'default' => '1', - 'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_compose_enabled', - 'label' => __( 'Compose Branded Variants', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_images', - 'default' => '1', - 'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_compose_variants', - 'label' => __( 'Composed Variant Set', 'prautoblogger' ), - 'type' => 'text', - 'section' => 'prautoblogger_images', - 'default' => 'og,square', - 'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_image_featured_mark_enabled', - 'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_images', - 'default' => '1', - 'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ), - ), - array( - 'id' => 'prautoblogger_runware_model_catalog', - 'label' => __( 'Runware Model Catalog', 'prautoblogger' ), - 'type' => 'runware_catalog_sync', - 'section' => 'prautoblogger_images', - 'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ), - ), + PRAutoBlogger_Settings_Fields_Images::get_fields() ); } } diff --git a/includes/admin/class-settings-fields-images.php b/includes/admin/class-settings-fields-images.php new file mode 100644 index 0000000..fb5642b --- /dev/null +++ b/includes/admin/class-settings-fields-images.php @@ -0,0 +1,123 @@ +> + */ + public static function get_fields(): array { + return array( + + // ── Images ───────────────────────────────────────────────── + array( + 'id' => 'prautoblogger_image_enabled', + 'label' => __( 'Enable Image Generation', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '0', + 'description' => __( 'Generate images for each published article.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_b_enabled', + 'label' => __( 'Generate Second Image (B)', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_model', + 'label' => __( 'Image Model', 'prautoblogger' ), + 'type' => 'model_select', + 'section' => 'prautoblogger_images', + 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, + 'capability' => 'image_generation', + 'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), + 'badge' => __( 'Quality', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_runware_api_key', + 'label' => __( 'Runware API Key', 'prautoblogger' ), + 'type' => 'password', + 'section' => 'prautoblogger_images', + 'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ), + 'icon' => '🔑', + ), + array( + 'id' => 'prautoblogger_image_style_template', + 'label' => __( 'Style Template', 'prautoblogger' ), + 'type' => 'textarea', + 'section' => 'prautoblogger_images', + 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE, + 'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_prompt_instructions', + 'label' => __( 'Image Prompt Instructions', 'prautoblogger' ), + 'type' => 'textarea', + 'section' => 'prautoblogger_images', + 'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT, + 'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_nsfw_retry', + 'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_compose_enabled', + 'label' => __( 'Compose Branded Variants', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_compose_variants', + 'label' => __( 'Composed Variant Set', 'prautoblogger' ), + 'type' => 'text', + 'section' => 'prautoblogger_images', + 'default' => 'og,square', + 'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_image_featured_mark_enabled', + 'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ), + 'type' => 'toggle', + 'section' => 'prautoblogger_images', + 'default' => '1', + 'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ), + ), + array( + 'id' => 'prautoblogger_runware_model_catalog', + 'label' => __( 'Runware Model Catalog', 'prautoblogger' ), + 'type' => 'runware_catalog_sync', + 'section' => 'prautoblogger_images', + 'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ), + ), + ); + } +} diff --git a/includes/admin/class-settings-sanitizer.php b/includes/admin/class-settings-sanitizer.php new file mode 100644 index 0000000..eee8ee2 --- /dev/null +++ b/includes/admin/class-settings-sanitizer.php @@ -0,0 +1,107 @@ +skip_dedup ) { $topic = $result->get_topic(); - $topic_keywords = $this->extract_meaningful_keywords( $topic ); + $topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic ); if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) { ++$deduped_count; @@ -165,133 +166,6 @@ class PRAutoBlogger_Idea_Scorer { return false; } - /** - * Extract meaningful keywords from a text string (for fallback dedup). - * - * @param string $text Input text. - * @return string[] Unique lowercase keywords. - */ - private function extract_meaningful_keywords( string $text ): array { - static $stopwords = array( - 'a', - 'an', - 'the', - 'and', - 'or', - 'but', - 'is', - 'are', - 'was', - 'were', - 'in', - 'on', - 'at', - 'to', - 'for', - 'of', - 'with', - 'by', - 'from', - 'as', - 'it', - 'its', - 'this', - 'that', - 'your', - 'you', - 'how', - 'what', - 'when', - 'why', - 'where', - 'which', - 'who', - 'do', - 'does', - 'did', - 'not', - 'no', - 'can', - 'will', - 'should', - 'could', - 'would', - 'may', - 'might', - 'have', - 'has', - 'had', - 'be', - 'been', - 'being', - 'about', - 'into', - 'through', - 'during', - 'before', - 'after', - 'above', - 'below', - 'between', - 'same', - 'up', - 'down', - 'out', - 'off', - 'over', - 'under', - 'again', - 'then', - 'here', - 'there', - 'all', - 'each', - 'every', - 'both', - 'few', - 'more', - 'most', - 'other', - 'some', - 'such', - 'than', - 'too', - 'very', - 'just', - 'also', - 'only', - 'own', - 'so', - 'if', - 'while', - 'because', - 'until', - 'vs', - 'versus', - 'guide', - 'complete', - 'ultimate', - 'best', - 'top', - 'new', - 'first', - 'need', - 'know', - 'everything', - ); - - $text = strtolower( $text ); - $words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY ); - $words = array_filter( - $words, - static function ( string $w ) use ( $stopwords ): bool { - return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true ); - } - ); - - return array_values( array_unique( $words ) ); - } - /** * Map analysis type to a reader-friendly article type. * diff --git a/includes/core/class-keyword-extractor.php b/includes/core/class-keyword-extractor.php new file mode 100644 index 0000000..cc6e3c4 --- /dev/null +++ b/includes/core/class-keyword-extractor.php @@ -0,0 +1,156 @@ += 2 && ! in_array( $w, self::STOPWORDS, true ); + } + ); + + return array_values( array_unique( $words ) ); + } +} diff --git a/includes/core/class-semantic-dedup.php b/includes/core/class-semantic-dedup.php index 829af75..6729dcb 100644 --- a/includes/core/class-semantic-dedup.php +++ b/includes/core/class-semantic-dedup.php @@ -204,7 +204,7 @@ class PRAutoBlogger_Semantic_Dedup { return false; } foreach ( $this->recent_titles as $title ) { - $title_keywords = $this->extract_meaningful_keywords( $title ); + $title_keywords = PRAutoBlogger_Keyword_Extractor::extract( $title ); $overlap = count( array_intersect( $topic_keywords, $title_keywords ) ); if ( $overlap / count( $topic_keywords ) >= 0.6 ) { return true; @@ -241,131 +241,4 @@ class PRAutoBlogger_Semantic_Dedup { } return $titles; } - - /** - * Extract meaningful keywords from text (fallback dedup). - * - * @param string $text Input text. - * @return string[] Unique lowercase keywords. - */ - private function extract_meaningful_keywords( string $text ): array { - static $stopwords = array( - 'a', - 'an', - 'the', - 'and', - 'or', - 'but', - 'is', - 'are', - 'was', - 'were', - 'in', - 'on', - 'at', - 'to', - 'for', - 'of', - 'with', - 'by', - 'from', - 'as', - 'it', - 'its', - 'this', - 'that', - 'your', - 'you', - 'how', - 'what', - 'when', - 'why', - 'where', - 'which', - 'who', - 'do', - 'does', - 'did', - 'not', - 'no', - 'can', - 'will', - 'should', - 'could', - 'would', - 'may', - 'might', - 'have', - 'has', - 'had', - 'be', - 'been', - 'being', - 'about', - 'into', - 'through', - 'during', - 'before', - 'after', - 'above', - 'below', - 'between', - 'same', - 'up', - 'down', - 'out', - 'off', - 'over', - 'under', - 'again', - 'then', - 'here', - 'there', - 'all', - 'each', - 'every', - 'both', - 'few', - 'more', - 'most', - 'other', - 'some', - 'such', - 'than', - 'too', - 'very', - 'just', - 'also', - 'only', - 'own', - 'so', - 'if', - 'while', - 'because', - 'until', - 'vs', - 'versus', - 'guide', - 'complete', - 'ultimate', - 'best', - 'top', - 'new', - 'first', - 'need', - 'know', - 'everything', - ); - - $text = strtolower( $text ); - $words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY ); - $words = array_filter( - $words, - static function ( string $w ) use ( $stopwords ): bool { - return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true ); - } - ); - - return array_values( array_unique( $words ) ); - } } diff --git a/includes/providers/class-image-aspect-ratio.php b/includes/providers/class-image-aspect-ratio.php new file mode 100644 index 0000000..0ffe3e6 --- /dev/null +++ b/includes/providers/class-image-aspect-ratio.php @@ -0,0 +1,88 @@ + + */ + private const STANDARD_ASPECTS = array( + array( + 'w' => 1, + 'h' => 1, + 'ratio' => 1.0, + ), + array( + 'w' => 3, + 'h' => 2, + 'ratio' => 1.5, + ), + array( + 'w' => 2, + 'h' => 3, + 'ratio' => 0.6667, + ), + array( + 'w' => 4, + 'h' => 3, + 'ratio' => 1.3333, + ), + array( + 'w' => 3, + 'h' => 4, + 'ratio' => 0.75, + ), + array( + 'w' => 16, + 'h' => 9, + 'ratio' => 1.7778, + ), + array( + 'w' => 9, + 'h' => 16, + 'ratio' => 0.5625, + ), + ); + + /** + * Snap pixel dimensions to the nearest supported aspect ratio string. + * + * @param int $width Width in pixels. + * @param int $height Height in pixels (must be > 0 to avoid division by zero). + * @return string Ratio string e.g. "16:9". Returns "1:1" when height is 0. + */ + public static function snap( int $width, int $height ): string { + $target = $height > 0 ? (float) $width / (float) $height : 1.0; + $best = self::STANDARD_ASPECTS[0]; + $best_d = abs( $target - $best['ratio'] ); + + foreach ( self::STANDARD_ASPECTS as $candidate ) { + $d = abs( $target - $candidate['ratio'] ); + if ( $d < $best_d ) { + $best = $candidate; + $best_d = $d; + } + } + + return $best['w'] . ':' . $best['h']; + } +} diff --git a/includes/providers/class-open-router-image-batch.php b/includes/providers/class-open-router-image-batch.php index 7eefe54..72142c8 100644 --- a/includes/providers/class-open-router-image-batch.php +++ b/includes/providers/class-open-router-image-batch.php @@ -262,56 +262,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch { * @return array */ private function build_request_body( string $prompt, int $width, int $height, string $model ): array { - $target = $height > 0 ? (float) $width / (float) $height : 1.0; - - // Snap to nearest standard aspect ratio. - $aspects = array( - array( - 'w' => 1, - 'h' => 1, - 'r' => 1.0, - ), - array( - 'w' => 3, - 'h' => 2, - 'r' => 1.5, - ), - array( - 'w' => 2, - 'h' => 3, - 'r' => 0.6667, - ), - array( - 'w' => 4, - 'h' => 3, - 'r' => 1.3333, - ), - array( - 'w' => 3, - 'h' => 4, - 'r' => 0.75, - ), - array( - 'w' => 16, - 'h' => 9, - 'r' => 1.7778, - ), - array( - 'w' => 9, - 'h' => 16, - 'r' => 0.5625, - ), - ); - $best = $aspects[0]; - $best_d = abs( $target - $best['r'] ); - foreach ( $aspects as $c ) { - $d = abs( $target - $c['r'] ); - if ( $d < $best_d ) { - $best = $c; - $best_d = $d; - } - } - $aspect = $best['w'] . ':' . $best['h']; + $aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height ); return array( 'model' => $model, diff --git a/includes/providers/class-open-router-image-provider.php b/includes/providers/class-open-router-image-provider.php index 32054b7..b3a4121 100644 --- a/includes/providers/class-open-router-image-provider.php +++ b/includes/providers/class-open-router-image-provider.php @@ -233,51 +233,10 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro ); } - /** - * Supported aspect ratios for OpenRouter image models. - * - * @var array - */ - private const STANDARD_ASPECTS = array( - array( - 'w' => 1, - 'h' => 1, - 'ratio' => 1.0, - ), - array( - 'w' => 3, - 'h' => 2, - 'ratio' => 1.5, - ), - array( - 'w' => 2, - 'h' => 3, - 'ratio' => 0.6667, - ), - array( - 'w' => 4, - 'h' => 3, - 'ratio' => 1.3333, - ), - array( - 'w' => 3, - 'h' => 4, - 'ratio' => 0.75, - ), - array( - 'w' => 16, - 'h' => 9, - 'ratio' => 1.7778, - ), - array( - 'w' => 9, - 'h' => 16, - 'ratio' => 0.5625, - ), - ); + private function build_request_body( string $prompt, int $width, int $height, string $model ): array { - $aspect = $this->snap_aspect_ratio( $width, $height ); + $aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height ); return array( 'model' => $model, @@ -296,29 +255,6 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro ); } - /** - * Snap pixel dimensions to the nearest supported aspect ratio string. - * - * @param int $width Width in pixels. - * @param int $height Height in pixels. - * @return string Aspect ratio string like "16:9". - */ - private function snap_aspect_ratio( int $width, int $height ): string { - $target = $height > 0 ? (float) $width / (float) $height : 1.0; - $best = self::STANDARD_ASPECTS[0]; - $best_d = abs( $target - $best['ratio'] ); - - foreach ( self::STANDARD_ASPECTS as $candidate ) { - $d = abs( $target - $candidate['ratio'] ); - if ( $d < $best_d ) { - $best = $candidate; - $best_d = $d; - } - } - - return $best['w'] . ':' . $best['h']; - } - /** * Enforce the interface's dimension contract. * diff --git a/includes/providers/class-runware-image-batch.php b/includes/providers/class-runware-image-batch.php index aacbd6f..2e91988 100644 --- a/includes/providers/class-runware-image-batch.php +++ b/includes/providers/class-runware-image-batch.php @@ -2,19 +2,7 @@ // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard declare(strict_types=1); -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle -// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close +// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent. /** * Parallel image generation via curl_multi for Runware. diff --git a/includes/providers/class-runware-image-provider.php b/includes/providers/class-runware-image-provider.php index 7bb35f1..3d32360 100644 --- a/includes/providers/class-runware-image-provider.php +++ b/includes/providers/class-runware-image-provider.php @@ -178,72 +178,15 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid /** * {@inheritDoc} * - * Runware has no dedicated "validate key" endpoint, so we send a minimal - * authentication task and look for `taskType=authentication` in the - * response data with no accompanying errors. + * Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance). + * + * @see providers/class-runware-image-validator.php */ public function validate_credentials_detailed(): array { - try { - $api_key = $this->support->get_api_key(); - } catch ( \RuntimeException $e ) { - return array( - 'status' => 'error', - 'message' => $e->getMessage(), - ); - } - - $body = array( - array( - 'taskType' => 'authentication', - 'apiKey' => $api_key, - ), - ); - - $response = wp_remote_post( - PRAutoBlogger_Runware_Image_Support::API_URL, - array( - 'timeout' => 15, - 'headers' => array( 'Content-Type' => 'application/json' ), - 'body' => wp_json_encode( $body ), - ) - ); - - if ( is_wp_error( $response ) ) { - return array( - 'status' => 'error', - 'message' => __( 'Could not reach Runware API.', 'prautoblogger' ), - 'debug' => $response->get_error_message(), - ); - } - - $status = (int) wp_remote_retrieve_response_code( $response ); - $raw = (string) wp_remote_retrieve_body( $response ); - - if ( 200 !== $status ) { - return array( - 'status' => 'error', - 'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ), - 'debug' => substr( $raw, 0, 200 ), - ); - } - - $decoded = json_decode( $raw, true ); - if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) { - $first = $decoded['errors'][0] ?? array(); - $msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first; - return array( - 'status' => 'error', - 'message' => __( 'Runware rejected the API key.', 'prautoblogger' ), - 'debug' => substr( $msg, 0, 200 ), - ); - } - - return array( - 'status' => 'ok', - 'message' => __( 'Runware API key valid.', 'prautoblogger' ), - ); + return PRAutoBlogger_Runware_Validator::validate( $this->support ); } + /** * Build the JSON task-array body for a single imageInference request. * diff --git a/includes/providers/class-runware-image-validator.php b/includes/providers/class-runware-image-validator.php new file mode 100644 index 0000000..5fa8920 --- /dev/null +++ b/includes/providers/class-runware-image-validator.php @@ -0,0 +1,89 @@ +get_api_key(); + } catch ( \RuntimeException $e ) { + return array( + 'status' => 'error', + 'message' => $e->getMessage(), + ); + } + + $body = array( + array( + 'taskType' => 'authentication', + 'apiKey' => $api_key, + ), + ); + + $response = wp_remote_post( + PRAutoBlogger_Runware_Image_Support::API_URL, + array( + 'timeout' => 15, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( $body ), + ) + ); + + if ( is_wp_error( $response ) ) { + return array( + 'status' => 'error', + 'message' => __( 'Could not reach Runware API.', 'prautoblogger' ), + 'debug' => $response->get_error_message(), + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $raw = (string) wp_remote_retrieve_body( $response ); + + if ( 200 !== $status ) { + return array( + 'status' => 'error', + 'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ), + 'debug' => substr( $raw, 0, 200 ), + ); + } + + $decoded = json_decode( $raw, true ); + if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) { + $first = $decoded['errors'][0] ?? array(); + $msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first; + return array( + 'status' => 'error', + 'message' => __( 'Runware rejected the API key.', 'prautoblogger' ), + 'debug' => substr( $msg, 0, 200 ), + ); + } + + return array( + 'status' => 'ok', + 'message' => __( 'Runware API key valid.', 'prautoblogger' ), + ); + } +} diff --git a/prautoblogger.php b/prautoblogger.php index 97ea22f..335d318 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.21.0 + * Version: 0.22.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.21.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.22.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 ); define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 ); diff --git a/scripts/check-constants.php b/scripts/check-constants.php index 2d36670..60a9e87 100644 --- a/scripts/check-constants.php +++ b/scripts/check-constants.php @@ -35,18 +35,18 @@ const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php'; // Directories skipped when walking the shipped tree for references. -const CONSTANTS_EXCLUDE_DIRS = ['tests', 'vendor', '.github', '.git', 'node_modules']; +const CONSTANTS_EXCLUDE_DIRS = array( 'tests', 'vendor', '.github', '.git', 'node_modules' ); // Entry points for the reference scan (files and dirs relative to repo root). -const CONSTANTS_SCAN_ROOTS = [ +const CONSTANTS_SCAN_ROOTS = array( 'prautoblogger.php', 'uninstall.php', 'includes', 'templates', -]; +); -$verbose = in_array( '--verbose', $argv ?? [], true ); -$self_test = in_array( '--self-test', $argv ?? [], true ); +$verbose = in_array( '--verbose', $argv ?? array(), true ); +$self_test = in_array( '--self-test', $argv ?? array(), true ); // ------------------------------------------------------------------------- // 1. Build the defined set from prautoblogger.php only. @@ -67,7 +67,7 @@ preg_match_all( ); /** @var array $defined_set constant name => 1 */ -$defined_set = array_flip( $def_matches[1] ?? [] ); +$defined_set = array_flip( $def_matches[1] ?? array() ); if ( $verbose ) { echo 'Defined in ' . CONSTANTS_BOOTSTRAP_FILE . ' (' . count( $defined_set ) . "):\n"; @@ -108,7 +108,7 @@ function constants_collect_php_files( string $dir, array &$files ): void { } /** @var string[] $php_files */ -$php_files = []; +$php_files = array(); foreach ( CONSTANTS_SCAN_ROOTS as $root ) { if ( ! file_exists( $root ) ) { @@ -133,11 +133,11 @@ if ( $verbose ) { // ------------------------------------------------------------------------- /** @var array $errors */ -$errors = []; +$errors = array(); foreach ( $php_files as $php_path ) { $raw = file_get_contents( $php_path ); - if ( $raw === false ) { + if ( false === $raw ) { fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" ); continue; } @@ -188,7 +188,7 @@ foreach ( $php_files as $php_path ) { } if ( ! $guarded ) { - $errors[] = [ $php_path, $idx + 1, $name, $raw_line ]; + $errors[] = array( $php_path, $idx + 1, $name, $raw_line ); } } } @@ -198,7 +198,7 @@ foreach ( $php_files as $php_path ) { // 4. Report results. // ------------------------------------------------------------------------- -if ( $errors !== [] ) { +if ( array() !== $errors ) { fwrite( STDERR, "\nFAIL -- Unguarded undefined PRAUTOBLOGGER_* constants:\n\n" ); foreach ( $errors as [ $php_path, $lineno, $name, $line_text ] ) { fwrite( STDERR, " {$php_path}:{$lineno}: {$name}\n" ); @@ -228,7 +228,7 @@ if ( $self_test ) { // Now simulate an unguarded reference by re-running the scan on a temp file. $tmp_file = tempnam( sys_get_temp_dir(), 'prab_selftest_' ) . '.php'; file_put_contents( $tmp_file, " $raw_line ) { if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) { diff --git a/templates/admin/ideas-browser.php b/templates/admin/ideas-browser.php index c1da706..27d4a71 100644 --- a/templates/admin/ideas-browser.php +++ b/templates/admin/ideas-browser.php @@ -72,10 +72,11 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' ); 1 ) : ?>
add_query_arg( 'paged', '%#%' ), - 'format' => '', - 'current' => $paged, - 'total' => $total_pages, - 'prev_text' => '«', - 'next_text' => '»', - ] ) ); + echo wp_kses_post( + paginate_links( + array( + 'base' => add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'current' => $paged, + 'total' => $total_pages, + 'prev_text' => '«', + 'next_text' => '»', + ) + ) + ); ?>
diff --git a/templates/admin/log-viewer.php b/templates/admin/log-viewer.php index 348a24e..6b11d7d 100755 --- a/templates/admin/log-viewer.php +++ b/templates/admin/log-viewer.php @@ -18,13 +18,13 @@ if ( ! defined( 'ABSPATH' ) ) { } $base_url = admin_url( 'admin.php?page=prautoblogger-logs' ); -$level_counts = [ +$level_counts = array( 'all' => __( 'All', 'prautoblogger' ), 'error' => __( 'Errors', 'prautoblogger' ), 'warning' => __( 'Warnings', 'prautoblogger' ), 'info' => __( 'Info', 'prautoblogger' ), 'debug' => __( 'Debug', 'prautoblogger' ), -]; +); ?>
@@ -46,7 +46,20 @@ $level_counts = [
$lv_label ) : ?> - @@ -119,14 +132,18 @@ $level_counts = [ 1 ) : ?>
add_query_arg( 'paged', '%#%' ), - 'format' => '', - 'current' => $paged, - 'total' => $total_pages, - 'prev_text' => '«', - 'next_text' => '»', - ] ) ); + echo wp_kses_post( + paginate_links( + array( + 'base' => add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'current' => $paged, + 'total' => $total_pages, + 'prev_text' => '«', + 'next_text' => '»', + ) + ) + ); ?>
diff --git a/templates/admin/metabox-generation-info.php b/templates/admin/metabox-generation-info.php index b9778eb..2362520 100755 --- a/templates/admin/metabox-generation-info.php +++ b/templates/admin/metabox-generation-info.php @@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -$meta = [ +$meta = array( 'topic' => get_post_meta( $post->ID, '_prautoblogger_topic', true ), 'type' => get_post_meta( $post->ID, '_prautoblogger_article_type', true ), 'model' => get_post_meta( $post->ID, '_prautoblogger_model_used', true ), @@ -22,24 +22,24 @@ $meta = [ 'seo_score' => get_post_meta( $post->ID, '_prautoblogger_seo_score', true ), 'generated_at' => get_post_meta( $post->ID, '_prautoblogger_generated_at', true ), 'keywords' => get_post_meta( $post->ID, '_prautoblogger_target_keywords', true ), -]; +); ?>

- +

- +

- +

- +

@@ -47,8 +47,8 @@ $meta = [

- - + +

@@ -69,7 +69,7 @@ $meta = [

- +

diff --git a/templates/admin/review-queue.php b/templates/admin/review-queue.php index 287a80c..dad6f6d 100644 --- a/templates/admin/review-queue.php +++ b/templates/admin/review-queue.php @@ -141,7 +141,7 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp $quality_score = get_post_meta( $post_id, '_prautoblogger_quality_score', true ); $generated_at = get_post_meta( $post_id, '_prautoblogger_generated_at', true ); $editor_notes = get_post_meta( $post_id, '_prautoblogger_editor_notes', true ); - $verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ?: 'pending' ); + $verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ? $verdict : 'pending' ); ?> @@ -158,9 +158,9 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
- - - + + + diff --git a/tests/unit/Admin/SettingsFieldsExtendedTest.php b/tests/unit/Admin/SettingsFieldsExtendedTest.php new file mode 100644 index 0000000..4f71e3a --- /dev/null +++ b/tests/unit/Admin/SettingsFieldsExtendedTest.php @@ -0,0 +1,77 @@ +justReturn( false ); + \Brain\Monkey\Functions\when( 'update_option' )->justReturn( true ); + // wp_timezone_string is referenced in schedule-time field description. + \Brain\Monkey\Functions\when( 'wp_timezone_string' )->justReturn( 'UTC' ); + } + + /** + * get_fields() must return exactly 29 fields (18 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 + * from the local array + 11 image fields from Settings_Fields_Images. + */ + public function test_get_fields_returns_29_fields(): void { + $fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields(); + $this->assertCount( + 29, + $fields, + 'Expected 29 fields (18 local + 11 image). A + union instead of array_merge would return only 18.' + ); + } + + /** + * Every field must carry a non-empty id and a section string. + * This catches a malformed field entry that would fail register_setting(). + */ + public function test_every_field_has_id_and_section(): void { + $fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields(); + foreach ( $fields as $field ) { + $this->assertArrayHasKey( 'id', $field, 'Field missing id key' ); + $this->assertNotEmpty( $field['id'], 'Field has empty id' ); + $this->assertArrayHasKey( 'section', $field, sprintf( 'Field %s missing section key', $field['id'] ) ); + } + } + + /** + * The image fields from Settings_Fields_Images must all be present. + * + * Checks that prautoblogger_image_enabled (first image field) + * exists in the merged result — if the + union regresses, this field + * would be missing because it is at index 0 in the image array (which + * collides with index 0 in the local array under +). + */ + public function test_image_fields_present_in_merged_result(): void { + $fields = \PRAutoBlogger_Settings_Fields_Extended::get_fields(); + $ids = array_column( $fields, 'id' ); + $this->assertContains( + 'prautoblogger_image_enabled', + $ids, + 'Enable Image Generation toggle missing — array_merge may have regressed to +' + ); + } +}