From 3890485dcbd5a48e16a55826cc9c0932ae00d771 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 16 Jun 2026 14:00:48 +0000 Subject: [PATCH] ci: adopt estate reusable CI + split 300-line violations (v0.22.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ci.yml → thin caller of peptide-e2e reusable CI (tests: brain-monkey) + keep check-constants as parallel job; workflow_call trigger enabled - deploy.yml: retire standalone validate job; gate on ci.yml via workflow_call - composer.json: add 'test' script (vendor/bin/phpunit --testdox) - Version bumped to 0.22.0 300-line splits (9 files over limit → all under 300): - class-keyword-extractor.php: shared tokeniser from Idea_Scorer + Semantic_Dedup - class-image-aspect-ratio.php: aspect-ratio snap from OR provider + OR batch - class-settings-sanitizer.php: option sanitization from Admin_Page - class-settings-fields-images.php: image fields from Settings_Fields_Extended - class-runware-image-validator.php: credential check from Runware_Image_Provider - Runware batch: 15 phpcs:disable lines → 1 grouped suppression Agent-Session: claude/adopt-ci-20260616 --- .github/workflows/ci.yml | 130 +++-------------- .github/workflows/deploy.yml | 26 +--- CHANGELOG.md | 39 +++++ composer.json | 45 +++--- includes/admin/class-admin-page.php | 80 +---------- .../admin/class-settings-fields-extended.php | 93 +----------- .../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 | 64 +++++++++ includes/core/class-semantic-dedup.php | 129 +---------------- .../providers/class-image-aspect-ratio.php | 60 ++++++++ .../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 | 90 ++++++++++++ prautoblogger.php | 4 +- 18 files changed, 559 insertions(+), 765 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f853c39..4b6d847 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,3 @@ 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. - 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..d0b041c 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -220,97 +220,6 @@ class PRAutoBlogger_Settings_Fields_Extended { '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..84d077f --- /dev/null +++ b/includes/core/class-keyword-extractor.php @@ -0,0 +1,64 @@ += 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..ef77000 --- /dev/null +++ b/includes/providers/class-image-aspect-ratio.php @@ -0,0 +1,60 @@ + + */ + 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..059ab2d --- /dev/null +++ b/includes/providers/class-runware-image-validator.php @@ -0,0 +1,90 @@ +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 );