Compare commits
6 Commits
claude/pra
...
claude/pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c8071f2e8 | ||
|
|
e499ff598c | ||
|
|
f2a00d421d | ||
|
|
165921e8ef | ||
|
|
94b660162a | ||
|
|
c7f63b1133 |
@@ -35,33 +35,25 @@ 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',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => 'prautoblogger_deploy_handle',
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
)
|
||||
);
|
||||
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
|
||||
'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',
|
||||
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',
|
||||
)
|
||||
);
|
||||
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',
|
||||
] );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +78,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.',
|
||||
array( 'status' => 429 )
|
||||
[ 'status' => 429 ]
|
||||
);
|
||||
}
|
||||
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
||||
@@ -95,7 +87,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.', array( 'status' => 400 ) );
|
||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$tmp_file = $files['plugin']['tmp_name'];
|
||||
@@ -104,11 +96,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.', array( 'status' => 400 ) );
|
||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] );
|
||||
}
|
||||
|
||||
$has_main_file = false;
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
||||
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
||||
$has_main_file = true;
|
||||
break;
|
||||
@@ -121,7 +113,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.',
|
||||
array( 'status' => 400 )
|
||||
[ 'status' => 400 ]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,7 +143,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.', array( 'status' => 500 ) );
|
||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +161,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.', array( 'status' => 500 ) );
|
||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] );
|
||||
}
|
||||
|
||||
// 7. Activate plugin.
|
||||
@@ -186,7 +178,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.',
|
||||
array( 'status' => 500 )
|
||||
[ 'status' => 500 ]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,23 +188,18 @@ 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 ? $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 ?: 'fresh install',
|
||||
gmdate( 'Y-m-d H:i:s' )
|
||||
) );
|
||||
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
),
|
||||
200
|
||||
);
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,7 +210,7 @@ function prautoblogger_deploy_get_installed_version(): string {
|
||||
if ( ! file_exists( $main_file ) ) {
|
||||
return '';
|
||||
}
|
||||
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
|
||||
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
|
||||
return $data['Version'] ?? '';
|
||||
}
|
||||
|
||||
|
||||
133
.github/workflows/ci.yml
vendored
133
.github/workflows/ci.yml
vendored
@@ -1,34 +1,114 @@
|
||||
##
|
||||
# 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
|
||||
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.
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_call: {}
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
|
||||
with:
|
||||
tests: brain-monkey
|
||||
secrets: inherit
|
||||
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
|
||||
|
||||
check-constants:
|
||||
name: Check PRAUTOBLOGGER_* constants
|
||||
@@ -46,8 +126,7 @@ 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 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).
|
||||
- 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
|
||||
|
||||
28
.github/workflows/deploy.yml
vendored
28
.github/workflows/deploy.yml
vendored
@@ -9,17 +9,29 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: CI Gate (reusable)
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
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
|
||||
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: ci
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
needs: validate
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
> **Cross-app context:** decisions that affect multiple plugins (Cloudflare AI Gateway routing, OpenRouter account sharing, the interface pattern, image-generation stack, social distributor choice) are recorded in `Peptide Repo CTO/docs/engineering/decisions/`. The incident runbook for cross-app failure modes is at `Peptide Repo CTO/docs/engineering/INCIDENT-RUNBOOK.md`. Engineer PRAutoBlogger should read both before making decisions that cross plugin boundaries.
|
||||
|
||||
> **App-local decisions:** choices that affect only this plugin are recorded in [`docs/adr/`](docs/adr/README.md) — a per-repo numbered log (0001, 0002, …). Read `docs/adr/README.md` for the index. The routing rule and required template live centrally in `Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`.
|
||||
|
||||
PRAutoBlogger is a WordPress plugin that monitors social media (starting with Reddit) for recurring questions, complaints, and comparisons in a configured niche, uses LLM agents (via OpenRouter) to generate high-quality blog articles from those insights, runs them through a chief editor agent for quality review, and auto-publishes them on a configurable daily schedule. All collected data and generation metrics are stored for a self-improvement feedback loop.
|
||||
|
||||
---
|
||||
@@ -1117,8 +1115,7 @@ If the host kills any tick:
|
||||
| Caller | Path |
|
||||
|--------|------|
|
||||
| Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||
| WP-CLI (async) | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
|
||||
| WP-CLI (sync / VPS) | `wp prautoblogger generate --sync` → `WP_CLI_Commands::run_sync()` → `set_sync_mode(true)` → `on_orchestrate_tick()` → tick loop `on_generate_tick()` (×N) → `set_sync_mode(false)` |
|
||||
| WP-CLI | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
|
||||
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
|
||||
|
||||
### SSH Workaround Retirement
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -8,91 +8,8 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
## [Unreleased - Internal]
|
||||
|
||||
### Internal
|
||||
- ADR log scaffolded: `docs/adr/` created as the app-local decision log for this plugin; `docs/adr/README.md` is the index; ADR-0001 documents the v0.16.0 editorial illustration image-style pivot. `ARCHITECTURE.md` updated to point to the folder. No runtime changes; docs-only.
|
||||
- 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.1] - 2026-06-22
|
||||
|
||||
### Added
|
||||
- **`wp prautoblogger generate --sync`** (VPS orchestrator mode): runs the full
|
||||
checkpoint pipeline synchronously in the calling process — acquires lock, calls
|
||||
`on_orchestrate_tick()` in-process, then loops `on_generate_tick()` until the
|
||||
queue is empty or a terminal state is reached (max 20 ticks). Exits 0 on
|
||||
success with a JSON summary line; exits 1 on error. Designed to be called from
|
||||
the Coolify VPS over a held-open SSH session, where Hostinger's ~10-min
|
||||
background-process kill does not apply.
|
||||
|
||||
### Fixed
|
||||
- **`$sync_mode` guard in `Generation_Checkpoint_Runner::on_generate_tick()`**:
|
||||
when running under `--sync`, the internal `wp_schedule_single_event()` +
|
||||
`fire_cron_now()` reschedule is suppressed so the VPS's external loop cannot
|
||||
race with a competing background wp-cron tick. Existing async (cron/AJAX) paths
|
||||
are unaffected.
|
||||
- **Reaper timezone fix** in `Run_Reaper::sweep_stuck_stages()` and
|
||||
`sweep_stuck_runs()`: `$cutoff` was computed with `gmdate()` (UTC) while
|
||||
`updated_at` is stored via `current_time('mysql')` (server local time, UTC+8).
|
||||
Changed to `wp_date()` so the cutoff respects the site timezone. On this host
|
||||
the 8-hour delta could suppress reaping of a same-day-stalled run; the 44h-old
|
||||
run `ee6e7d91` was already far outside the threshold and unaffected.
|
||||
- **Scoped self-heal migration** in `DB_Migrations`: restores the low-stakes
|
||||
maintenance cron events (`prautoblogger_reap_orphan_research_rows`,
|
||||
`prautoblogger_collect_metrics`, `prautoblogger_sync_runware_models`) that
|
||||
vanished after the 06-15 deploy cycle. **`prautoblogger_daily_generation` is
|
||||
deliberately NOT re-registered** — the VPS systemd timer owns generation
|
||||
scheduling as of v0.22.1. Note: wp-cron reliability on this Hostinger host is
|
||||
suspect (background PHP killed ~10 min); maintenance crons are low-stakes and
|
||||
acceptable on this transport; generation is not.
|
||||
- **Sync-mode guard tests** (QA NF-1 remediation 2026-06-23): moved sync-mode
|
||||
tests to `GenerationCheckpointRunnerSyncModeTest.php`; fixed
|
||||
`test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event` to
|
||||
return >= 1 idea from `orchestrate_only()` so execution reaches the sync guard
|
||||
at line ~181 (was vacuous -- previous stub returned `[]` causing early return);
|
||||
added `test_generate_tick_sync_mode_suppresses_reschedule` for the
|
||||
`on_generate_tick()` guard; async regression test preserved in same file.
|
||||
- **Root `CONTEXT.md`**: created domain glossary (run/run_stages/checkpoint/
|
||||
idea-queue/Generation_Lock) and execution mode comparison (async wp-cron vs.
|
||||
`--sync` VPS).
|
||||
- **`ARCHITECTURE.md` SS25**: added `WP-CLI (sync / VPS)` row to Entry Points table.
|
||||
|
||||
## [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
|
||||
@@ -521,6 +438,7 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
the catalog + OpenRouter static list. Breaking change: callers that expect a
|
||||
specific static list should note the new dynamic Runware side.
|
||||
|
||||
|
||||
### Fixed
|
||||
- Runware image generation now correctly uses the admin-selected model instead
|
||||
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
|
||||
@@ -620,6 +538,7 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
### Fixed
|
||||
- Image prompt builder now properly delegates to static parser methods
|
||||
|
||||
|
||||
### Fixed
|
||||
- **Opik autoloader gap** — `class-autoloader.php` listed `services/` as a scan directory but not `services/opik/`, where all four Opik classes live (`Opik_Client`, `Opik_Trace_Context`, `Opik_Span_Queue`, `Opik_Dispatcher`). Composer's classmap (used in PHPUnit) covers `includes/` recursively so tests passed, but the WordPress runtime autoloader would fatal on first Opik class reference in production. Added `services/opik/` to the scan list.
|
||||
|
||||
|
||||
77
CONTEXT.md
77
CONTEXT.md
@@ -1,77 +0,0 @@
|
||||
# PRAutoBlogger — Domain Glossary
|
||||
|
||||
Quick reference for the generation pipeline domain model. For full design rationale
|
||||
see `ARCHITECTURE.md` (§21–§25) and `CONVENTIONS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Core Domain Terms
|
||||
|
||||
**run**
|
||||
A single end-to-end article generation cycle: orchestrate → generate idea(s) →
|
||||
finalize. Represented as a row in `wp_prautoblogger_runs` (status, cost ceiling,
|
||||
timestamps). One run may produce multiple articles if the daily idea-count > 1.
|
||||
|
||||
**run\_stages** (`wp_prautoblogger_run_stages`)
|
||||
Sub-steps within a run (research, analysis, generation, publish). Each stage has
|
||||
its own status and can be replayed independently via `Rerun_Executor`. Generation
|
||||
checkpoint runs populate stages as Article_Worker progresses.
|
||||
|
||||
**checkpoint**
|
||||
The persistence boundary between pipeline ticks. After each tick the idea queue
|
||||
and run state are written to `wp_options` so a process kill cannot lose work. The
|
||||
next tick reads the queue option and continues. See §25 of ARCHITECTURE.md.
|
||||
|
||||
**idea queue** (`prautoblogger_article_queue` option)
|
||||
Serialized array of `Article_Idea` objects written by `on_orchestrate_tick()` and
|
||||
consumed one-at-a-time by successive `on_generate_tick()` calls. Deleted on
|
||||
finalize or cleanup.
|
||||
|
||||
**Generation\_Lock** (`wp_prautoblogger_generation_lock` or `wp_options`)
|
||||
DB mutex that prevents two concurrent runs. Acquired at the start of
|
||||
`on_orchestrate_tick()`, released by `Helpers::finalize()` or on error/halt.
|
||||
|
||||
---
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Async (wp-cron driven)
|
||||
|
||||
Default path for the Board "New Article" button and the plain
|
||||
`wp prautoblogger generate` WP-CLI command.
|
||||
|
||||
1. `kick_off()` schedules `prautoblogger_gen_orchestrate` and calls `fire_cron_now()`.
|
||||
2. `on_orchestrate_tick()` runs in a background PHP process, schedules
|
||||
`prautoblogger_gen_tick`, and calls `fire_cron_now()` to spawn the next process.
|
||||
3. Each `on_generate_tick()` spawns the next until the queue drains.
|
||||
|
||||
Limitation: Hostinger kills background PHP processes at ~10 min. Orchestrate tick
|
||||
(LLM-free) and individual generate ticks (one LLM call each) are both well within
|
||||
this limit; the checkpoint design is the mitigation.
|
||||
|
||||
### Sync (VPS-driven / `--sync`)
|
||||
|
||||
Used by the Coolify KVM8 VPS systemd timer via `infra/vps/orchestrate-generation.sh`
|
||||
in `peptide-e2e`. Introduced v0.22.1.
|
||||
|
||||
1. `run_sync()` sets `Generation_Checkpoint_Runner::$sync_mode = true`.
|
||||
2. Calls `on_orchestrate_tick()` **in-process** (no background spawn).
|
||||
3. Loops calling `on_generate_tick()` directly until queue empty or terminal state.
|
||||
4. `$sync_mode = true` suppresses all `wp_schedule_single_event` +
|
||||
`fire_cron_now` calls inside both tick methods, preventing a competing
|
||||
background cron chain from forming in parallel.
|
||||
|
||||
The VPS SSH session is held open; the process is not subject to Hostinger's
|
||||
background-kill constraint.
|
||||
|
||||
---
|
||||
|
||||
## Table Shorthand
|
||||
|
||||
Throughout the codebase `wp_prautoblogger_*` tables are referenced by their
|
||||
`$wpdb->` property names (e.g. `$wpdb->prab_runs`, `$wpdb->prab_run_stages`).
|
||||
Full table names are defined in `class-activator.php`.
|
||||
|
||||
---
|
||||
|
||||
*See also: `ARCHITECTURE.md` §25 (checkpoint design), `CONVENTIONS.md` (coding rules).*
|
||||
@@ -1,26 +1,23 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
"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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
# ADR-0001: Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-05-29
|
||||
- **Deciders:** CTO, Engineer PRAutoBlogger
|
||||
- **Supersedes:** —
|
||||
- **Superseded by:** —
|
||||
- **Related:** ARCHITECTURE.md §#20; CHANGELOG.md [0.16.0]; PR #146 (d00e746);
|
||||
`docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md`;
|
||||
ARCHITECTURE.md §#21 (v0.17.0 deterministic composer, the follow-on commit)
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
PRAutoBlogger generates two AI images (A/B pair) for each article and publishes
|
||||
them as featured images. From the initial comic-strip launch (commit b17ed47,
|
||||
`feat: switch to single-panel newspaper comic style for article images`) through
|
||||
v0.15.x, the image style was a **single-panel newspaper comic** — the rewriter
|
||||
LLM was asked to produce a comic gag or visual pun, and the result was appended
|
||||
to a style suffix and sent to the diffusion model.
|
||||
|
||||
By April 2026 two problems had accumulated:
|
||||
|
||||
1. **Text legibility.** Small diffusion models (FLUX.1 schnell is the default
|
||||
since v0.9.0 at ~$0.0006/image) cannot reliably render readable text inside
|
||||
an image. Comic panels depend on legible speech bubbles or captions burned
|
||||
into the image; both were routinely garbled (confirmed in the 2026-04-21 A/B
|
||||
finding documented in ARCHITECTURE.md §#21).
|
||||
|
||||
2. **Caption path already existed.** A separate HTML caption below the image had
|
||||
been extracted as early as commit 214f57b (`Separate comic captions from
|
||||
generated images`, 2026-04-18). The scene/caption split in
|
||||
`Image_Scene_Parser` was already live, meaning there was an established path
|
||||
for readable text that did not depend on the diffusion model rendering it.
|
||||
|
||||
Together these created pressure to stop asking the diffusion model to render
|
||||
text at all, and instead shift all readable copy to the HTML caption path that
|
||||
was already in place.
|
||||
|
||||
## Decision drivers
|
||||
|
||||
1. **Image quality on default model.** FLUX.1 schnell (the default since v0.9.0)
|
||||
produces garbled text in generated images. A style that depends on readable
|
||||
in-image text degrades silently on the model we actually ship.
|
||||
2. **Editorial trust signal.** Peptide Repo's editorial direction is
|
||||
science-informational; comic gags undercut the authority the site needs for
|
||||
health-adjacent content.
|
||||
3. **Reuse the existing caption path.** The scene/caption split and HTML caption
|
||||
path were already live; the change is a prompt rewrite + template substitution
|
||||
with no new infrastructure.
|
||||
4. **Admin configurability.** The style suffix was a single string appended to
|
||||
every prompt; a template with a named substitution token (`{{ topic_summary
|
||||
}}`) is more maintainable and admin-overridable without a code deploy.
|
||||
5. **Cost and provider neutrality.** The change is prompt-only; provider, model,
|
||||
and pricing are unchanged.
|
||||
|
||||
## Options considered
|
||||
|
||||
### Option A — Keep the comic style, fix text rendering per-model
|
||||
|
||||
Ask the rewriter LLM to omit text from comic panels (no speech bubbles, no
|
||||
caption burns), and describe a visual scene instead. Keep the style suffix
|
||||
pattern.
|
||||
|
||||
**Pros:**
|
||||
- Minimal code change — just a prompt edit.
|
||||
- Comic aesthetic is recognisable and differentiating.
|
||||
|
||||
**Cons:**
|
||||
- Does not solve the underlying mismatch: the style suffix is a static string
|
||||
that gives no per-article context to the diffusion model. The rewriter scene
|
||||
still has no slot to insert topic-specific imagery.
|
||||
- Comic gags require the LLM to invent a joke per peptide, which is inconsistent
|
||||
in tone.
|
||||
- Comic aesthetic conflicts with the editorial trust direction for health content.
|
||||
|
||||
### Option B — Pivot to text-free editorial scientific illustration + template substitution (chosen)
|
||||
|
||||
Change the rewriter system prompt so the LLM emits a concise 1-2 sentence
|
||||
topic/mechanism summary as the SCENE — one concrete centered focal subject, no
|
||||
text, no people-as-gag, no logos. Replace the style suffix with an editable
|
||||
template (`prautoblogger_image_style_template`) that contains a single
|
||||
`{{ topic_summary }}` token filled per-article by
|
||||
`PRAutoBlogger_Image_Template_Filler`. The CAPTION line from the scene/caption
|
||||
split continues to flow to the HTML-caption-below-image path unchanged.
|
||||
|
||||
**Pros:**
|
||||
- Diffusion model is never asked to render text; garbled-text failures are
|
||||
eliminated.
|
||||
- The editorial scientific illustration style is consistent with the site's
|
||||
authority positioning.
|
||||
- Template substitution gives admins a single editable field to control the
|
||||
entire visual style without touching the per-article rewriter logic.
|
||||
- Degradation is explicit: missing token → append + warning; empty summary →
|
||||
style-only prompt; a blank prompt is never sent to the provider.
|
||||
- Provider, model, and cost are unchanged.
|
||||
|
||||
**Cons:**
|
||||
- Breaks the comic gag aesthetic for any site running PRAutoBlogger in a
|
||||
context where that was intentional (low risk for this deployment).
|
||||
- The old `prautoblogger_image_style_suffix` option must be migrated
|
||||
(handled via one-time migration `prautoblogger_migrated_style_template_v0160`
|
||||
and mirroring to `_deprecated` for one cycle).
|
||||
|
||||
### Option C — Generate text overlays in PHP (deterministic composer only)
|
||||
|
||||
Skip the style change; solve legibility by compositing text onto images in PHP
|
||||
after the diffusion model runs.
|
||||
|
||||
**Pros:**
|
||||
- Diffusion model input is unchanged (comic style preserved).
|
||||
- Composited text is always readable regardless of model.
|
||||
|
||||
**Cons:**
|
||||
- Compositing a comic-style gag is much harder than compositing a caption band
|
||||
(arbitrary layout vs. a fixed panel at the bottom).
|
||||
- Does not address the editorial direction mismatch.
|
||||
- The deterministic PHP composer (ARCHITECTURE.md §#21) was already planned as
|
||||
a follow-on commit specifically for OG/social variants — pursuing it as the
|
||||
sole fix conflates two separate problems.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** — pivot to text-free editorial scientific illustration with
|
||||
caption-as-HTML and template substitution.
|
||||
|
||||
This was shipped as v0.16.0 (2026-05-29, PR #146, commit d00e746). The changes
|
||||
are entirely in the prompt layer:
|
||||
|
||||
- `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` now asks for a concise
|
||||
topic/mechanism SCENE and keeps the CAPTION line for the HTML path.
|
||||
- `PRAutoBlogger_Image_Template_Filler::fill()` replaces the old
|
||||
`trim($scene . ' ' . $style_suffix)` concat at all three entry points
|
||||
(`build_article_prompt`, `build_source_prompt`, `build_fallback_prompt`).
|
||||
- New admin setting `prautoblogger_image_style_template` with
|
||||
single-token validation replaces the Style Suffix field.
|
||||
- One-time migration preserves the old suffix value for one cycle.
|
||||
|
||||
The deterministic PHP composer for OG/social branding (ARCHITECTURE.md §#21)
|
||||
was deliberately deferred to a follow-on commit (v0.17.0, #152) so that the
|
||||
prompt-only change could ship and be verified first.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Accepted benefits:**
|
||||
- Garbled-text image failures are eliminated on the default FLUX.1 schnell model.
|
||||
- The editorial scientific illustration style is consistent with the site's
|
||||
health-content positioning and sustainable across future model changes.
|
||||
- The template + token pattern makes style updates admin-configurable without
|
||||
code changes.
|
||||
|
||||
**Accepted costs:**
|
||||
- The comic aesthetic is gone and cannot be restored by toggling a setting;
|
||||
switching back requires a prompt and template change.
|
||||
- The `prautoblogger_image_style_suffix` option is deprecated; the migration
|
||||
runs once on upgrade and the old option is not deleted until a future cycle.
|
||||
|
||||
**Follow-up created:**
|
||||
- ARCHITECTURE.md §#21 (v0.17.0): deterministic PHP image composer to composite
|
||||
branded OG and square card variants — necessary because the social/OG image
|
||||
travels without the HTML caption and therefore needs text burned in by PHP,
|
||||
not by the diffusion model.
|
||||
@@ -1,26 +0,0 @@
|
||||
# PRAutoBlogger — App-Local ADR Log
|
||||
|
||||
This folder is the per-repo decision log for the **PRAutoBlogger** plugin.
|
||||
It records architectural choices that affect this codebase alone.
|
||||
|
||||
**Cross-cutting decisions** (spanning multiple plugins, shared infra, or
|
||||
CI/deploy standards) live centrally in
|
||||
`Peptide Repo CTO/docs/engineering/decisions/`.
|
||||
|
||||
For the routing rule, required sections, and status lifecycle, read the
|
||||
canonical process document:
|
||||
`Peptide Repo CTO/docs/engineering/ADR-PROCESS.md`
|
||||
|
||||
The canonical template for new ADRs is:
|
||||
`Peptide Repo CTO/docs/engineering/adr-template.md`
|
||||
|
||||
## Numbering
|
||||
|
||||
ADRs in this folder use a per-repo zero-padded sequence starting at `0001`.
|
||||
A new ADR always gets the next unused number; numbers are never reused.
|
||||
|
||||
## Index
|
||||
|
||||
| Number | Title | Status | Date |
|
||||
|--------|-------|--------|------|
|
||||
| [0001](0001-editorial-illustration-image-style.md) | Editorial illustration image style — pivot from comic to text-free illustration with caption-as-HTML | Accepted | 2026-05-29 |
|
||||
@@ -270,14 +270,84 @@ 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 ) {
|
||||
return PRAutoBlogger_Settings_Sanitizer::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 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,194 +35,282 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return array_merge(
|
||||
return array(
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
array(
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
),
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => array(
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => array(
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Board Settings ─────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_board_poll_interval',
|
||||
'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' ),
|
||||
),
|
||||
// ── 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' ),
|
||||
// ── 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' )
|
||||
),
|
||||
),
|
||||
PRAutoBlogger_Settings_Fields_Images::get_fields()
|
||||
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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Image settings fields for PRAutoBlogger.
|
||||
*
|
||||
* Declarative field definitions for the Images section of the settings page.
|
||||
* Extracted from PRAutoBlogger_Settings_Fields_Extended for 300-line compliance.
|
||||
*
|
||||
* What: Returns the images-section field array consumed by the settings registry.
|
||||
* Who triggers: PRAutoBlogger_Settings_Fields_Extended::get_fields() via merge.
|
||||
* Dependencies: PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
* PRAutoBlogger_Image_Prompt_Builder, PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* @see admin/class-settings-fields-extended.php -- Merges these fields in get_fields().
|
||||
* @see admin/class-admin-page.php -- Renders the fields.
|
||||
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
|
||||
*/
|
||||
class PRAutoBlogger_Settings_Fields_Images {
|
||||
|
||||
/**
|
||||
* Get image generation settings fields.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Settings field sanitizer for PRAutoBlogger.
|
||||
*
|
||||
* Centralises all option sanitization logic: encryption for API keys, JSON
|
||||
* encoding for multi-value fields, numeric coercion, image-model registry
|
||||
* validation. Extracted from PRAutoBlogger_Admin_Page for 300-line compliance.
|
||||
*
|
||||
* What: Static utility called by register_setting() sanitize_callback.
|
||||
* Who triggers: WordPress Settings API on option save; PRAutoBlogger_Admin_Page::sanitize_field().
|
||||
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Image_Model_Registry,
|
||||
* PRAutoBlogger_Image_Template_Filler.
|
||||
*
|
||||
* @see admin/class-admin-page.php -- Registers settings and delegates here.
|
||||
* @see admin/class-image-model-registry.php -- Validates image model slug on save.
|
||||
* @see CONVENTIONS.md -- "How To: Add a New Admin Setting".
|
||||
*/
|
||||
class PRAutoBlogger_Settings_Sanitizer {
|
||||
|
||||
/**
|
||||
* Sanitize a settings field value.
|
||||
*
|
||||
* @param mixed $value The submitted value.
|
||||
* @return mixed Sanitized value.
|
||||
*/
|
||||
public static 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 self::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 static 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 );
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,6 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PRAutoBlogger_WP_CLI_Commands {
|
||||
|
||||
/**
|
||||
* Max generate-tick iterations for the --sync loop (covers daily_article_target = 1
|
||||
* with ample headroom; prevents infinite loops if the queue never drains).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const SYNC_MAX_TICKS = 20;
|
||||
|
||||
/**
|
||||
* Register WP-CLI commands.
|
||||
*
|
||||
@@ -26,19 +18,12 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
return;
|
||||
}
|
||||
|
||||
// Command: wp prautoblogger opik:eval
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger generate',
|
||||
array( self::class, 'generate_command' ),
|
||||
array(
|
||||
'shortdesc' => 'Queue (or synchronously run) a new article generation run',
|
||||
'synopsis' => array(
|
||||
array(
|
||||
'type' => 'flag',
|
||||
'name' => 'sync',
|
||||
'description' => 'Run the full pipeline synchronously in this process (VPS orchestrator mode). Blocks until complete.',
|
||||
'optional' => true,
|
||||
),
|
||||
),
|
||||
'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints',
|
||||
)
|
||||
);
|
||||
|
||||
@@ -66,148 +51,6 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue or synchronously run a new article generation run.
|
||||
*
|
||||
* Without --sync: routes through kick_off() (same as the board "New Article"
|
||||
* button). Returns immediately; all pipeline work happens in cron.
|
||||
*
|
||||
* With --sync: runs the pipeline synchronously in this SSH/CLI process.
|
||||
* 1. Acquires the generation lock (skips if a live run is in progress).
|
||||
* 2. Calls on_orchestrate_tick() to collect/score ideas in-process.
|
||||
* 3. Loops calling on_generate_tick() until the queue is empty or the run
|
||||
* reaches a terminal state, up to SYNC_MAX_TICKS iterations.
|
||||
* 4. Emits a JSON summary line on stdout and exits 0 (success) or 1 (error).
|
||||
*
|
||||
* The --sync path suppresses wp-cron reschedule inside on_generate_tick() so
|
||||
* the external loop cannot race with a background cron tick.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--sync]
|
||||
* : Run synchronously (VPS orchestrator mode).
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp prautoblogger generate
|
||||
* wp prautoblogger generate --sync
|
||||
*
|
||||
* @param array $args Positional args (unused).
|
||||
* @param array $assoc_args Associative args: sync.
|
||||
* @return void
|
||||
*/
|
||||
public static function generate_command( array $args, array $assoc_args ): void {
|
||||
if ( isset( $assoc_args['sync'] ) ) {
|
||||
self::run_sync();
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full pipeline synchronously in this process (--sync mode).
|
||||
*
|
||||
* Called only from generate_command(); never from wp-cron or AJAX paths.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function run_sync(): void {
|
||||
// Honour ignore_user_abort so a dropped SSH session does not kill us.
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
|
||||
// Check for a live (non-expired) lock. Generation_Lock::acquire() already
|
||||
// cleans expired locks on its DELETE pass, so if acquire() returns false
|
||||
// there is a genuinely live concurrent run — skip cleanly.
|
||||
if ( ! PRAutoBlogger_Generation_Lock::is_locked() ) {
|
||||
// Lock is not held; proceed. acquire() is called inside on_orchestrate_tick().
|
||||
} else {
|
||||
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
|
||||
$age = null !== $acquired_at ? ( time() - $acquired_at ) : HOUR_IN_SECONDS;
|
||||
if ( $age < HOUR_IN_SECONDS ) {
|
||||
\WP_CLI::log(
|
||||
json_encode(
|
||||
array(
|
||||
'status' => 'skipped',
|
||||
'reason' => 'run_in_progress',
|
||||
'lock_age_s' => $age,
|
||||
)
|
||||
)
|
||||
);
|
||||
exit( 0 );
|
||||
}
|
||||
// Expired lock — on_orchestrate_tick()'s acquire() will clean it.
|
||||
}
|
||||
|
||||
// Enable sync-mode: on_generate_tick() will skip the wp-cron reschedule.
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
|
||||
|
||||
\WP_CLI::log( 'PRAB-SYNC: starting orchestrate tick' );
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
|
||||
// If the orchestrate tick failed to acquire the lock or found no ideas,
|
||||
// the RUN_ID option will be absent. Detect and exit cleanly.
|
||||
$run_id = (string) get_option( 'prautoblogger_checkpoint_run_id', '' );
|
||||
if ( '' === $run_id ) {
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||
\WP_CLI::log(
|
||||
json_encode(
|
||||
array(
|
||||
'status' => 'done',
|
||||
'generated' => 0,
|
||||
'reason' => 'no_ideas_or_lock_failed',
|
||||
)
|
||||
)
|
||||
);
|
||||
exit( 0 );
|
||||
}
|
||||
}
|
||||
|
||||
// Tick loop: drive generate ticks synchronously.
|
||||
$ticks = 0;
|
||||
while ( $ticks < self::SYNC_MAX_TICKS ) {
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||
break; // Queue drained or finalized.
|
||||
}
|
||||
++$ticks;
|
||||
\WP_CLI::log( sprintf( 'PRAB-SYNC: generate tick %d (ideas remaining: %d)', $ticks, count( $queue['ideas'] ) ) );
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
|
||||
|
||||
if ( $ticks >= self::SYNC_MAX_TICKS ) {
|
||||
\WP_CLI::warning( 'PRAB-SYNC: hit max-tick guard (' . self::SYNC_MAX_TICKS . '). Queue may not be fully drained.' );
|
||||
}
|
||||
|
||||
// Read final status transient for summary.
|
||||
$status = get_transient( 'prautoblogger_generation_status' );
|
||||
$result = is_array( $status ) ? $status : array();
|
||||
|
||||
$summary = array(
|
||||
'status' => 'done',
|
||||
'generated' => (int) ( $result['generated'] ?? 0 ),
|
||||
'published' => (int) ( $result['published'] ?? 0 ),
|
||||
'rejected' => (int) ( $result['rejected'] ?? 0 ),
|
||||
'cost_usd' => round( (float) ( $result['cost'] ?? 0.0 ), 4 ),
|
||||
'ticks' => $ticks,
|
||||
);
|
||||
|
||||
\WP_CLI::log( json_encode( $summary ) );
|
||||
|
||||
if ( 'error' === ( $result['status'] ?? '' ) ) {
|
||||
\WP_CLI::error( 'PRAB-SYNC: generation completed with error: ' . ( $result['message'] ?? 'unknown' ), false );
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
\WP_CLI::success( 'PRAB-SYNC: generation complete.' );
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run eval on the frozen dataset.
|
||||
*
|
||||
@@ -234,4 +77,27 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
|
||||
exit( $result['items_run'] > 0 ? 0 : 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a new article generation run on chained-cron checkpoints.
|
||||
*
|
||||
* Routes through PRAutoBlogger_Generation_Checkpoint_Runner::kick_off()
|
||||
* (same path as the board "New Article" button). Does NOT execute
|
||||
* synchronously -- check the board or Activity Log for progress.
|
||||
* This retires the `do_action("prautoblogger_manual_generation")` workaround.
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp prautoblogger generate
|
||||
*
|
||||
* @param array $args Positional args (unused).
|
||||
* @param array $assoc_args Associative args (unused).
|
||||
* @return void
|
||||
*/
|
||||
public static function generate_command( array $args, array $assoc_args ): void {
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,32 +125,5 @@ class PRAutoBlogger_DB_Migrations {
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_enc_prefix', '1' );
|
||||
}
|
||||
|
||||
// v0.22.1 self-heal: restore low-stakes maintenance cron events that were
|
||||
// lost when the WP-Cron schedule vanished after the 06-15 deploy cycle.
|
||||
// NOTE: wp-cron reliability on this Hostinger host is suspect (background
|
||||
// PHP processes are killed at ~10 min). These low-stakes events (metrics,
|
||||
// reaper, Runware sync) are acceptable on wp-cron; daily GENERATION is NOT
|
||||
// re-registered here — the VPS systemd timer owns generation scheduling.
|
||||
// The prautoblogger_migrated_schedule_tz_v082 guard is intentionally NOT
|
||||
// re-used here; this migration targets the missing events themselves.
|
||||
if ( ! get_option( 'prautoblogger_migrated_maintenance_crons_v0221' ) ) {
|
||||
// Re-register maintenance crons only — daily_generation deliberately excluded.
|
||||
// Activator::schedule_cron() uses wp_next_scheduled() guards so it is
|
||||
// idempotent and will not overwrite events that are already present.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
|
||||
$tomorrow_reap = strtotime( 'tomorrow 03:15' );
|
||||
if ( false !== $tomorrow_reap ) {
|
||||
wp_schedule_event( (int) $tomorrow_reap, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||
}
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
||||
}
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_maintenance_crons_v0221', '1' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,26 +52,6 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
|
||||
/** WP-Cron action for each article-generation tick. */
|
||||
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
|
||||
|
||||
/**
|
||||
* When true, on_generate_tick() skips wp-cron rescheduling so the VPS
|
||||
* orchestrator's external loop is the sole driver. Set via set_sync_mode().
|
||||
* Never true on the wp-cron path; reset to false after the sync run.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static bool $sync_mode = false;
|
||||
|
||||
/**
|
||||
* Enable or disable synchronous-driver mode.
|
||||
* Called by WP_CLI_Commands::run_sync() before and after the tick loop.
|
||||
*
|
||||
* @param bool $enabled True to suppress internal cron reschedule.
|
||||
* @return void
|
||||
*/
|
||||
public static function set_sync_mode( bool $enabled ): void {
|
||||
self::$sync_mode = $enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the orchestration tick and write the initial status transient.
|
||||
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
|
||||
@@ -175,15 +155,11 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
|
||||
)
|
||||
);
|
||||
|
||||
// In sync mode the VPS tick loop is the external driver; skip
|
||||
// wp-cron reschedule to prevent a competing background tick.
|
||||
// on_generate_tick() has the same guard for subsequent reschedules.
|
||||
if ( ! self::$sync_mode ) {
|
||||
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
@@ -269,14 +245,10 @@ class PRAutoBlogger_Generation_Checkpoint_Runner {
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
// In sync mode the VPS tick loop is the external driver; skip
|
||||
// wp-cron reschedule to prevent a competing background tick.
|
||||
if ( ! self::$sync_mode ) {
|
||||
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), self::GENERATE_ACTION );
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
} else {
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
|
||||
|
||||
@@ -11,10 +11,9 @@ declare(strict_types=1);
|
||||
* API is unavailable.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||
* Dependencies: Semantic_Dedup, Keyword_Extractor, WP_Query.
|
||||
* Dependencies: Semantic_Dedup, WP_Query.
|
||||
*
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-keyword-extractor.php — Keyword tokeniser (fallback dedup).
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-content-analyzer.php — Produces the analysis results we consume.
|
||||
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
||||
* @see ARCHITECTURE.md — Data flow step 3.
|
||||
@@ -68,7 +67,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
|
||||
if ( ! $this->skip_dedup ) {
|
||||
$topic = $result->get_topic();
|
||||
$topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic );
|
||||
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||
|
||||
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||
++$deduped_count;
|
||||
@@ -166,6 +165,133 @@ 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.
|
||||
*
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Shared keyword-extraction utility for the scoring and dedup pipeline.
|
||||
*
|
||||
* Extracts meaningful lowercase tokens from free text by stripping stop-words.
|
||||
* Used by both PRAutoBlogger_Idea_Scorer and PRAutoBlogger_Semantic_Dedup as the
|
||||
* fallback path when embedding similarity is unavailable.
|
||||
*
|
||||
* What: Static utility; no state.
|
||||
* Who triggers: PRAutoBlogger_Idea_Scorer, PRAutoBlogger_Semantic_Dedup.
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see core/class-idea-scorer.php -- Primary consumer (scoring pipeline).
|
||||
* @see core/class-semantic-dedup.php -- Secondary consumer (embedding fallback).
|
||||
*/
|
||||
class PRAutoBlogger_Keyword_Extractor {
|
||||
|
||||
/**
|
||||
* Common English stop-words filtered out before keyword matching.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private const 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',
|
||||
);
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from text.
|
||||
*
|
||||
* Lowercases input, splits on non-alphanumeric characters, filters
|
||||
* stop-words and single-character tokens, and de-duplicates.
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keyword tokens.
|
||||
*/
|
||||
public static function extract( string $text ): array {
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, self::STOPWORDS, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
}
|
||||
@@ -133,9 +133,7 @@ class PRAutoBlogger_Run_Reaper {
|
||||
* @param int $seconds Expected seconds (default 300).
|
||||
*/
|
||||
$expected = (int) apply_filters( 'prautoblogger_filter_stage_expected_seconds', self::EXPECTED_STAGE_SECONDS );
|
||||
// Use wp_date() so the cutoff is expressed in the site's local timezone,
|
||||
// matching the updated_at values written by current_time('mysql') (v0.22.1 TZ fix).
|
||||
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
$now = current_time( 'mysql' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
@@ -171,8 +169,7 @@ class PRAutoBlogger_Run_Reaper {
|
||||
* @param int $seconds Expected seconds (default 1800).
|
||||
*/
|
||||
$expected = (int) apply_filters( 'prautoblogger_filter_expected_run_seconds', self::EXPECTED_RUN_SECONDS );
|
||||
// Use wp_date() so the cutoff matches updated_at's local-timezone storage (v0.22.1 TZ fix).
|
||||
$cutoff = wp_date( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * $expected ) );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$stuck = $wpdb->get_col(
|
||||
|
||||
@@ -204,7 +204,7 @@ class PRAutoBlogger_Semantic_Dedup {
|
||||
return false;
|
||||
}
|
||||
foreach ( $this->recent_titles as $title ) {
|
||||
$title_keywords = PRAutoBlogger_Keyword_Extractor::extract( $title );
|
||||
$title_keywords = $this->extract_meaningful_keywords( $title );
|
||||
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
|
||||
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
||||
return true;
|
||||
@@ -241,4 +241,131 @@ 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 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Aspect-ratio snap utility for image generation providers.
|
||||
*
|
||||
* Converts pixel dimensions to the nearest supported W:H string for APIs
|
||||
* that require an aspect-ratio parameter rather than raw pixel sizes.
|
||||
*
|
||||
* What: Static utility; no state.
|
||||
* Who triggers: PRAutoBlogger_OpenRouter_Image_Provider, PRAutoBlogger_OpenRouter_Image_Batch,
|
||||
* PRAutoBlogger_Runware_Image_Batch.
|
||||
* Dependencies: none.
|
||||
*
|
||||
* @see providers/class-open-router-image-provider.php -- generate_image() path.
|
||||
* @see providers/class-open-router-image-batch.php -- parallel batch path.
|
||||
* @see providers/class-runware-image-batch.php -- Runware parallel batch.
|
||||
*/
|
||||
class PRAutoBlogger_Image_Aspect_Ratio {
|
||||
|
||||
/**
|
||||
* Standard aspect ratios supported by OpenRouter / Runware image models.
|
||||
*
|
||||
* @var array<int, array{w: int, h: int, ratio: float}>
|
||||
*/
|
||||
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'];
|
||||
}
|
||||
}
|
||||
@@ -262,7 +262,56 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
$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'];
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
|
||||
@@ -233,10 +233,51 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Supported aspect ratios for OpenRouter image models.
|
||||
*
|
||||
* @var array<int, array{w: int, h: int, ratio: float}>
|
||||
*/
|
||||
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 = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
$aspect = $this->snap_aspect_ratio( $width, $height );
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
@@ -255,6 +296,29 @@ 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.
|
||||
*
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
declare(strict_types=1);
|
||||
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent.
|
||||
// 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
|
||||
|
||||
/**
|
||||
* Parallel image generation via curl_multi for Runware.
|
||||
|
||||
@@ -178,14 +178,71 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance).
|
||||
*
|
||||
* @see providers/class-runware-image-validator.php
|
||||
* 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.
|
||||
*/
|
||||
public function validate_credentials_detailed(): array {
|
||||
return PRAutoBlogger_Runware_Validator::validate( $this->support );
|
||||
}
|
||||
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' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON task-array body for a single imageInference request.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Credential validator for the Runware image provider.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Runware_Image_Provider for 300-line compliance.
|
||||
* Sends a minimal authentication task to Runware's API to confirm the key is valid.
|
||||
*
|
||||
* What: Static utility; no state.
|
||||
* Who triggers: PRAutoBlogger_Runware_Image_Provider::validate_credentials_detailed().
|
||||
* Dependencies: PRAutoBlogger_Runware_Image_Support (API_URL constant, get_api_key()).
|
||||
*
|
||||
* @see providers/class-runware-image-provider.php -- Sole caller.
|
||||
* @see providers/class-runware-image-support.php -- API key retrieval + URL.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Validator {
|
||||
|
||||
/**
|
||||
* {@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.
|
||||
*/
|
||||
public static function validate( PRAutoBlogger_Runware_Image_Support $support ): array {
|
||||
try {
|
||||
$api_key = $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' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.22.1
|
||||
* Version: 0.21.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.22.1' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.21.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
|
||||
@@ -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 = array( 'tests', 'vendor', '.github', '.git', 'node_modules' );
|
||||
const CONSTANTS_EXCLUDE_DIRS = ['tests', 'vendor', '.github', '.git', 'node_modules'];
|
||||
|
||||
// Entry points for the reference scan (files and dirs relative to repo root).
|
||||
const CONSTANTS_SCAN_ROOTS = array(
|
||||
const CONSTANTS_SCAN_ROOTS = [
|
||||
'prautoblogger.php',
|
||||
'uninstall.php',
|
||||
'includes',
|
||||
'templates',
|
||||
);
|
||||
];
|
||||
|
||||
$verbose = in_array( '--verbose', $argv ?? array(), true );
|
||||
$self_test = in_array( '--self-test', $argv ?? array(), true );
|
||||
$verbose = in_array( '--verbose', $argv ?? [], true );
|
||||
$self_test = in_array( '--self-test', $argv ?? [], true );
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Build the defined set from prautoblogger.php only.
|
||||
@@ -67,7 +67,7 @@ preg_match_all(
|
||||
);
|
||||
|
||||
/** @var array<string, int> $defined_set constant name => 1 */
|
||||
$defined_set = array_flip( $def_matches[1] ?? array() );
|
||||
$defined_set = array_flip( $def_matches[1] ?? [] );
|
||||
|
||||
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 = array();
|
||||
$php_files = [];
|
||||
|
||||
foreach ( CONSTANTS_SCAN_ROOTS as $root ) {
|
||||
if ( ! file_exists( $root ) ) {
|
||||
@@ -133,11 +133,11 @@ if ( $verbose ) {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @var array<int, array{string, int, string, string}> $errors */
|
||||
$errors = array();
|
||||
$errors = [];
|
||||
|
||||
foreach ( $php_files as $php_path ) {
|
||||
$raw = file_get_contents( $php_path );
|
||||
if ( false === $raw ) {
|
||||
if ( $raw === false ) {
|
||||
fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" );
|
||||
continue;
|
||||
}
|
||||
@@ -188,7 +188,7 @@ foreach ( $php_files as $php_path ) {
|
||||
}
|
||||
|
||||
if ( ! $guarded ) {
|
||||
$errors[] = array( $php_path, $idx + 1, $name, $raw_line );
|
||||
$errors[] = [ $php_path, $idx + 1, $name, $raw_line ];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ foreach ( $php_files as $php_path ) {
|
||||
// 4. Report results.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if ( array() !== $errors ) {
|
||||
if ( $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, "<?php\necho {$sentinel};\n" );
|
||||
$tmp_errors = array();
|
||||
$tmp_errors = [];
|
||||
$tmp_lines = explode( "\n", (string) file_get_contents( $tmp_file ) );
|
||||
foreach ( $tmp_lines as $idx => $raw_line ) {
|
||||
if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) {
|
||||
|
||||
@@ -72,11 +72,10 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||
<tbody>
|
||||
<?php foreach ( $rows as $row ) : ?>
|
||||
<?php
|
||||
$decoded = json_decode( $row['metadata_json'] ?? '{}', true );
|
||||
$meta = is_array( $decoded ) ? $decoded : array();
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||
$suggested = $meta['suggested_title'] ?? '';
|
||||
$key_points = $meta['key_points'] ?? array();
|
||||
$keywords = $meta['target_keywords'] ?? array();
|
||||
$key_points = $meta['key_points'] ?? [];
|
||||
$keywords = $meta['target_keywords'] ?? [];
|
||||
$score_pct = round( (float) $row['relevance_score'] * 100 );
|
||||
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
|
||||
$type_class = 'prab-type-' . sanitize_html_class( $row['analysis_type'] );
|
||||
@@ -165,18 +164,14 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||
<?php if ( $total_pages > 1 ) : ?>
|
||||
<div class="prautoblogger-pagination" style="margin-top: 12px;">
|
||||
<?php
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -18,13 +18,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
$base_url = admin_url( 'admin.php?page=prautoblogger-logs' );
|
||||
$level_counts = array(
|
||||
$level_counts = [
|
||||
'all' => __( 'All', 'prautoblogger' ),
|
||||
'error' => __( 'Errors', 'prautoblogger' ),
|
||||
'warning' => __( 'Warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug', 'prautoblogger' ),
|
||||
);
|
||||
];
|
||||
?>
|
||||
<div class="wrap ab-wrap">
|
||||
<div class="ab-header">
|
||||
@@ -46,20 +46,7 @@ $level_counts = array(
|
||||
<div class="ab-log-filters">
|
||||
<div class="ab-log-level-tabs">
|
||||
<?php foreach ( $level_counts as $lv => $lv_label ) : ?>
|
||||
<a href="
|
||||
<?php
|
||||
echo esc_url(
|
||||
add_query_arg(
|
||||
array(
|
||||
'level' => $lv,
|
||||
's' => $search,
|
||||
'paged' => 1,
|
||||
),
|
||||
$base_url
|
||||
)
|
||||
);
|
||||
?>
|
||||
"
|
||||
<a href="<?php echo esc_url( add_query_arg( [ 'level' => $lv, 's' => $search, 'paged' => 1 ], $base_url ) ); ?>"
|
||||
class="ab-log-level-tab <?php echo $level === $lv ? 'ab-log-level-active' : ''; ?>">
|
||||
<?php echo esc_html( $lv_label ); ?>
|
||||
</a>
|
||||
@@ -132,18 +119,14 @@ $level_counts = array(
|
||||
<?php if ( $total_pages > 1 ) : ?>
|
||||
<div class="ab-log-pagination">
|
||||
<?php
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$meta = array(
|
||||
$meta = [
|
||||
'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 = array(
|
||||
'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 ),
|
||||
);
|
||||
];
|
||||
?>
|
||||
<div class="prautoblogger-metabox">
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Generated:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['generated_at'] ? $meta['generated_at'] : __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
<?php echo esc_html( $meta['generated_at'] ?: __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Topic:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['topic'] ? $meta['topic'] : '—' ); ?>
|
||||
<?php echo esc_html( $meta['topic'] ?: '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Type:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ? $meta['type'] : 'article' ) ); ?>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ?: 'article' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Model:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['model'] ? $meta['model'] : '—' ); ?>
|
||||
<?php echo esc_html( $meta['model'] ?: '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Pipeline:', 'prautoblogger' ); ?></strong>
|
||||
@@ -47,8 +47,8 @@ $meta = array(
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Editor Verdict:', 'prautoblogger' ); ?></strong>
|
||||
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ? $meta['verdict'] : 'unknown' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $meta['verdict'] ? $meta['verdict'] : __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ?: 'unknown' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $meta['verdict'] ?: __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||
</span>
|
||||
</p>
|
||||
<?php if ( $meta['quality_score'] ) : ?>
|
||||
@@ -69,7 +69,7 @@ $meta = array(
|
||||
<?php if ( $meta['keywords'] ) : ?>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Keywords:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( implode( ', ', ( json_decode( $meta['keywords'], true ) ? json_decode( $meta['keywords'], true ) : array() ) ) ); ?>
|
||||
<?php echo esc_html( implode( ', ', json_decode( $meta['keywords'], true ) ?: [] ) ); ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -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 ? $verdict : 'pending' );
|
||||
$verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ?: 'pending' );
|
||||
?>
|
||||
<tr data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
|
||||
<td class="check-column">
|
||||
@@ -158,9 +158,9 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
|
||||
<div class="prautoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo esc_html( $topic ? $topic : '—' ); ?></td>
|
||||
<td><?php echo esc_html( ucfirst( $article_type ? $article_type : 'article' ) ); ?></td>
|
||||
<td><span class="<?php echo esc_attr( $verdict_class ); ?>"><?php echo esc_html( ucfirst( $verdict ? $verdict : 'pending' ) ); ?></span></td>
|
||||
<td><?php echo esc_html( $topic ?: '—' ); ?></td>
|
||||
<td><?php echo esc_html( ucfirst( $article_type ?: 'article' ) ); ?></td>
|
||||
<td><span class="<?php echo esc_attr( $verdict_class ); ?>"><?php echo esc_html( ucfirst( $verdict ?: 'pending' ) ); ?></span></td>
|
||||
<td><?php echo '' !== $quality_score ? esc_html( $quality_score . '/10' ) : '—'; ?></td>
|
||||
<td><?php echo $generated_at ? esc_html( wp_date( 'M j, g:i A', strtotime( $generated_at ) ) ) : '—'; ?></td>
|
||||
<td class="prautoblogger-queue-actions">
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Settings_Fields_Extended.
|
||||
*
|
||||
* Pins the cardinality contract on get_fields() so any future split that
|
||||
* accidentally re-introduces a + array union (dropping keys) fails CI
|
||||
* rather than silently at runtime.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class SettingsFieldsExtendedTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Stub WP functions that the settings-field classes invoke.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
// Image_Model_Registry checks option cache; return false to use hardcoded list.
|
||||
\Brain\Monkey\Functions\when( 'get_option' )->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 +'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Sync-mode guard tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.22.1).
|
||||
*
|
||||
* Isolated from GenerationCheckpointRunnerTest to keep both files under 300 lines.
|
||||
*
|
||||
* Contract under test:
|
||||
* set_sync_mode(true) -> on_orchestrate_tick() must NOT schedule GENERATE_ACTION
|
||||
* or call spawn_cron/fire_cron_now (VPS loop drives ticks directly).
|
||||
* NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the guard
|
||||
* at line ~181, not the empty-ideas early return at ~148.
|
||||
* set_sync_mode(true) -> on_generate_tick() with ideas remaining must NOT schedule
|
||||
* GENERATE_ACTION or call spawn_cron/fire_cron_now.
|
||||
* set_sync_mode(false) -> on_generate_tick() with ideas remaining DOES schedule
|
||||
* GENERATE_ACTION (regression guard -- async path unaffected by sync guard).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class GenerationCheckpointRunnerSyncModeTest extends BaseTestCase {
|
||||
|
||||
/** @var string[] Cron hooks captured from wp_schedule_single_event(). */
|
||||
private array $scheduled = array();
|
||||
|
||||
/** @var array<string, mixed> Option writes captured from update_option(). */
|
||||
private array $options = array();
|
||||
|
||||
/** @var bool Tracks whether spawn_cron was called. */
|
||||
private bool $spawn_called = false;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->scheduled = array();
|
||||
$this->options = array();
|
||||
$this->spawn_called = false;
|
||||
|
||||
Functions\when( 'wp_schedule_single_event' )->alias(
|
||||
function ( $ts, $hook, $args = array() ) {
|
||||
$this->scheduled[] = $hook;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_next_scheduled' )->justReturn( false );
|
||||
Functions\when( 'spawn_cron' )->alias( function () {
|
||||
$this->spawn_called = true;
|
||||
return true;
|
||||
} );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
||||
|
||||
Functions\when( 'set_transient' )->justReturn( true );
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $name, $value, $autoload = null ) {
|
||||
$this->options[ $name ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'delete_option' )->alias(
|
||||
function ( $name ) {
|
||||
unset( $this->options[ $name ] );
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $name, $default = false ) {
|
||||
return $this->options[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
Functions\when( 'wp_generate_uuid4' )->justReturn( 'sync-test-uuid-001' );
|
||||
$this->options['prautoblogger_monthly_budget_usd'] = '0';
|
||||
Functions\when( 'wp_cache_get' )->justReturn( false );
|
||||
Functions\when( 'wp_cache_set' )->justReturn( true );
|
||||
Functions\when( 'wp_cache_delete' )->justReturn( true );
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/** Stub the generation lock to always succeed. */
|
||||
private function wire_wpdb_for_lock(): void {
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
}
|
||||
|
||||
/** Build a minimal Article_Idea-compatible array. */
|
||||
private function make_idea( string $topic = 'Sync Topic' ): array {
|
||||
return array(
|
||||
'topic' => $topic,
|
||||
'article_type' => 'guide',
|
||||
'suggested_title' => 'Guide to ' . $topic,
|
||||
'summary' => '',
|
||||
'score' => 0.9,
|
||||
'analysis_id' => 1,
|
||||
'source_ids' => array(),
|
||||
'key_points' => array(),
|
||||
'target_keywords' => array(),
|
||||
);
|
||||
}
|
||||
|
||||
// __ sync-mode guard: on_orchestrate_tick() ______________________________________________
|
||||
|
||||
/**
|
||||
* sync_mode=true + >=1 idea returned: on_orchestrate_tick() must not schedule
|
||||
* GENERATE_ACTION or call spawn_cron (VPS loop drives generate ticks directly).
|
||||
*
|
||||
* NON-VACUOUS: orchestrate_only() returns 1 idea so execution reaches the sync
|
||||
* guard at line ~181, not the empty-ideas early return at ~148.
|
||||
*/
|
||||
public function test_orchestrate_tick_in_sync_mode_does_not_schedule_background_event(): void {
|
||||
$this->wire_wpdb_for_lock();
|
||||
|
||||
// Provide Pipeline_Runner stub that returns 1 idea -- forces execution past
|
||||
// the empty-ideas early return so the sync guard at line ~181 is exercised.
|
||||
if ( ! class_exists( 'PRAutoBlogger_Pipeline_Runner', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Pipeline_Runner {
|
||||
public function set_skip_dedup($v){ return $this; }
|
||||
public function orchestrate_only($ct){
|
||||
$idea = new PRAutoBlogger_Article_Idea([
|
||||
"topic"=>"Sync Topic","article_type"=>"guide",
|
||||
"suggested_title"=>"Guide","summary"=>"","score"=>0.9,
|
||||
"analysis_id"=>1,"source_ids"=>[],"key_points"=>[],
|
||||
"target_keywords"=>[]
|
||||
]);
|
||||
return [$idea];
|
||||
}
|
||||
}' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'sync mode: on_orchestrate_tick must not schedule GENERATE_ACTION'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$this->spawn_called,
|
||||
'sync mode: on_orchestrate_tick must not call spawn_cron'
|
||||
);
|
||||
}
|
||||
|
||||
// __ sync-mode guard: on_generate_tick() _________________________________________________
|
||||
|
||||
/**
|
||||
* sync_mode=true + 2 ideas in queue: on_generate_tick() must not reschedule
|
||||
* GENERATE_ACTION or call spawn_cron (VPS loop is the sole driver of next tick).
|
||||
*
|
||||
* Uses 2 ideas so the code stays in the "more ideas remain" branch -- this
|
||||
* path calls Article_Worker->generate() and then the sync guard, never
|
||||
* Post_Assembler::amortize_research_costs() (that is in the last-idea branch).
|
||||
* Article_Worker is stubbed; Post_Assembler is not needed and must NOT be
|
||||
* redefined here to avoid clobbering the real class for downstream tests.
|
||||
*/
|
||||
public function test_generate_tick_sync_mode_suppresses_reschedule(): void {
|
||||
$run_id = 'sync-gen-tick-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
// Stub Article_Worker only -- Post_Assembler is NOT stubbed here because
|
||||
// amortize_research_costs() is not called on the "more ideas remain" path,
|
||||
// and redefining it with an incomplete stub breaks Publisher tests that run
|
||||
// after this file (alphabetical: SyncModeTest < Test).
|
||||
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Article_Worker {
|
||||
public function __construct($ct){}
|
||||
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
|
||||
}' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( true );
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'sync mode: on_generate_tick must not schedule GENERATE_ACTION'
|
||||
);
|
||||
$this->assertFalse(
|
||||
$this->spawn_called,
|
||||
'sync mode: on_generate_tick must not call spawn_cron'
|
||||
);
|
||||
}
|
||||
|
||||
// __ async regression guard ______________________________________________________________
|
||||
|
||||
/**
|
||||
* sync_mode=false: on_generate_tick() with ideas remaining DOES schedule
|
||||
* GENERATE_ACTION (regression guard -- async path must be unaffected by sync guard).
|
||||
*/
|
||||
public function test_generate_tick_async_mode_schedules_generate_action(): void {
|
||||
$run_id = 'async-regression-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea( 'A' ), $this->make_idea( 'B' ) ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
// Article_Worker stub only -- see note in test_generate_tick_sync_mode_suppresses_reschedule.
|
||||
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Article_Worker {
|
||||
public function __construct($ct){}
|
||||
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
|
||||
}' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::set_sync_mode( false );
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'async mode: on_generate_tick must schedule GENERATE_ACTION when ideas remain'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,6 @@
|
||||
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
|
||||
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
|
||||
*
|
||||
* Sync-mode guard tests live in GenerationCheckpointRunnerSyncModeTest.php
|
||||
* (split to comply with the 300-line rule).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
@@ -107,7 +104,7 @@ class GenerationCheckpointRunnerTest extends BaseTestCase {
|
||||
);
|
||||
}
|
||||
|
||||
// __ kick_off() __________________________________________________________________________
|
||||
// ── kick_off() ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* kick_off() schedules exactly the ORCHESTRATE cron action and writes
|
||||
@@ -132,7 +129,7 @@ class GenerationCheckpointRunnerTest extends BaseTestCase {
|
||||
$this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
|
||||
}
|
||||
|
||||
// __ on_generate_tick() __________________________________________________________________
|
||||
// ── on_generate_tick() ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* An empty queue causes generate tick to finalize without scheduling
|
||||
|
||||
@@ -46,13 +46,6 @@ class RunReaperTest extends BaseTestCase {
|
||||
}
|
||||
);
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
// wp_date() is called by the reaper's stuck-sweep cutoff (v0.22.1 TZ fix).
|
||||
// Alias to gmdate() so tests remain timezone-agnostic.
|
||||
Functions\when( 'wp_date' )->alias(
|
||||
static function ( string $format, ?int $timestamp = null ): string {
|
||||
return gmdate( $format, null !== $timestamp ? $timestamp : time() );
|
||||
}
|
||||
);
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user