Compare commits
4 Commits
claude/pha
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f95f709e | ||
|
|
18cd342634 | ||
|
|
110c681c57 | ||
|
|
d770e8a86a |
@@ -35,25 +35,33 @@ add_action( 'rest_api_init', 'prautoblogger_deploy_register_route' );
|
||||
* Register the deploy REST route.
|
||||
*/
|
||||
function prautoblogger_deploy_register_route(): void {
|
||||
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
|
||||
'methods' => 'POST',
|
||||
'callback' => 'prautoblogger_deploy_handle',
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
] );
|
||||
register_rest_route(
|
||||
'prautoblogger-deploy/v1',
|
||||
'/deploy',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => 'prautoblogger_deploy_handle',
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
)
|
||||
);
|
||||
|
||||
// Health check endpoint (GET) — useful for monitoring.
|
||||
register_rest_route( 'prautoblogger-deploy/v1', '/status', [
|
||||
'methods' => 'GET',
|
||||
'callback' => function () {
|
||||
return [
|
||||
'status' => 'ready',
|
||||
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
||||
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
||||
'version' => prautoblogger_deploy_get_installed_version(),
|
||||
];
|
||||
},
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
] );
|
||||
register_rest_route(
|
||||
'prautoblogger-deploy/v1',
|
||||
'/status',
|
||||
array(
|
||||
'methods' => 'GET',
|
||||
'callback' => function () {
|
||||
return array(
|
||||
'status' => 'ready',
|
||||
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
||||
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
||||
'version' => prautoblogger_deploy_get_installed_version(),
|
||||
);
|
||||
},
|
||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +86,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
return new WP_Error(
|
||||
'rate_limited',
|
||||
'Deploy in progress or recently completed. Wait 60 seconds.',
|
||||
[ 'status' => 429 ]
|
||||
array( 'status' => 429 )
|
||||
);
|
||||
}
|
||||
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
||||
@@ -87,7 +95,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
$files = $request->get_file_params();
|
||||
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', [ 'status' => 400 ] );
|
||||
return new WP_Error( 'no_file', 'No plugin zip uploaded.', array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$tmp_file = $files['plugin']['tmp_name'];
|
||||
@@ -96,11 +104,11 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
$zip = new ZipArchive();
|
||||
if ( true !== $zip->open( $tmp_file ) ) {
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', [ 'status' => 400 ] );
|
||||
return new WP_Error( 'invalid_zip', 'Uploaded file is not a valid zip.', array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$has_main_file = false;
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
|
||||
for ( $i = 0; $i < $zip->numFiles; $i++ ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
|
||||
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
||||
$has_main_file = true;
|
||||
break;
|
||||
@@ -113,7 +121,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
return new WP_Error(
|
||||
'invalid_plugin',
|
||||
'Zip must contain prautoblogger/prautoblogger.php at root.',
|
||||
[ 'status' => 400 ]
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +151,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
activate_plugin( 'prautoblogger/prautoblogger.php' );
|
||||
}
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', [ 'status' => 500 ] );
|
||||
return new WP_Error( 'backup_failed', 'Could not backup current plugin.', array( 'status' => 500 ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +169,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
}
|
||||
delete_transient( 'prautoblogger_deploy_lock' );
|
||||
error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' );
|
||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', [ 'status' => 500 ] );
|
||||
return new WP_Error( 'extract_failed', 'Zip extraction failed. Rolled back.', array( 'status' => 500 ) );
|
||||
}
|
||||
|
||||
// 7. Activate plugin.
|
||||
@@ -178,7 +186,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
return new WP_Error(
|
||||
'activation_failed',
|
||||
'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.',
|
||||
[ 'status' => 500 ]
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,18 +196,23 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
||||
}
|
||||
|
||||
$old_version = prautoblogger_deploy_get_installed_version();
|
||||
error_log( sprintf(
|
||||
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
||||
$incoming_version,
|
||||
$old_version ?: 'fresh install',
|
||||
gmdate( 'Y-m-d H:i:s' )
|
||||
) );
|
||||
error_log(
|
||||
sprintf(
|
||||
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
||||
$incoming_version,
|
||||
$old_version ? $old_version : 'fresh install',
|
||||
gmdate( 'Y-m-d H:i:s' )
|
||||
)
|
||||
);
|
||||
|
||||
return new WP_REST_Response( [
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
], 200 );
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'success' => true,
|
||||
'version' => $incoming_version,
|
||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||
),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,7 +223,7 @@ function prautoblogger_deploy_get_installed_version(): string {
|
||||
if ( ! file_exists( $main_file ) ) {
|
||||
return '';
|
||||
}
|
||||
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
|
||||
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
|
||||
return $data['Version'] ?? '';
|
||||
}
|
||||
|
||||
|
||||
131
.github/workflows/ci.yml
vendored
131
.github/workflows/ci.yml
vendored
@@ -1,114 +1,34 @@
|
||||
name: CI — Lint & Test
|
||||
|
||||
# Runs on every PR and every push to main. Validates PHPUnit tests + lint +
|
||||
# coding standards before deploy can happen. deploy.yml on main continues to
|
||||
# run its own validate job as a belt-and-suspenders check, but this CI workflow
|
||||
# is what gives PRs an immediate signal so fixes happen before merge.
|
||||
##
|
||||
# CI caller — PRAutoBlogger
|
||||
#
|
||||
# Thin caller that delegates to the reusable estate CI workflow in peptide-e2e.
|
||||
# workflow_call is required so deploy.yml can gate on this workflow via
|
||||
# `uses: ./.github/workflows/ci.yml` (the workflow_call trigger enables that).
|
||||
#
|
||||
# Permissions: contents: write is required at the caller level so the reusable
|
||||
# workflow's phpcbf auto-commit job can push fixes back to the PR branch.
|
||||
# (GitHub enforces that callers cannot be escalated beyond their declared scope.)
|
||||
#
|
||||
# tests: brain-monkey — no WP install or DB required; Brain Monkey mocking only.
|
||||
# line_limit_paths: includes — estate hard 300-line rule applied to includes/.
|
||||
##
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
php-lint:
|
||||
name: PHP Lint (${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
- name: Syntax check all PHP files
|
||||
# Exclude .github/mu-plugins — those run on the production server (PHP 8+)
|
||||
# and may use PHP 8+ syntax (union types etc.) that older versions reject.
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
|
||||
phpcs:
|
||||
name: PHPCS (WordPress standards)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false # strict after PHPCS cleanup in v0.8.3
|
||||
permissions:
|
||||
contents: write # allows phpcbf auto-fix commits back to the PR branch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
tools: composer:v2, phpcs
|
||||
- name: Install WordPress Coding Standards
|
||||
run: |
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
composer global require --no-interaction \
|
||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||
"wp-coding-standards/wpcs:^3.0" \
|
||||
"phpcsstandards/phpcsutils:^1.0" \
|
||||
"phpcsstandards/phpcsextra:^1.0"
|
||||
- name: Auto-fix with phpcbf (commit back to branch if changed)
|
||||
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
|
||||
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
|
||||
run: |
|
||||
phpcbf --standard=phpcs.xml \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
includes/ prautoblogger.php uninstall.php || true
|
||||
if ! git diff --quiet; then
|
||||
git config user.name "peptiderepo"
|
||||
git config user.email "peptiderepo@users.noreply.github.com"
|
||||
git add -A
|
||||
git commit -m "style: auto-fix PHPCS violations via phpcbf"
|
||||
git push
|
||||
fi
|
||||
- name: Run PHPCS (only non-auto-fixable violations can fail here)
|
||||
run: |
|
||||
phpcs --standard=phpcs.xml \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=full \
|
||||
includes/ prautoblogger.php uninstall.php
|
||||
|
||||
phpunit:
|
||||
name: PHPUnit (PHP ${{ matrix.php }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup PHP ${{ matrix.php }}
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
coverage: none
|
||||
tools: composer:v2
|
||||
- name: Install composer dependencies
|
||||
run: composer install --no-progress --no-interaction --prefer-dist
|
||||
- name: Run PHPUnit (Brain Monkey — no WP install required)
|
||||
run: vendor/bin/phpunit --testdox
|
||||
ci:
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/ci.yml@main
|
||||
with:
|
||||
tests: brain-monkey
|
||||
secrets: inherit
|
||||
|
||||
check-constants:
|
||||
name: Check PRAUTOBLOGGER_* constants
|
||||
@@ -126,3 +46,8 @@ jobs:
|
||||
# by defined() at the reference site. Prevents the v0.19.0 incident
|
||||
# class (constant deleted from bootstrap, test fallback masks the gap).
|
||||
run: php scripts/check-constants.php
|
||||
- name: Self-test check-constants scanner
|
||||
# Verifies the scanner itself can detect the failure cases it was
|
||||
# designed to catch. If the scanner regresses, this step fails CI.
|
||||
# Added v0.21.0; restored in v0.22.0 (was accidentally dropped in rewrite).
|
||||
run: php scripts/check-constants.php --self-test
|
||||
|
||||
28
.github/workflows/deploy.yml
vendored
28
.github/workflows/deploy.yml
vendored
@@ -9,29 +9,17 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: PHP Lint Gate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
coverage: none
|
||||
- name: PHP Lint Check
|
||||
run: |
|
||||
set -e
|
||||
find . -name '*.php' \
|
||||
-not -path './vendor/*' \
|
||||
-not -path './node_modules/*' \
|
||||
-not -path './.git/*' \
|
||||
-not -path './.github/*' \
|
||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
||||
ci:
|
||||
name: CI Gate (reusable)
|
||||
uses: ./.github/workflows/ci.yml
|
||||
permissions:
|
||||
contents: write
|
||||
secrets: inherit
|
||||
|
||||
deploy:
|
||||
name: Deploy (shared pipeline)
|
||||
needs: validate
|
||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
needs: ci
|
||||
uses: peptide-repo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||
with:
|
||||
target_path: wp-content/plugins/prautoblogger
|
||||
secrets: inherit
|
||||
|
||||
@@ -1084,3 +1084,65 @@ PRAutoBlogger and Peptide News both call OpenRouter and may share a single API k
|
||||
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
|
||||
|
||||
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.
|
||||
|
||||
## §25 Chained-Cron Checkpoint Generation Path (v0.21.0, M4)
|
||||
|
||||
### Problem
|
||||
|
||||
Hostinger shared hosting kills PHP background processes after ~120 s. The former
|
||||
`on_manual_generation()` path in `Executor` ran the complete orchestrate +
|
||||
generate pipeline in a single PHP process — meaning a host kill mid-run left the
|
||||
generation lock held, the run row stuck in `running`, and partially-generated
|
||||
articles orphaned with no status broadcast.
|
||||
|
||||
### Solution: Two-Tick Checkpoint Machine
|
||||
|
||||
`PRAutoBlogger_Generation_Checkpoint_Runner` (v0.21.0) splits the pipeline into
|
||||
independent cron ticks, each of which completes well within Hostinger's limit:
|
||||
|
||||
| Tick | WP-Cron action | What happens |
|
||||
|------|---------------|-------------|
|
||||
| 1 | `prautoblogger_gen_orchestrate` | Collect sources → analyze → score ideas. Serialize ideas to `prautoblogger_article_queue` option. Schedule Tick 2. |
|
||||
| 2..N | `prautoblogger_gen_tick` | Pop ONE idea from queue. Call `Article_Worker::generate()`. Persist updated queue. Reschedule next Tick, or finalize. |
|
||||
|
||||
If the host kills any tick:
|
||||
- The idea queue is persisted (option survived the crash).
|
||||
- The next CRON run picks up naturally (no orphan).
|
||||
- `Run_Reaper` reclaims truly stuck runs via its daily sweep.
|
||||
|
||||
### Entry Points
|
||||
|
||||
| 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 | `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
|
||||
|
||||
The former `wp eval 'do_action("prautoblogger_manual_generation")'` workaround
|
||||
fired `on_manual_generation()` synchronously in the CLI PHP process, bypassing:
|
||||
- Nonce/capability checks (running as CLI user, not WP user)
|
||||
- Cron-based loopback spawning
|
||||
- Per-process time limits
|
||||
|
||||
This path is retired. `Executor::on_manual_generation()` now delegates immediately
|
||||
to `Generation_Checkpoint_Runner::on_orchestrate_tick()` (the Tick 1 handler),
|
||||
which acquires the lock and runs orchestration within its own process. The
|
||||
`wp prautoblogger generate` WP-CLI command provides the supported CLI path.
|
||||
|
||||
### Key State
|
||||
|
||||
| Store | Key | Contents |
|
||||
|-------|-----|---------|
|
||||
| DB option | `prautoblogger_article_queue` | `{run_id, ideas[], results{}}` — persisted across ticks |
|
||||
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
|
||||
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
|
||||
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
|
||||
|
||||
### Budget / Halt Contract
|
||||
|
||||
`Cost_Tracker::is_budget_exceeded()` is checked at the START of Tick 1 and each
|
||||
generate tick. A halted run (`Run_State::get_status() = 'halted'`) causes the
|
||||
generate tick to abort without calling `Article_Worker`, clean up the queue, and
|
||||
release the lock — same guarantee as `Pipeline_Runner`'s queued-article path.
|
||||
|
||||
94
CHANGELOG.md
94
CHANGELOG.md
@@ -10,6 +10,100 @@ and this project uses [Semantic Versioning](https://semver.org/).
|
||||
### Internal
|
||||
- CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class.
|
||||
|
||||
## [0.22.0] - 2026-06-16
|
||||
|
||||
### Changed
|
||||
- **CI adopted estate standard (reusable workflow)**: `ci.yml` is now a thin
|
||||
caller of `peptiderepo/peptide-e2e/.github/workflows/ci.yml@main` with
|
||||
`tests: brain-monkey`. Removes duplicate per-repo lint, PHPCS, and PHPUnit
|
||||
jobs (now provided by the reusable workflow). The phpcbf auto-fix commit-back
|
||||
pattern (prautoblogger gold standard) is now the reusable implementation.
|
||||
- **Deploy gate upgraded**: `deploy.yml` no longer runs a standalone PHP-lint
|
||||
validate job; it calls `./.github/workflows/ci.yml` (which includes the full
|
||||
reusable suite) via `workflow_call`. This closes the gap where deploy's lint
|
||||
gate was weaker than CI's PHPCS gate.
|
||||
- **Constants guard retained**: `check-constants` job kept as a second parallel
|
||||
job in `ci.yml` alongside the reusable `ci` job. No regression from the
|
||||
v0.19.0 protection.
|
||||
- **`composer.json` test script added**: `composer test` now runs
|
||||
`vendor/bin/phpunit --testdox` (required by the reusable workflow's phpunit job).
|
||||
|
||||
### Refactored (300-line rule compliance)
|
||||
- `PRAutoBlogger_Keyword_Extractor` (new, `core/class-keyword-extractor.php`):
|
||||
shared keyword tokeniser extracted from `Idea_Scorer` and `Semantic_Dedup`;
|
||||
both now delegate to `PRAutoBlogger_Keyword_Extractor::extract()`.
|
||||
- `PRAutoBlogger_Image_Aspect_Ratio` (new, `providers/class-image-aspect-ratio.php`):
|
||||
aspect-ratio snap utility extracted from `OpenRouter_Image_Provider` and
|
||||
`OpenRouter_Image_Batch`; both now call `PRAutoBlogger_Image_Aspect_Ratio::snap()`.
|
||||
- `PRAutoBlogger_Settings_Sanitizer` (new, `admin/class-settings-sanitizer.php`):
|
||||
all option sanitization logic extracted from `Admin_Page::sanitize_field()`;
|
||||
`Admin_Page` delegates via `PRAutoBlogger_Settings_Sanitizer::sanitize_field()`.
|
||||
- `PRAutoBlogger_Settings_Fields_Images` (new, `admin/class-settings-fields-images.php`):
|
||||
image-section field definitions extracted from `Settings_Fields_Extended`;
|
||||
`Settings_Fields_Extended::get_fields()` merges via array union.
|
||||
- `PRAutoBlogger_Runware_Validator` (new, `providers/class-runware-image-validator.php`):
|
||||
`validate_credentials_detailed()` extracted from `Runware_Image_Provider`;
|
||||
provider delegates via `PRAutoBlogger_Runware_Validator::validate()`.
|
||||
- `class-runware-image-batch.php`: 15 individual `phpcs:disable` lines collapsed
|
||||
to a single grouped suppression (equivalent effect, saves 14 lines).
|
||||
- `class-open-router-image-provider.php`: `snap_aspect_ratio` private method and
|
||||
`STANDARD_ASPECTS` constant removed (now handled by `Image_Aspect_Ratio`).
|
||||
|
||||
## [0.21.0] - 2026-06-14
|
||||
|
||||
### Added
|
||||
- **Board âNew Articleâ button (Phase 2 admin M4)**. Manual generation is now
|
||||
accessible from the kanban board (not just the Settings page). The button fires
|
||||
the existing `prautoblogger_generate_now` AJAX action; the board status area
|
||||
shows generation progress via the existing transient poller. Requires
|
||||
`manage_options` capability.
|
||||
- **Chained-cron checkpoint machine** (`PRAutoBlogger_Generation_Checkpoint_Runner`).
|
||||
Replaces the monolithic `on_manual_generation()` synchronous path with a
|
||||
two-tick checkpoint machine so Hostingerâs PHP-process kill cannot abort a
|
||||
mid-pipeline run:
|
||||
- Tickâ1 (`prautoblogger_gen_orchestrate`): collect â analyze â score ideas,
|
||||
persist to `prautoblogger_article_queue` option, schedule Tick 2.
|
||||
- Tickâ2â¦N (`prautoblogger_gen_tick`): pop ONE idea, generate one article,
|
||||
reschedule or finalize. Persists consumed queue BEFORE generating to prevent
|
||||
orphan-recovery duplication on restart.
|
||||
- Budget / halt: `Cost_Tracker::is_budget_exceeded()` checked per tick;
|
||||
a halted run aborts all remaining ticks without calling `Article_Worker`.
|
||||
- **WP-CLI `wp prautoblogger generate` command**. Routes through the same
|
||||
chained-cron checkpoints as the board button. Replaces the retired SSH-only
|
||||
`wp eval 'do_action("prautoblogger_manual_generation")'` workaround.
|
||||
- **Settings-backed board pagination** (perf pass). All hardcoded LIMIT / `posts_per_page`
|
||||
values in board queries replaced with two new settings:
|
||||
- `prautoblogger_board_column_limit` (default 20, constant `PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT`)
|
||||
- `prautoblogger_ideas_per_page` (default 30, constant `PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE`)
|
||||
- **DB indexes** (via `PRAUTOBLOGGER_DB_VERSION` â `1.4.0`):
|
||||
- `generation_log`: `KEY run_id_post_id`, `KEY created_status` (board query + failed-cards).
|
||||
- `analysis_results`: `KEY analyzed_type` (ideas-browser pagination + type filter).
|
||||
- `runs`: `KEY started_at` (articles-list pagination `ORDER BY started_at DESC LIMIT n OFFSET m`).
|
||||
- **Query helper split**: `PRAutoBlogger_Ideas_Browser_Query` (new `class-ideas-browser-query.php`)
|
||||
carries static query helpers extracted from `class-ideas-browser.php` to enforce the
|
||||
300-line rule (CONVENTIONS §1).
|
||||
- **Guard P2s** (`scripts/check-constants.php`):
|
||||
- Added `--self-test` mode: synthesizes a missing constant and asserts the scanner catches it.
|
||||
- Added inline comment documenting `CONSTANTS_GUARD_WINDOW = 4`.
|
||||
- Wired `--self-test` as a CI step after the main constants check.
|
||||
- **PHPUnit test** `tests/unit/Core/GenerationCheckpointRunnerTest.php`: tick-by-tick
|
||||
state-machine proof (kick_off schedules only, empty queue finalizes, halted run aborts,
|
||||
multi-idea queue pops one and reschedules).
|
||||
- **Pipeline_Status** now exports `write_initial()` and `write_error()` for the
|
||||
checkpoint runner to set the status transient before the first cron tick fires.
|
||||
|
||||
### Changed
|
||||
- `Executor::on_manual_generation()` now delegates to
|
||||
`Generation_Checkpoint_Runner::on_orchestrate_tick()` instead of running the full
|
||||
pipeline synchronously. The method body is 1 line; the synchronous path is retired.
|
||||
- `Pipeline_Runner::orchestrate_only()` added as a public wrapper exposing the private
|
||||
`orchestrate()` method for use by `Generation_Checkpoint_Runner`.
|
||||
|
||||
### Removed
|
||||
- **SSH-only workaround retired**: `wp eval 'do_action("prautoblogger_manual_generation")'` no
|
||||
longer fires the pipeline synchronously in one PHP process. Use `wp prautoblogger generate`
|
||||
instead (routes through the chained-cron checkpoint machine). See ARCHITECTURE.md §25.
|
||||
|
||||
## [0.20.1] - 2026-06-12
|
||||
|
||||
### Fixed
|
||||
|
||||
84
INFORMATION-ARCHITECTURE.md
Normal file
84
INFORMATION-ARCHITECTURE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Information Architecture — PRAutoBlogger
|
||||
|
||||
Auto-generated index of all admin surfaces, data flows, and integrations.
|
||||
Maintained per DoD v1.1.0 (same-PR update rule).
|
||||
|
||||
---
|
||||
|
||||
## Admin Pages
|
||||
|
||||
| Slug | Class | Template | Purpose |
|
||||
|------|-------|----------|---------|
|
||||
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
|
||||
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
|
||||
| `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs |
|
||||
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) |
|
||||
| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
|
||||
| `prautoblogger-activity` | `PRAutoBlogger_Activity_Page` | `activity-page.php` | Cost + generation activity log |
|
||||
|
||||
---
|
||||
|
||||
## Generation Entry Points (v0.21.0)
|
||||
|
||||
```
|
||||
Board "New Article" button
|
||||
└─ AJAX prautoblogger_generate_now
|
||||
└─ Generation_Status_Poller::on_ajax_generate_now()
|
||||
└─ schedules prautoblogger_manual_generation cron
|
||||
└─ Executor::on_manual_generation()
|
||||
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
|
||||
└─ schedules prautoblogger_gen_tick
|
||||
└─ Generation_Checkpoint_Runner::on_generate_tick() ← Tick 2..N
|
||||
|
||||
WP-CLI: wp prautoblogger generate
|
||||
└─ WP_CLI_Commands::generate_command()
|
||||
└─ Generation_Checkpoint_Runner::kick_off()
|
||||
└─ schedules prautoblogger_gen_orchestrate
|
||||
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
|
||||
|
||||
Daily auto-generation cron
|
||||
└─ prautoblogger_daily_generation
|
||||
└─ Executor::on_cron_run()
|
||||
└─ Pipeline_Runner::run() (legacy synchronous path — runs in a single PHP process)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database Tables
|
||||
|
||||
| Table | DB Version | Purpose |
|
||||
|-------|-----------|---------|
|
||||
| `prautoblogger_sources` | 1.0.0 | Collected Reddit/RSS source content |
|
||||
| `prautoblogger_analysis_results` | 1.0.0 | Scored article ideas |
|
||||
| `prautoblogger_generation_log` | 1.0.0 | Per-LLM-call log (cost, tokens, status) |
|
||||
| `prautoblogger_runs` | 1.0.0 | Per-run ledger (status, ceiling_usd) |
|
||||
| `prautoblogger_run_stages` | 1.2.0 | Per-stage state for M3 re-run |
|
||||
| `prautoblogger_stage_inputs` | 1.3.0 | Immutable input versions for stage edits (M3) |
|
||||
| `prautoblogger_run_decisions` | 1.2.0 | Editorial decisions per run |
|
||||
|
||||
DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.2→1.3 (M3 stage inputs), 1.3→1.4 (M4 indexes).
|
||||
|
||||
---
|
||||
|
||||
## Key Options
|
||||
|
||||
| Option | Type | Default | Purpose |
|
||||
|--------|------|---------|---------|
|
||||
| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
|
||||
| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
|
||||
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
|
||||
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
|
||||
| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
|
||||
|
||||
---
|
||||
|
||||
## WP-CLI Commands
|
||||
|
||||
| Command | Since | Description |
|
||||
|---------|-------|-------------|
|
||||
| `wp prautoblogger generate` | 0.21.0 | Queue article generation via chained-cron checkpoints |
|
||||
| `wp prautoblogger opik:eval` | 0.12.0 | Trigger Opik eval run |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.21.0 (2026-06-14)*
|
||||
53
assets/js/board-generate.js
Normal file
53
assets/js/board-generate.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
|
||||
*
|
||||
* Loaded on the board page alongside board.js. Depends on the prabBoard config
|
||||
* object and jQuery, both already present when board.js loads.
|
||||
*
|
||||
* @see assets/js/board.js -- Board poller (loads first).
|
||||
* @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
|
||||
*/
|
||||
( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.prabBoard || {};
|
||||
|
||||
/**
|
||||
* Handle click on the "New Article" button. Fires the existing generate_now
|
||||
* AJAX action and triggers board backoff reset so the new card appears fast.
|
||||
*/
|
||||
function onBoardGenerateNow() {
|
||||
var $btn = $( '#prab-board-generate-now' );
|
||||
if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
|
||||
return;
|
||||
}
|
||||
$btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
|
||||
var $status = $( '#prab-board-gen-status' );
|
||||
$status.text( '' ).hide();
|
||||
|
||||
$.ajax( {
|
||||
url: cfg.ajaxUrl,
|
||||
method: 'POST',
|
||||
data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
|
||||
success: function ( response ) {
|
||||
if ( response && response.success ) {
|
||||
$status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
|
||||
// Notify board poller to reset backoff (run is now active).
|
||||
$( document ).trigger( 'prab:generation-started' );
|
||||
} else {
|
||||
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
|
||||
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
|
||||
}
|
||||
},
|
||||
error: function () {
|
||||
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
|
||||
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
$( function () {
|
||||
$( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
@@ -164,6 +164,11 @@
|
||||
renderColumn( 'in_review', snapshot.in_review || [] );
|
||||
renderColumn( 'published', snapshot.published || [] );
|
||||
renderColumn( 'failed', snapshot.failed || [] );
|
||||
// Re-enable board Generate button once no active runs remain.
|
||||
if ( ! snapshot.has_active_runs ) {
|
||||
$( '#prab-board-generate-now' ).prop( 'disabled', false )
|
||||
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,22 +230,7 @@
|
||||
} );
|
||||
}
|
||||
|
||||
/** Initialise the board poller on DOM ready. */
|
||||
$( function () {
|
||||
$board = $( '#prab-board' );
|
||||
if ( ! $board.length ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject the error banner after the heading.
|
||||
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
|
||||
$board.before( $errorBanner );
|
||||
|
||||
// Check if any generating card is present in the initial server render.
|
||||
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
|
||||
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
|
||||
|
||||
schedulePoll( nextDelay( hasActive ) );
|
||||
} );
|
||||
|
||||
}( jQuery ) );
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
{
|
||||
"name": "peptiderepo/prautoblogger",
|
||||
"description": "WordPress plugin that auto-researches and publishes blog content using AI.",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": {
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"brain/monkey": "^2.6"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"includes/"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
||||
"name": "peptiderepo/prautoblogger",
|
||||
"description": "WordPress plugin that auto-researches and publishes blog content using AI.",
|
||||
"type": "wordpress-plugin",
|
||||
"license": "GPL-2.0-or-later",
|
||||
"require": {
|
||||
"php": ">=7.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"brain/monkey": "^2.6"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"includes/"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vendor/bin/phpunit --testdox"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,84 +270,14 @@ class PRAutoBlogger_Admin_Page {
|
||||
/**
|
||||
* Sanitize a settings field value.
|
||||
*
|
||||
* Delegates to PRAutoBlogger_Settings_Sanitizer (extracted for 300-line compliance).
|
||||
*
|
||||
* @param mixed $value The submitted value.
|
||||
* @return mixed Sanitized value.
|
||||
*
|
||||
* @see admin/class-settings-sanitizer.php
|
||||
*/
|
||||
public function sanitize_field( $value ) {
|
||||
$option_name = '';
|
||||
$filter = current_filter();
|
||||
if ( 0 === strpos( $filter, 'sanitize_option_' ) ) {
|
||||
$option_name = substr( $filter, strlen( 'sanitize_option_' ) );
|
||||
}
|
||||
|
||||
$encrypted = array( 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' );
|
||||
if ( in_array( $option_name, $encrypted, true ) ) {
|
||||
// Empty value means password field wasn't touched — keep existing.
|
||||
if ( '' === $value ) {
|
||||
return get_option( $option_name, '' );
|
||||
}
|
||||
|
||||
// Already encrypted (has "enc:" prefix) — return as-is.
|
||||
// This is the primary defence against double-encryption:
|
||||
// PRAutoBlogger_Encryption::encrypt() also checks for this prefix,
|
||||
// so even if this callback is called multiple times, the value
|
||||
// is encrypted exactly once and never re-encrypted.
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $value ) ) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// New plaintext value — encrypt it (adds "enc:" prefix).
|
||||
return PRAutoBlogger_Encryption::encrypt( sanitize_text_field( $value ) );
|
||||
}
|
||||
|
||||
$json_fields = array( 'prautoblogger_target_subreddits', 'prautoblogger_topic_exclusions', 'prautoblogger_enabled_sources' );
|
||||
if ( in_array( $option_name, $json_fields, true ) ) {
|
||||
// PHP array from checkboxes — sanitize each item and re-encode.
|
||||
if ( is_array( $value ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $value ) ) );
|
||||
}
|
||||
|
||||
$trimmed = trim( (string) $value );
|
||||
|
||||
// Already JSON-encoded array — decode, sanitize each item, re-encode.
|
||||
// This prevents sanitize_text_field() from mangling the JSON string,
|
||||
// which caused json_decode() on read to return null → empty subreddits.
|
||||
if ( '[' === substr( $trimmed, 0, 1 ) ) {
|
||||
$decoded = json_decode( $trimmed, true );
|
||||
if ( is_array( $decoded ) ) {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $decoded ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Comma-separated plain text — split, sanitize, encode as JSON array.
|
||||
$items = array_filter( array_map( 'trim', explode( ',', $trimmed ) ) );
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||
}
|
||||
|
||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days' );
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
if ( 'prautoblogger_image_model' === $option_name ) {
|
||||
return $this->sanitize_image_model( (string) $value );
|
||||
}
|
||||
if ( 'prautoblogger_image_style_template' === $option_name ) {
|
||||
// Multi-line template: validation + textarea-safe sanitisation
|
||||
// (incl. the single-token check, brief A5) lives in the filler.
|
||||
return PRAutoBlogger_Image_Template_Filler::sanitize_for_save( (string) $value );
|
||||
}
|
||||
return sanitize_text_field( (string) $value );
|
||||
}
|
||||
|
||||
/** Validate model id against the registry and persist the derived provider. */
|
||||
private function sanitize_image_model( string $value ): string {
|
||||
$candidate = sanitize_text_field( $value );
|
||||
$provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate );
|
||||
if ( '' !== $provider ) {
|
||||
update_option( 'prautoblogger_image_provider', $provider );
|
||||
return $candidate;
|
||||
}
|
||||
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
|
||||
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
|
||||
return PRAutoBlogger_Settings_Sanitizer::sanitize_field( $value );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'draft',
|
||||
'posts_per_page' => 20,
|
||||
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => '_prautoblogger_generated',
|
||||
@@ -144,7 +144,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => 20,
|
||||
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
|
||||
'date_query' => array(
|
||||
array(
|
||||
'after' => $date_after,
|
||||
|
||||
@@ -76,8 +76,9 @@ class PRAutoBlogger_Board_Gen_Log_Query {
|
||||
AND post_id IS NULL
|
||||
GROUP BY run_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 10",
|
||||
$since
|
||||
LIMIT %d",
|
||||
$since,
|
||||
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
@@ -148,8 +149,9 @@ class PRAutoBlogger_Board_Gen_Log_Query {
|
||||
AND post_id IS NULL
|
||||
GROUP BY run_id
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 20",
|
||||
$since
|
||||
LIMIT %d",
|
||||
$since,
|
||||
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
@@ -4,27 +4,27 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Registers and renders the kanban board — the primary landing screen for PRAutoBlogger.
|
||||
* Registers and renders the kanban board -- the primary landing screen for PRAutoBlogger.
|
||||
*
|
||||
* Board columns: Generating | In Review | Published | Failed.
|
||||
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
|
||||
* backing off to 2× when no active generating run is detected.
|
||||
* backing off to 2x when no active generating run is detected.
|
||||
*
|
||||
* Card click-throughs (M1):
|
||||
* - Generating → Activity Log filtered to run context
|
||||
* - In Review → Review Queue
|
||||
* - Published → Post edit screen
|
||||
* - Failed → Activity Log
|
||||
* - Generating -> Activity Log filtered to run context
|
||||
* - In Review -> Review Queue
|
||||
* - Published -> Post edit screen
|
||||
* - Failed -> Activity Log
|
||||
*
|
||||
* M2 will rewire all click-throughs to the Article Dossier page.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
|
||||
*
|
||||
* @see admin/class-board-data-provider.php — Supplies card data.
|
||||
* @see templates/admin/board-page.php — HTML template.
|
||||
* @see assets/js/board.js — Polling + DOM updates.
|
||||
* @see ARCHITECTURE.md — §Board (kanban dashboard).
|
||||
* @see admin/class-board-data-provider.php -- Supplies card data.
|
||||
* @see templates/admin/board-page.php -- HTML template.
|
||||
* @see assets/js/board.js -- Polling + DOM updates.
|
||||
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
|
||||
*/
|
||||
class PRAutoBlogger_Board_Page {
|
||||
|
||||
@@ -48,7 +48,7 @@ class PRAutoBlogger_Board_Page {
|
||||
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
||||
* runs here, so get_plugin_page_hookname() resolves to the correct
|
||||
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
|
||||
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board.
|
||||
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md Section: Board.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -128,6 +128,13 @@ class PRAutoBlogger_Board_Page {
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'prautoblogger-board-generate',
|
||||
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
|
||||
array( 'jquery', 'prautoblogger-board' ),
|
||||
PRAUTOBLOGGER_VERSION,
|
||||
true
|
||||
);
|
||||
|
||||
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
|
||||
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||
@@ -138,6 +145,7 @@ class PRAutoBlogger_Board_Page {
|
||||
array(
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
|
||||
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||
'pollInterval' => $poll_interval * 1000,
|
||||
'publishedWindowDays' => $published_days,
|
||||
'action' => self::AJAX_ACTION,
|
||||
@@ -151,8 +159,12 @@ class PRAutoBlogger_Board_Page {
|
||||
'view' => __( 'View', 'prautoblogger' ),
|
||||
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
||||
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
||||
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
|
||||
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
|
||||
'newArticle' => __( 'New Article', 'prautoblogger' ),
|
||||
'generatingBtn' => __( 'Generating...', 'prautoblogger' ),
|
||||
'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
|
||||
'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
@@ -163,7 +175,7 @@ class PRAutoBlogger_Board_Page {
|
||||
*
|
||||
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
|
||||
*
|
||||
* Side effects: DB reads only — no writes.
|
||||
* Side effects: DB reads only -- no writes.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
|
||||
136
includes/admin/class-ideas-browser-query.php
Normal file
136
includes/admin/class-ideas-browser-query.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Query helpers for PRAutoBlogger_Ideas_Browser.
|
||||
*
|
||||
* Extracted to keep class-ideas-browser.php under 300 lines (CONVENTIONS §1).
|
||||
* All methods are static; class is never instantiated.
|
||||
*/
|
||||
class PRAutoBlogger_Ideas_Browser_Query {
|
||||
|
||||
/** Fall-through if setting is absent. */
|
||||
public const PER_PAGE_DEFAULT = 30;
|
||||
|
||||
/** Transient key prefix for per-idea generation status. */
|
||||
public const STATUS_PREFIX = 'prab_idea_gen_';
|
||||
|
||||
/** How long to keep per-idea status (seconds). */
|
||||
public const STATUS_TTL = 600;
|
||||
|
||||
/** Query analysis results with optional filtering and pagination. */
|
||||
public static function query_ideas( int $paged, string $type ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( '' !== $type ) {
|
||||
$where[] = 'analysis_type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
|
||||
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||
$per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
|
||||
$offset = ( $paged - 1 ) * $per_page;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
);
|
||||
|
||||
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', $per_page, $offset );
|
||||
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get counts per analysis_type for the filter badges. */
|
||||
public static function get_type_counts(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$counts = array();
|
||||
foreach ( $rows ?? array() as $row ) {
|
||||
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||
*
|
||||
* @param int $idea_id Analysis result row ID.
|
||||
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||
*/
|
||||
public static function load_idea_data( int $idea_id ): ?array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
|
||||
return array(
|
||||
'topic' => $row['topic'],
|
||||
'article_type' => $row['analysis_type'],
|
||||
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
|
||||
'summary' => $row['summary'] ?? '',
|
||||
'score' => (float) $row['relevance_score'],
|
||||
'analysis_id' => (int) $row['id'],
|
||||
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
|
||||
'key_points' => $meta['key_points'] ?? array(),
|
||||
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Find the most recent post created by a specific run_id. */
|
||||
public static function find_post_by_run_id( string $run_id ): ?int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return null !== $post_id ? (int) $post_id : null;
|
||||
}
|
||||
|
||||
/** Update the per-idea status transient with a stage message. */
|
||||
public static function update_idea_stage( int $idea_id, string $stage ): void {
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$current = get_transient( $key );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
set_transient( $key, $current, self::STATUS_TTL );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,14 @@ declare(strict_types=1);
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
|
||||
* Query helpers: class-ideas-browser-query.php (split for 300-line rule, CONVENTIONS §1).
|
||||
*
|
||||
* @see core/class-article-worker.php — Generates articles from ideas.
|
||||
* @see templates/admin/ideas-browser.php — Renders the HTML.
|
||||
* @see class-ideas-browser-query.php — Static query helpers.
|
||||
*/
|
||||
class PRAutoBlogger_Ideas_Browser {
|
||||
|
||||
private const PER_PAGE = 30;
|
||||
|
||||
/** Transient prefix for per-idea generation status. */
|
||||
private const STATUS_PREFIX = 'prab_idea_gen_';
|
||||
|
||||
/** How long to keep per-idea status (seconds). */
|
||||
private const STATUS_TTL = 600;
|
||||
|
||||
/** Register the Ideas submenu page under PRAutoBlogger. */
|
||||
public function on_register_menu(): void {
|
||||
add_submenu_page(
|
||||
@@ -43,11 +37,12 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
|
||||
|
||||
$result = $this->query_ideas( $paged, $type_filter );
|
||||
$rows = $result['rows'];
|
||||
$total = $result['total'];
|
||||
$total_pages = (int) ceil( $total / self::PER_PAGE );
|
||||
$type_counts = $this->get_type_counts();
|
||||
$result = PRAutoBlogger_Ideas_Browser_Query::query_ideas( $paged, $type_filter );
|
||||
$rows = $result['rows'];
|
||||
$total = $result['total'];
|
||||
$per_page_view = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', PRAutoBlogger_Ideas_Browser_Query::PER_PAGE_DEFAULT ) );
|
||||
$total_pages = (int) ceil( $total / $per_page_view );
|
||||
$type_counts = PRAutoBlogger_Ideas_Browser_Query::get_type_counts();
|
||||
|
||||
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
|
||||
}
|
||||
@@ -74,23 +69,26 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
return;
|
||||
}
|
||||
|
||||
$status_prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
|
||||
$status_ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
|
||||
|
||||
// Build Article_Idea from the analysis row.
|
||||
$idea_data = self::load_idea_data( $idea_id );
|
||||
$idea_data = PRAutoBlogger_Ideas_Browser_Query::load_idea_data( $idea_id );
|
||||
if ( null === $idea_data ) {
|
||||
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Store idea for the cron handler and set initial "running" status.
|
||||
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
|
||||
set_transient( $status_prefix . 'data_' . $idea_id, $idea_data, $status_ttl );
|
||||
set_transient(
|
||||
self::STATUS_PREFIX . $idea_id,
|
||||
$status_prefix . $idea_id,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$status_ttl
|
||||
);
|
||||
|
||||
// Schedule background cron — passes idea_id as argument.
|
||||
@@ -124,7 +122,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
}
|
||||
|
||||
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
||||
$status = get_transient( self::STATUS_PREFIX . $idea_id );
|
||||
$status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
@@ -149,16 +147,19 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||
$prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
|
||||
$ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
|
||||
$key = $prefix . $idea_id;
|
||||
|
||||
$idea_data = get_transient( $prefix . 'data_' . $idea_id );
|
||||
if ( ! is_array( $idea_data ) ) {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Idea data expired.',
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -167,10 +168,10 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => 'Another generation is running.',
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -180,7 +181,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
||||
|
||||
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Ideas_Browser_Query::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
|
||||
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||
$result = $worker->generate( $idea );
|
||||
|
||||
@@ -188,7 +189,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||
|
||||
// Find the generated post ID for the "View" link.
|
||||
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
$post_id = PRAutoBlogger_Ideas_Browser_Query::find_post_by_run_id( $cost_tracker->get_run_id() );
|
||||
|
||||
set_transient(
|
||||
$key,
|
||||
@@ -199,7 +200,7 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
'cost' => $result['cost'],
|
||||
'post_id' => $post_id,
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
@@ -209,129 +210,14 @@ class PRAutoBlogger_Ideas_Browser {
|
||||
set_transient(
|
||||
$key,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
$ttl
|
||||
);
|
||||
}
|
||||
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/** Update the per-idea status transient with a stage message. */
|
||||
private static function update_idea_stage( int $idea_id, string $stage ): void {
|
||||
$key = self::STATUS_PREFIX . $idea_id;
|
||||
$current = get_transient( $key );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
set_transient( $key, $current, self::STATUS_TTL );
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the most recent post created by a specific run_id. */
|
||||
private static function find_post_by_run_id( string $run_id ): ?int {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$post_id = $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return null !== $post_id ? (int) $post_id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an analysis result row and map it to Article_Idea constructor data.
|
||||
*
|
||||
* @param int $idea_id Analysis result row ID.
|
||||
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
|
||||
*/
|
||||
private static function load_idea_data( int $idea_id ): ?array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
|
||||
ARRAY_A
|
||||
);
|
||||
if ( ! $row ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
|
||||
return array(
|
||||
'topic' => $row['topic'],
|
||||
'article_type' => $row['analysis_type'],
|
||||
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
|
||||
'summary' => $row['summary'] ?? '',
|
||||
'score' => (float) $row['relevance_score'],
|
||||
'analysis_id' => (int) $row['id'],
|
||||
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
|
||||
'key_points' => $meta['key_points'] ?? array(),
|
||||
'target_keywords' => $meta['target_keywords'] ?? array(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Query analysis results with optional filtering and pagination. */
|
||||
private function query_ideas( int $paged, string $type ): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
$where = array();
|
||||
$params = array();
|
||||
|
||||
if ( '' !== $type ) {
|
||||
$where[] = 'analysis_type = %s';
|
||||
$params[] = $type;
|
||||
}
|
||||
|
||||
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
|
||||
$offset = ( $paged - 1 ) * self::PER_PAGE;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
);
|
||||
|
||||
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
|
||||
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
|
||||
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
|
||||
|
||||
return array(
|
||||
'rows' => $rows ?? array(),
|
||||
'total' => $total,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get counts per analysis_type for the filter badges. */
|
||||
private function get_type_counts(): array {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$counts = array();
|
||||
foreach ( $rows ?? array() as $row ) {
|
||||
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
|
||||
}
|
||||
return $counts;
|
||||
delete_transient( $prefix . 'data_' . $idea_id );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,282 +35,194 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function get_fields(): array {
|
||||
return array(
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
return array_merge(
|
||||
array(
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => array(
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
// ── Sources (extended) ──────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_research_model',
|
||||
'label' => __( 'Research Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL,
|
||||
'capability' => 'text→text',
|
||||
'description' => __( 'Model for LLM Deep Research. Pick a reasoning-capable model (e.g. Grok 4.1 Fast, DeepSeek-R1) for best results. Only used when LLM Deep Research is enabled above.', 'prautoblogger' ),
|
||||
'badge' => __( 'Reasoning', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_research_prompt',
|
||||
'label' => __( 'Research Prompt', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_sources',
|
||||
'default' => 'Conduct a deep research sweep of the {niche} space and identify the most substantive, actionable topics that practitioners and curious newcomers are searching for or actively discussing.' . "\n\n" . 'Focus on:' . "\n" . '- Practical questions real readers would search for (not theoretical debates)' . "\n" . '- Emerging trends gaining momentum in the community' . "\n" . '- Common misconceptions that deserve clarification' . "\n" . '- Comparative analysis (product vs. product, protocol vs. protocol)' . "\n" . '- Safety and efficacy topics that drive concern or curiosity' . "\n" . '- Beginner-to-advanced mix — cover entry-level and depth-focused topics' . "\n\n" . 'Aim for topics that feel timely, underserved by existing content, and likely to drive reader engagement. Return 8-12 findings with detailed analysis in each.',
|
||||
'description' => __( 'The research brief sent to the LLM. Use {niche} as a placeholder for your niche description. The system prompt handles output format — this controls WHAT to research.', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Board Settings ─────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_board_poll_interval',
|
||||
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 5,
|
||||
'min' => 3,
|
||||
'max' => 60,
|
||||
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_board_published_window_days',
|
||||
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 7,
|
||||
'min' => 1,
|
||||
'max' => 90,
|
||||
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
|
||||
),
|
||||
// ── AI Models (extended) ────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_enabled',
|
||||
'label' => __( 'Enable Reasoning', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => '0',
|
||||
'description' => __( 'Send reasoning instructions to models that support it (e.g. Grok, DeepSeek-R1). Reasoning tokens are billed as output tokens — expect higher costs per call. Models that don\'t support reasoning will ignore this.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_reasoning_effort',
|
||||
'label' => __( 'Reasoning Effort', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_models',
|
||||
'default' => 'medium',
|
||||
'options' => array(
|
||||
'xhigh' => __( 'Extra High — maximum depth, highest cost', 'prautoblogger' ),
|
||||
'high' => __( 'High — thorough reasoning', 'prautoblogger' ),
|
||||
'medium' => __( 'Medium — balanced (recommended)', 'prautoblogger' ),
|
||||
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
|
||||
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_daily_article_target',
|
||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 1,
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_schedule_time',
|
||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||
'type' => 'time',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => '03:00',
|
||||
'description' => sprintf(
|
||||
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||
// ── Board Settings ─────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_board_poll_interval',
|
||||
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 5,
|
||||
'min' => 3,
|
||||
'max' => 60,
|
||||
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_board_published_window_days',
|
||||
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 7,
|
||||
'min' => 1,
|
||||
'max' => 90,
|
||||
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_daily_article_target',
|
||||
'label' => __( 'Articles per Day', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 1,
|
||||
'min' => 1,
|
||||
'max' => 10,
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_schedule_time',
|
||||
'label' => __( 'Generation Time', 'prautoblogger' ),
|
||||
'type' => 'time',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => '03:00',
|
||||
'description' => sprintf(
|
||||
/* translators: %s: site timezone string, e.g. "Asia/Singapore". */
|
||||
esc_html__( "Daily generation runs at this time in your site's configured timezone (Settings → General → Timezone). Current site timezone: %s.", 'prautoblogger' ),
|
||||
esc_html( function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : 'UTC' )
|
||||
),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_monthly_budget_usd',
|
||||
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 50,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Publishing ──────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_auto_publish',
|
||||
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => '0',
|
||||
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_author',
|
||||
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||
'type' => 'author_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_category',
|
||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||
'type' => 'category_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_log_level',
|
||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 'info',
|
||||
'options' => array(
|
||||
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_property_id',
|
||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_credentials_json',
|
||||
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Display ────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_family',
|
||||
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 'default',
|
||||
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_size',
|
||||
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 32,
|
||||
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_table_borders',
|
||||
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => '1',
|
||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||
),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_monthly_budget_usd',
|
||||
'label' => __( 'Monthly Budget (USD)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_schedule',
|
||||
'default' => 50,
|
||||
'min' => 0,
|
||||
'step' => '0.01',
|
||||
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Publishing ──────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_auto_publish',
|
||||
'label' => __( 'Auto-Publish', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => '0',
|
||||
'description' => __( 'Automatically publish editor-approved posts. When off, all posts go to the Review Queue as drafts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_author',
|
||||
'label' => __( 'Default Author', 'prautoblogger' ),
|
||||
'type' => 'author_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'WordPress user assigned as author of generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_default_category',
|
||||
'label' => __( 'Default Category', 'prautoblogger' ),
|
||||
'type' => 'category_select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 0,
|
||||
'description' => __( 'Fallback category for generated posts.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_log_level',
|
||||
'label' => __( 'Log Level', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_publishing',
|
||||
'default' => 'info',
|
||||
'options' => array(
|
||||
'error' => __( 'Error — only failures', 'prautoblogger' ),
|
||||
'warning' => __( 'Warning — errors + warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info — key events (recommended)', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug — everything (verbose)', 'prautoblogger' ),
|
||||
),
|
||||
'description' => __( 'Controls detail level in the Activity Log.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Analytics ───────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_property_id',
|
||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_ga4_credentials_json',
|
||||
'label' => __( 'Service Account JSON', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Display ────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_family',
|
||||
'label' => __( 'Article Font', 'prautoblogger' ),
|
||||
'type' => 'select',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 'default',
|
||||
'options' => PRAutoBlogger_Article_Typography::get_font_choices(),
|
||||
'description' => __( 'Font family used for generated article body text. Serif fonts improve long-form readability.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_article_font_size',
|
||||
'label' => __( 'Article Font Size (px)', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 32,
|
||||
'description' => __( 'Base font size for article body text. 0 uses the theme default (13px). Recommended: 16–18px for comfortable reading.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_table_borders',
|
||||
'label' => __( 'Table Borders', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_display',
|
||||
'default' => '1',
|
||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
||||
),
|
||||
|
||||
// ── Images ─────────────────────────────────────────────────
|
||||
array(
|
||||
'id' => 'prautoblogger_image_enabled',
|
||||
'label' => __( 'Enable Image Generation', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '0',
|
||||
'description' => __( 'Generate images for each published article.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_b_enabled',
|
||||
'label' => __( 'Generate Second Image (B)', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Generate a second image from source data for A/B testing. Disabling saves one image generation + one LLM prompt rewrite per article.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_model',
|
||||
'label' => __( 'Image Model', 'prautoblogger' ),
|
||||
'type' => 'model_select',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
|
||||
'capability' => 'image_generation',
|
||||
'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ),
|
||||
'badge' => __( 'Quality', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_api_key',
|
||||
'label' => __( 'Runware API Key', 'prautoblogger' ),
|
||||
'type' => 'password',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
|
||||
'icon' => '🔑',
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_style_template',
|
||||
'label' => __( 'Style Template', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE,
|
||||
'description' => __( 'The full image prompt template. Must contain exactly one {{ topic_summary }} token, which is filled with a 1-2 sentence topic/mechanism summary per article. The rest is brand-locked editorial style. Replaces the old Style Suffix (now deprecated). Changing mid-run causes visible style drift.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_prompt_instructions',
|
||||
'label' => __( 'Image Prompt Instructions', 'prautoblogger' ),
|
||||
'type' => 'textarea',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => PRAutoBlogger_Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT,
|
||||
'description' => __( 'System prompt given to the rewriter LLM that turns each article into a SCENE (a 1-2 sentence editorial topic/mechanism summary, substituted into the Style Template) plus a CAPTION (HTML text shown below the image). Changing this reshapes the look of all future images. Leave blank to use the default.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_nsfw_retry',
|
||||
'label' => __( 'Retry NSFW-Blocked Images', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_enabled',
|
||||
'label' => __( 'Compose Branded Variants', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Render branded social variants (teal band, logo, baked caption) from each generated image via the local deterministic composer. Auto-degrades to plain resizing or pass-through if the server lacks an image library — publishing is never blocked.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_compose_variants',
|
||||
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => 'og,square',
|
||||
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_image_featured_mark_enabled',
|
||||
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
|
||||
'type' => 'toggle',
|
||||
'section' => 'prautoblogger_images',
|
||||
'default' => '1',
|
||||
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_runware_model_catalog',
|
||||
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
|
||||
'type' => 'runware_catalog_sync',
|
||||
'section' => 'prautoblogger_images',
|
||||
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Settings_Fields_Images::get_fields()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
123
includes/admin/class-settings-fields-images.php
Normal file
123
includes/admin/class-settings-fields-images.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?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' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
includes/admin/class-settings-sanitizer.php
Normal file
107
includes/admin/class-settings-sanitizer.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ class PRAutoBlogger_WP_CLI_Commands {
|
||||
}
|
||||
|
||||
// Command: wp prautoblogger opik:eval
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger generate',
|
||||
array( self::class, 'generate_command' ),
|
||||
array(
|
||||
'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints',
|
||||
)
|
||||
);
|
||||
|
||||
\WP_CLI::add_command(
|
||||
'prautoblogger opik:eval',
|
||||
array( self::class, 'opik_eval_command' ),
|
||||
@@ -69,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.' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ class PRAutoBlogger_Activator {
|
||||
'prautoblogger_table_borders' => '1',
|
||||
'prautoblogger_schedule_time' => '03:00',
|
||||
'prautoblogger_log_level' => 'info',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
'prautoblogger_image_nsfw_retry' => '1',
|
||||
// M4 (v0.21.0): board column card limit + ideas page size -- settings-backed.
|
||||
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
|
||||
'prautoblogger_ideas_per_page' => PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE,
|
||||
);
|
||||
|
||||
foreach ( $defaults as $key => $value ) {
|
||||
|
||||
@@ -85,68 +85,21 @@ class PRAutoBlogger_Executor {
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron handler: runs pipeline orchestration + first article.
|
||||
* Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
|
||||
*
|
||||
* If the pipeline queues additional articles (2..N), they will fire as
|
||||
* chained cron events — each in its own PHP process. The lock is released
|
||||
* by the pipeline when the last article completes, not here.
|
||||
* Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
|
||||
* runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
|
||||
* article generation. This replaces the former monolithic pipeline run and
|
||||
* retires the SSH-only `do_action("prautoblogger_manual_generation")` workaround
|
||||
* (see ARCHITECTURE.md ss25).
|
||||
*
|
||||
* Lock acquisition and status transient writes are fully owned by the
|
||||
* checkpoint runner; this method is a thin routing wrapper.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function on_manual_generation(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$results = $runner->run();
|
||||
|
||||
// If no articles were queued (target=1 or 0 ideas), finalize here.
|
||||
// When articles ARE queued, the pipeline handles status + lock release.
|
||||
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $results['generated'],
|
||||
'published' => $results['published'],
|
||||
'rejected' => $results['rejected'],
|
||||
'cost' => $results['cost'],
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
|
||||
'pipeline'
|
||||
);
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||
}
|
||||
|
||||
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
||||
|
||||
@@ -115,7 +115,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
||||
updated_at DATETIME NOT NULL,
|
||||
finished_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (run_id),
|
||||
KEY status (status)
|
||||
KEY status (status),
|
||||
KEY started_at (started_at)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Per-run per-stage state rows. The idempotency key is
|
||||
|
||||
@@ -146,6 +146,10 @@ class PRAutoBlogger {
|
||||
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
|
||||
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
|
||||
|
||||
// v0.21.0 (M4): chained-cron checkpoint ticks for new article generation.
|
||||
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_orchestrate_tick' ) );
|
||||
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_generate_tick' ) );
|
||||
|
||||
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
|
||||
|
||||
@@ -71,7 +71,8 @@ class PRAutoBlogger_Schema_Installer {
|
||||
metadata_json LONGTEXT DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY type_relevance (analysis_type, relevance_score),
|
||||
KEY analyzed_at (analyzed_at)
|
||||
KEY analyzed_at (analyzed_at),
|
||||
KEY analyzed_type (analyzed_at, analysis_type)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Generation log — every API call with cost tracking. `run_id`
|
||||
@@ -100,7 +101,9 @@ class PRAutoBlogger_Schema_Installer {
|
||||
KEY post_id (post_id),
|
||||
KEY run_id (run_id),
|
||||
KEY created_at (created_at),
|
||||
KEY stage (stage)
|
||||
KEY stage (stage),
|
||||
KEY run_id_post_id (run_id, post_id),
|
||||
KEY created_status (created_at, response_status)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Content scores — post performance metrics for self-improvement.
|
||||
|
||||
71
includes/core/class-generation-checkpoint-helpers.php
Normal file
71
includes/core/class-generation-checkpoint-helpers.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Private helpers for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
|
||||
*
|
||||
* Extracted to keep class-generation-checkpoint-runner.php under the 300-line
|
||||
* cap (CONVENTIONS §1). Contains the three internal-only static methods:
|
||||
* finalize() — write final transient, mark run done, release lock.
|
||||
* cleanup_queue() — delete queue + run-id options on abort paths.
|
||||
* fire_cron_now() — non-blocking loopback to fire cron events immediately.
|
||||
*
|
||||
* Only PRAutoBlogger_Generation_Checkpoint_Runner should call this class.
|
||||
*
|
||||
* @see core/class-generation-checkpoint-runner.php — Caller / orchestrator.
|
||||
* @see ARCHITECTURE.md ss25 — Checkpoint design.
|
||||
*/
|
||||
class PRAutoBlogger_Generation_Checkpoint_Helpers {
|
||||
|
||||
/** Option key for the checkpoint queue (mirrors Runner constant). */
|
||||
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||
|
||||
/** Option key for the in-progress run UUID (mirrors Runner constant). */
|
||||
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
|
||||
|
||||
/**
|
||||
* Finalize a completed run: write final transient, mark run done, release lock.
|
||||
*
|
||||
* @param array $results Generation counters (generated/published/rejected/cost).
|
||||
* @param string $run_id Run UUID (read from option if blank).
|
||||
* @return void
|
||||
*/
|
||||
public static function finalize( array $results, string $run_id = '' ): void {
|
||||
if ( '' === $run_id ) {
|
||||
$run_id = (string) get_option( self::RUN_ID_KEY, '' );
|
||||
}
|
||||
if ( '' !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||
}
|
||||
PRAutoBlogger_Pipeline_Status::write_final( $results );
|
||||
PRAutoBlogger_Pipeline_Status::log_summary( $results );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
}
|
||||
|
||||
/** Remove the persistent queue and run-id option on abort paths. */
|
||||
public static function cleanup_queue(): void {
|
||||
delete_option( self::QUEUE_KEY );
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-blocking loopback to fire cron events immediately.
|
||||
* Mirrors Pipeline_Runner::schedule_next() exactly.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function fire_cron_now(): void {
|
||||
spawn_cron();
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
267
includes/core/class-generation-checkpoint-runner.php
Normal file
267
includes/core/class-generation-checkpoint-runner.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Chained-cron checkpoint runner for in-admin manual article generation (v0.21.0, M4).
|
||||
*
|
||||
* What: Replaces the monolithic on_manual_generation() path with a two-tick
|
||||
* checkpoint machine so Hostinger's background-PHP-process kill cannot
|
||||
* abort a mid-pipeline run:
|
||||
* Tick 1 (ORCHESTRATE): collect sources -> analyze patterns -> score ideas ->
|
||||
* persist idea queue to option -> schedule Tick 2.
|
||||
* Tick 2..N (GENERATE): pop one idea from queue -> Article_Worker ->
|
||||
* update transient -> schedule next tick or finalize.
|
||||
*
|
||||
* Why separate from Rerun_Executor: Rerun_Executor targets existing run_stages
|
||||
* rows (replay/rebuild). This class creates a brand-new run from scratch.
|
||||
*
|
||||
* SSH retirement (v0.21.0): the former `wp eval 'do_action("prautoblogger_manual_generation")'`
|
||||
* workaround fired on_manual_generation() synchronously in one PHP process --
|
||||
* bypassing the nonce and running the full pipeline without checkpoints.
|
||||
* That path is retired: Executor::on_manual_generation() now delegates here,
|
||||
* and `wp prautoblogger generate` queues via kick_off() (see ARCHITECTURE.md ss25).
|
||||
*
|
||||
* Cost governor: Run_State::ceiling_setting() is applied per-run; a halted run
|
||||
* aborts all remaining ticks (same guarantee as Pipeline_Runner's queued-article path).
|
||||
*
|
||||
* Who triggers it: PRAutoBlogger_Executor::on_manual_generation() (cron),
|
||||
* PRAutoBlogger_WP_CLI_Commands (wp prautoblogger generate).
|
||||
* Dependencies: Pipeline_Runner (orchestration), Run_State (run ledger),
|
||||
* Pipeline_Status (transient), Generation_Lock, Cost_Tracker, Logger.
|
||||
*
|
||||
* @see includes/class-executor.php -- Cron/AJAX entry points.
|
||||
* @see core/class-pipeline-runner.php -- Orchestration + article worker.
|
||||
* @see core/class-run-state.php -- Per-run ledger (cost governor).
|
||||
* @see core/class-pipeline-status.php -- Transient broadcast helpers.
|
||||
* @see core/class-generation-checkpoint-helpers.php -- finalize/cleanup/fire helpers.
|
||||
* @see ARCHITECTURE.md ss25 -- Checkpoint generation path design.
|
||||
*/
|
||||
class PRAutoBlogger_Generation_Checkpoint_Runner {
|
||||
|
||||
/** Option key for the checkpoint queue (shared with Pipeline_Runner article queue). */
|
||||
private const QUEUE_KEY = 'prautoblogger_article_queue';
|
||||
|
||||
/** Option key for the in-progress run UUID (generation tick). */
|
||||
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
|
||||
|
||||
/** WP-Cron action for the orchestration tick. */
|
||||
public const ORCHESTRATE_ACTION = 'prautoblogger_gen_orchestrate';
|
||||
|
||||
/** WP-Cron action for each article-generation tick. */
|
||||
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
|
||||
|
||||
/**
|
||||
* Schedule the orchestration tick and write the initial status transient.
|
||||
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
|
||||
* Returns immediately -- ALL pipeline work happens in cron.
|
||||
*
|
||||
* Side effects: cron scheduling, transient write.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function kick_off(): void {
|
||||
if ( wp_next_scheduled( self::ORCHESTRATE_ACTION ) ) {
|
||||
return; // Idempotent: already queued.
|
||||
}
|
||||
wp_schedule_single_event( time(), self::ORCHESTRATE_ACTION );
|
||||
PRAutoBlogger_Pipeline_Status::write_initial();
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron Tick 1: collect -> analyze -> score. Persists ideas, creates run row,
|
||||
* schedules Tick 2 for the first article, then returns.
|
||||
*
|
||||
* Side effects: source/analysis DB writes, run row insert, option write,
|
||||
* status transient update, cron scheduling.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function on_orchestrate_tick(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
PRAutoBlogger_Pipeline_Status::write_error(
|
||||
__( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' )
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$run_id = wp_generate_uuid4();
|
||||
update_option( self::RUN_ID_KEY, $run_id, false );
|
||||
PRAutoBlogger_Run_State::ensure_run( $run_id );
|
||||
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( $run_id );
|
||||
|
||||
try {
|
||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Pipeline_Status::write_error(
|
||||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
return;
|
||||
}
|
||||
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Collecting sources…', 'prautoblogger' ) );
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$ideas = $runner->orchestrate_only( $cost_tracker );
|
||||
|
||||
if ( empty( $ideas ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
|
||||
PRAutoBlogger_Pipeline_Status::write_final(
|
||||
array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
)
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
return;
|
||||
}
|
||||
|
||||
$serialized = array_map(
|
||||
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
|
||||
$ideas
|
||||
);
|
||||
update_option(
|
||||
self::QUEUE_KEY,
|
||||
array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => $serialized,
|
||||
'results' => array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
),
|
||||
),
|
||||
false
|
||||
);
|
||||
|
||||
PRAutoBlogger_Pipeline_Status::broadcast(
|
||||
sprintf(
|
||||
/* translators: %d: number of ideas found */
|
||||
__( 'Found %d idea(s) — starting generation…', 'prautoblogger' ),
|
||||
count( $ideas )
|
||||
)
|
||||
);
|
||||
|
||||
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() ),
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_option( self::RUN_ID_KEY );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron Tick 2..N: generate one article, persist totals, reschedule or finalize.
|
||||
*
|
||||
* Side effects: LLM API calls, DB writes, post creation, transient updates,
|
||||
* cron scheduling.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function on_generate_tick(): void {
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@ignore_user_abort( true );
|
||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@set_time_limit( 300 );
|
||||
|
||||
$queue = get_option( self::QUEUE_KEY );
|
||||
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize(
|
||||
array(
|
||||
'generated' => 0,
|
||||
'published' => 0,
|
||||
'rejected' => 0,
|
||||
'cost' => 0.0,
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$run_id = (string) ( $queue['run_id'] ?? '' );
|
||||
$run_status = PRAutoBlogger_Run_State::get_status( $run_id );
|
||||
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf( 'Generate tick: run %s is %s -- aborting.', $run_id, $run_status ),
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
return;
|
||||
}
|
||||
|
||||
$idea_data = array_shift( $queue['ideas'] );
|
||||
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
|
||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||
$cost_tracker->set_run_id( $run_id );
|
||||
|
||||
if ( $cost_tracker->is_budget_exceeded() ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
PRAutoBlogger_Pipeline_Status::write_error(
|
||||
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist consumed queue BEFORE generating (prevents orphan-recovery duplication).
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
} else {
|
||||
delete_option( self::QUEUE_KEY );
|
||||
}
|
||||
|
||||
try {
|
||||
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||
$result = $worker->generate( $idea );
|
||||
|
||||
$queue['results']['generated'] += $result['generated'];
|
||||
$queue['results']['published'] += $result['published'];
|
||||
$queue['results']['rejected'] += $result['rejected'];
|
||||
$queue['results']['cost'] += $result['cost'];
|
||||
|
||||
if ( ! empty( $queue['ideas'] ) ) {
|
||||
update_option( self::QUEUE_KEY, $queue, false );
|
||||
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
|
||||
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 );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf( 'Generate tick %s for run %s: %s', get_class( $e ), $run_id, $e->getMessage() ),
|
||||
'pipeline'
|
||||
);
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,10 @@ declare(strict_types=1);
|
||||
* API is unavailable.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
||||
* Dependencies: Semantic_Dedup, WP_Query.
|
||||
* Dependencies: Semantic_Dedup, Keyword_Extractor, WP_Query.
|
||||
*
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-semantic-dedup.php — Embedding + keyword dedup engine.
|
||||
* @see core/class-keyword-extractor.php — Keyword tokeniser (fallback dedup).
|
||||
* @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.
|
||||
@@ -67,7 +68,7 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
|
||||
if ( ! $this->skip_dedup ) {
|
||||
$topic = $result->get_topic();
|
||||
$topic_keywords = $this->extract_meaningful_keywords( $topic );
|
||||
$topic_keywords = PRAutoBlogger_Keyword_Extractor::extract( $topic );
|
||||
|
||||
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||
++$deduped_count;
|
||||
@@ -165,133 +166,6 @@ class PRAutoBlogger_Idea_Scorer {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from a text string (for fallback dedup).
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keywords.
|
||||
*/
|
||||
private function extract_meaningful_keywords( string $text ): array {
|
||||
static $stopwords = array(
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'your',
|
||||
'you',
|
||||
'how',
|
||||
'what',
|
||||
'when',
|
||||
'why',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'not',
|
||||
'no',
|
||||
'can',
|
||||
'will',
|
||||
'should',
|
||||
'could',
|
||||
'would',
|
||||
'may',
|
||||
'might',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'about',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'same',
|
||||
'up',
|
||||
'down',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'then',
|
||||
'here',
|
||||
'there',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'also',
|
||||
'only',
|
||||
'own',
|
||||
'so',
|
||||
'if',
|
||||
'while',
|
||||
'because',
|
||||
'until',
|
||||
'vs',
|
||||
'versus',
|
||||
'guide',
|
||||
'complete',
|
||||
'ultimate',
|
||||
'best',
|
||||
'top',
|
||||
'new',
|
||||
'first',
|
||||
'need',
|
||||
'know',
|
||||
'everything',
|
||||
);
|
||||
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ) use ( $stopwords ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Map analysis type to a reader-friendly article type.
|
||||
*
|
||||
|
||||
156
includes/core/class-keyword-extractor.php
Normal file
156
includes/core/class-keyword-extractor.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?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 ) );
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,19 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Public wrapper for orchestrate(): collect -> analyze -> score.
|
||||
* Used by Generation_Checkpoint_Runner (Tick 1) so orchestration and
|
||||
* article generation happen in separate cron ticks.
|
||||
*
|
||||
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
|
||||
* @return PRAutoBlogger_Article_Idea[]
|
||||
*/
|
||||
public function orchestrate_only( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
|
||||
return $this->orchestrate( $cost_tracker );
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -103,4 +103,41 @@ class PRAutoBlogger_Pipeline_Status {
|
||||
'pipeline'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an initial "running" status before the first tick fires.
|
||||
* Mirrors the shape on_ajax_generate_now() already writes so the poller
|
||||
* sees a consistent "running" state whether triggered from board or settings page.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function write_initial(): void {
|
||||
set_transient(
|
||||
self::KEY,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
self::TTL
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a terminal "error" status.
|
||||
*
|
||||
* @param string $message Operator-facing error description.
|
||||
* @return void
|
||||
*/
|
||||
public static function write_error( string $message ): void {
|
||||
set_transient(
|
||||
self::KEY,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
),
|
||||
self::TTL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ class PRAutoBlogger_Semantic_Dedup {
|
||||
return false;
|
||||
}
|
||||
foreach ( $this->recent_titles as $title ) {
|
||||
$title_keywords = $this->extract_meaningful_keywords( $title );
|
||||
$title_keywords = PRAutoBlogger_Keyword_Extractor::extract( $title );
|
||||
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
|
||||
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
||||
return true;
|
||||
@@ -241,131 +241,4 @@ class PRAutoBlogger_Semantic_Dedup {
|
||||
}
|
||||
return $titles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful keywords from text (fallback dedup).
|
||||
*
|
||||
* @param string $text Input text.
|
||||
* @return string[] Unique lowercase keywords.
|
||||
*/
|
||||
private function extract_meaningful_keywords( string $text ): array {
|
||||
static $stopwords = array(
|
||||
'a',
|
||||
'an',
|
||||
'the',
|
||||
'and',
|
||||
'or',
|
||||
'but',
|
||||
'is',
|
||||
'are',
|
||||
'was',
|
||||
'were',
|
||||
'in',
|
||||
'on',
|
||||
'at',
|
||||
'to',
|
||||
'for',
|
||||
'of',
|
||||
'with',
|
||||
'by',
|
||||
'from',
|
||||
'as',
|
||||
'it',
|
||||
'its',
|
||||
'this',
|
||||
'that',
|
||||
'your',
|
||||
'you',
|
||||
'how',
|
||||
'what',
|
||||
'when',
|
||||
'why',
|
||||
'where',
|
||||
'which',
|
||||
'who',
|
||||
'do',
|
||||
'does',
|
||||
'did',
|
||||
'not',
|
||||
'no',
|
||||
'can',
|
||||
'will',
|
||||
'should',
|
||||
'could',
|
||||
'would',
|
||||
'may',
|
||||
'might',
|
||||
'have',
|
||||
'has',
|
||||
'had',
|
||||
'be',
|
||||
'been',
|
||||
'being',
|
||||
'about',
|
||||
'into',
|
||||
'through',
|
||||
'during',
|
||||
'before',
|
||||
'after',
|
||||
'above',
|
||||
'below',
|
||||
'between',
|
||||
'same',
|
||||
'up',
|
||||
'down',
|
||||
'out',
|
||||
'off',
|
||||
'over',
|
||||
'under',
|
||||
'again',
|
||||
'then',
|
||||
'here',
|
||||
'there',
|
||||
'all',
|
||||
'each',
|
||||
'every',
|
||||
'both',
|
||||
'few',
|
||||
'more',
|
||||
'most',
|
||||
'other',
|
||||
'some',
|
||||
'such',
|
||||
'than',
|
||||
'too',
|
||||
'very',
|
||||
'just',
|
||||
'also',
|
||||
'only',
|
||||
'own',
|
||||
'so',
|
||||
'if',
|
||||
'while',
|
||||
'because',
|
||||
'until',
|
||||
'vs',
|
||||
'versus',
|
||||
'guide',
|
||||
'complete',
|
||||
'ultimate',
|
||||
'best',
|
||||
'top',
|
||||
'new',
|
||||
'first',
|
||||
'need',
|
||||
'know',
|
||||
'everything',
|
||||
);
|
||||
|
||||
$text = strtolower( $text );
|
||||
$words = preg_split( '/[^a-z0-9-]+/', $text, -1, PREG_SPLIT_NO_EMPTY );
|
||||
$words = array_filter(
|
||||
$words,
|
||||
static function ( string $w ) use ( $stopwords ): bool {
|
||||
return strlen( $w ) >= 2 && ! in_array( $w, $stopwords, true );
|
||||
}
|
||||
);
|
||||
|
||||
return array_values( array_unique( $words ) );
|
||||
}
|
||||
}
|
||||
|
||||
88
includes/providers/class-image-aspect-ratio.php
Normal file
88
includes/providers/class-image-aspect-ratio.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?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,56 +262,7 @@ class PRAutoBlogger_OpenRouter_Image_Batch {
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||
|
||||
// Snap to nearest standard aspect ratio.
|
||||
$aspects = array(
|
||||
array(
|
||||
'w' => 1,
|
||||
'h' => 1,
|
||||
'r' => 1.0,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 2,
|
||||
'r' => 1.5,
|
||||
),
|
||||
array(
|
||||
'w' => 2,
|
||||
'h' => 3,
|
||||
'r' => 0.6667,
|
||||
),
|
||||
array(
|
||||
'w' => 4,
|
||||
'h' => 3,
|
||||
'r' => 1.3333,
|
||||
),
|
||||
array(
|
||||
'w' => 3,
|
||||
'h' => 4,
|
||||
'r' => 0.75,
|
||||
),
|
||||
array(
|
||||
'w' => 16,
|
||||
'h' => 9,
|
||||
'r' => 1.7778,
|
||||
),
|
||||
array(
|
||||
'w' => 9,
|
||||
'h' => 16,
|
||||
'r' => 0.5625,
|
||||
),
|
||||
);
|
||||
$best = $aspects[0];
|
||||
$best_d = abs( $target - $best['r'] );
|
||||
foreach ( $aspects as $c ) {
|
||||
$d = abs( $target - $c['r'] );
|
||||
if ( $d < $best_d ) {
|
||||
$best = $c;
|
||||
$best_d = $d;
|
||||
}
|
||||
}
|
||||
$aspect = $best['w'] . ':' . $best['h'];
|
||||
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
|
||||
@@ -233,51 +233,10 @@ 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 = $this->snap_aspect_ratio( $width, $height );
|
||||
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||
|
||||
return array(
|
||||
'model' => $model,
|
||||
@@ -296,29 +255,6 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap pixel dimensions to the nearest supported aspect ratio string.
|
||||
*
|
||||
* @param int $width Width in pixels.
|
||||
* @param int $height Height in pixels.
|
||||
* @return string Aspect ratio string like "16:9".
|
||||
*/
|
||||
private function snap_aspect_ratio( int $width, int $height ): string {
|
||||
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
||||
$best = self::STANDARD_ASPECTS[0];
|
||||
$best_d = abs( $target - $best['ratio'] );
|
||||
|
||||
foreach ( self::STANDARD_ASPECTS as $candidate ) {
|
||||
$d = abs( $target - $candidate['ratio'] );
|
||||
if ( $d < $best_d ) {
|
||||
$best = $candidate;
|
||||
$best_d = $d;
|
||||
}
|
||||
}
|
||||
|
||||
return $best['w'] . ':' . $best['h'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the interface's dimension contract.
|
||||
*
|
||||
|
||||
@@ -2,19 +2,7 @@
|
||||
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
declare(strict_types=1);
|
||||
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
||||
// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent.
|
||||
|
||||
/**
|
||||
* Parallel image generation via curl_multi for Runware.
|
||||
|
||||
@@ -178,72 +178,15 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Runware has no dedicated "validate key" endpoint, so we send a minimal
|
||||
* authentication task and look for `taskType=authentication` in the
|
||||
* response data with no accompanying errors.
|
||||
* Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance).
|
||||
*
|
||||
* @see providers/class-runware-image-validator.php
|
||||
*/
|
||||
public function validate_credentials_detailed(): array {
|
||||
try {
|
||||
$api_key = $this->support->get_api_key();
|
||||
} catch ( \RuntimeException $e ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
$body = array(
|
||||
array(
|
||||
'taskType' => 'authentication',
|
||||
'apiKey' => $api_key,
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
PRAutoBlogger_Runware_Image_Support::API_URL,
|
||||
array(
|
||||
'timeout' => 15,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode( $body ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
|
||||
'debug' => $response->get_error_message(),
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
|
||||
if ( 200 !== $status ) {
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
|
||||
'debug' => substr( $raw, 0, 200 ),
|
||||
);
|
||||
}
|
||||
|
||||
$decoded = json_decode( $raw, true );
|
||||
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
$first = $decoded['errors'][0] ?? array();
|
||||
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
|
||||
return array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
|
||||
'debug' => substr( $msg, 0, 200 ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'status' => 'ok',
|
||||
'message' => __( 'Runware API key valid.', 'prautoblogger' ),
|
||||
);
|
||||
return PRAutoBlogger_Runware_Validator::validate( $this->support );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build the JSON task-array body for a single imageInference request.
|
||||
*
|
||||
|
||||
89
includes/providers/class-runware-image-validator.php
Normal file
89
includes/providers/class-runware-image-validator.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?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.20.1
|
||||
* Version: 0.22.0
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,10 +35,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.20.1' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.22.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
||||
// Default max cards shown per board column and per ideas-browser page.
|
||||
// Settings-backed (prautoblogger_board_column_limit, prautoblogger_ideas_per_page).
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE', 30 );
|
||||
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
|
||||
// request's completion budget (max_tokens) is RAISED by this amount so
|
||||
// reasoning can never consume the visible-content budget and return an empty
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
* kept a fallback copy, masking the gap until prod boot.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* php scripts/check-constants.php [--verbose]
|
||||
* php scripts/check-constants.php [--verbose] [--self-test]
|
||||
*
|
||||
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found.
|
||||
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found. Exit 2 = --self-test failed.
|
||||
*
|
||||
* Include/exclude summary (relative to repo root):
|
||||
* Defined-set source prautoblogger.php only
|
||||
@@ -29,23 +29,24 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
// How many lines above the bare use to look for a defined() guard.
|
||||
const CONSTANTS_GUARD_WINDOW = 4;
|
||||
const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && chains (wider risks false-positives).
|
||||
|
||||
// Source of truth for define()d constants -- test-bootstrap fallbacks excluded.
|
||||
const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php';
|
||||
|
||||
// Directories skipped when walking the shipped tree for references.
|
||||
const CONSTANTS_EXCLUDE_DIRS = ['tests', 'vendor', '.github', '.git', 'node_modules'];
|
||||
const CONSTANTS_EXCLUDE_DIRS = array( 'tests', 'vendor', '.github', '.git', 'node_modules' );
|
||||
|
||||
// Entry points for the reference scan (files and dirs relative to repo root).
|
||||
const CONSTANTS_SCAN_ROOTS = [
|
||||
const CONSTANTS_SCAN_ROOTS = array(
|
||||
'prautoblogger.php',
|
||||
'uninstall.php',
|
||||
'includes',
|
||||
'templates',
|
||||
];
|
||||
);
|
||||
|
||||
$verbose = in_array( '--verbose', $argv ?? [], true );
|
||||
$verbose = in_array( '--verbose', $argv ?? array(), true );
|
||||
$self_test = in_array( '--self-test', $argv ?? array(), true );
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Build the defined set from prautoblogger.php only.
|
||||
@@ -66,7 +67,7 @@ preg_match_all(
|
||||
);
|
||||
|
||||
/** @var array<string, int> $defined_set constant name => 1 */
|
||||
$defined_set = array_flip( $def_matches[1] ?? [] );
|
||||
$defined_set = array_flip( $def_matches[1] ?? array() );
|
||||
|
||||
if ( $verbose ) {
|
||||
echo 'Defined in ' . CONSTANTS_BOOTSTRAP_FILE . ' (' . count( $defined_set ) . "):\n";
|
||||
@@ -107,7 +108,7 @@ function constants_collect_php_files( string $dir, array &$files ): void {
|
||||
}
|
||||
|
||||
/** @var string[] $php_files */
|
||||
$php_files = [];
|
||||
$php_files = array();
|
||||
|
||||
foreach ( CONSTANTS_SCAN_ROOTS as $root ) {
|
||||
if ( ! file_exists( $root ) ) {
|
||||
@@ -132,11 +133,11 @@ if ( $verbose ) {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** @var array<int, array{string, int, string, string}> $errors */
|
||||
$errors = [];
|
||||
$errors = array();
|
||||
|
||||
foreach ( $php_files as $php_path ) {
|
||||
$raw = file_get_contents( $php_path );
|
||||
if ( $raw === false ) {
|
||||
if ( false === $raw ) {
|
||||
fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" );
|
||||
continue;
|
||||
}
|
||||
@@ -187,7 +188,7 @@ foreach ( $php_files as $php_path ) {
|
||||
}
|
||||
|
||||
if ( ! $guarded ) {
|
||||
$errors[] = [ $php_path, $idx + 1, $name, $raw_line ];
|
||||
$errors[] = array( $php_path, $idx + 1, $name, $raw_line );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,7 +198,7 @@ foreach ( $php_files as $php_path ) {
|
||||
// 4. Report results.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if ( $errors !== [] ) {
|
||||
if ( array() !== $errors ) {
|
||||
fwrite( STDERR, "\nFAIL -- Unguarded undefined PRAUTOBLOGGER_* constants:\n\n" );
|
||||
foreach ( $errors as [ $php_path, $lineno, $name, $line_text ] ) {
|
||||
fwrite( STDERR, " {$php_path}:{$lineno}: {$name}\n" );
|
||||
@@ -208,5 +209,44 @@ if ( $errors !== [] ) {
|
||||
exit( 1 );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Self-test mode: synthesize a missing constant and assert non-zero exit.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
if ( $self_test ) {
|
||||
// Temporarily inject a fake reference that must NOT be in prautoblogger.php.
|
||||
// The scan above will flag PRAUTOBLOGGER_SELFTEST_SENTINEL as unguarded.
|
||||
// We verify this script would have exited non-zero in that scenario.
|
||||
$sentinel = 'PRAUTOBLOGGER_SELFTEST_SENTINEL';
|
||||
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
|
||||
if ( false !== strpos( $bootstrap_src, $sentinel ) ) {
|
||||
fwrite( STDERR, "SELF-TEST FAIL: sentinel constant already defined in prautoblogger.php -- pick a unique name.\n" );
|
||||
exit( 2 );
|
||||
}
|
||||
|
||||
// If $errors is empty at this point, the real codebase is clean (good).
|
||||
// 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_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 ) ) {
|
||||
foreach ( $m[0] as $n ) {
|
||||
if ( ! isset( $defined_set[ $n ] ) ) {
|
||||
$tmp_errors[] = $n;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@unlink( $tmp_file );
|
||||
if ( ! in_array( $sentinel, $tmp_errors, true ) ) {
|
||||
fwrite( STDERR, "SELF-TEST FAIL: scanner did not detect unguarded sentinel constant.\n" );
|
||||
exit( 2 );
|
||||
}
|
||||
echo "SELF-TEST OK -- scanner correctly detected the synthetic missing constant.\n";
|
||||
exit( 0 );
|
||||
}
|
||||
|
||||
echo "OK -- All PRAUTOBLOGGER_* constants are production-defined or defined()-guarded.\n";
|
||||
exit( 0 );
|
||||
|
||||
@@ -98,7 +98,16 @@ function prab_render_board_card( array $card, string $column_key ): void {
|
||||
<h1 class="prab-board-heading">
|
||||
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
|
||||
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
|
||||
<?php if ( current_user_can( 'manage_options' ) ) : ?>
|
||||
<button type="button"
|
||||
id="prab-board-generate-now"
|
||||
class="prab-board-generate-btn button button-primary"
|
||||
aria-label="<?php esc_attr_e( 'Generate a new article now', 'prautoblogger' ); ?>">
|
||||
<?php esc_html_e( 'New Article', 'prautoblogger' ); ?>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</h1>
|
||||
<div id="prab-board-gen-status" class="prab-board-gen-status" aria-live="polite" style="display:none;"></div>
|
||||
|
||||
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">
|
||||
|
||||
|
||||
@@ -72,10 +72,11 @@ $base_url = admin_url( 'admin.php?page=prautoblogger-ideas' );
|
||||
<tbody>
|
||||
<?php foreach ( $rows as $row ) : ?>
|
||||
<?php
|
||||
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?: [];
|
||||
$decoded = json_decode( $row['metadata_json'] ?? '{}', true );
|
||||
$meta = is_array( $decoded ) ? $decoded : array();
|
||||
$suggested = $meta['suggested_title'] ?? '';
|
||||
$key_points = $meta['key_points'] ?? [];
|
||||
$keywords = $meta['target_keywords'] ?? [];
|
||||
$key_points = $meta['key_points'] ?? array();
|
||||
$keywords = $meta['target_keywords'] ?? array();
|
||||
$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'] );
|
||||
@@ -164,14 +165,18 @@ $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( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -18,13 +18,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
}
|
||||
|
||||
$base_url = admin_url( 'admin.php?page=prautoblogger-logs' );
|
||||
$level_counts = [
|
||||
$level_counts = array(
|
||||
'all' => __( 'All', 'prautoblogger' ),
|
||||
'error' => __( 'Errors', 'prautoblogger' ),
|
||||
'warning' => __( 'Warnings', 'prautoblogger' ),
|
||||
'info' => __( 'Info', 'prautoblogger' ),
|
||||
'debug' => __( 'Debug', 'prautoblogger' ),
|
||||
];
|
||||
);
|
||||
?>
|
||||
<div class="wrap ab-wrap">
|
||||
<div class="ab-header">
|
||||
@@ -46,7 +46,20 @@ $level_counts = [
|
||||
<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( [ 'level' => $lv, 's' => $search, 'paged' => 1 ], $base_url ) ); ?>"
|
||||
<a href="
|
||||
<?php
|
||||
echo esc_url(
|
||||
add_query_arg(
|
||||
array(
|
||||
'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>
|
||||
@@ -119,14 +132,18 @@ $level_counts = [
|
||||
<?php if ( $total_pages > 1 ) : ?>
|
||||
<div class="ab-log-pagination">
|
||||
<?php
|
||||
echo wp_kses_post( paginate_links( [
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
] ) );
|
||||
echo wp_kses_post(
|
||||
paginate_links(
|
||||
array(
|
||||
'base' => add_query_arg( 'paged', '%#%' ),
|
||||
'format' => '',
|
||||
'current' => $paged,
|
||||
'total' => $total_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
)
|
||||
)
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
$meta = [
|
||||
$meta = array(
|
||||
'topic' => get_post_meta( $post->ID, '_prautoblogger_topic', true ),
|
||||
'type' => get_post_meta( $post->ID, '_prautoblogger_article_type', true ),
|
||||
'model' => get_post_meta( $post->ID, '_prautoblogger_model_used', true ),
|
||||
@@ -22,24 +22,24 @@ $meta = [
|
||||
'seo_score' => get_post_meta( $post->ID, '_prautoblogger_seo_score', true ),
|
||||
'generated_at' => get_post_meta( $post->ID, '_prautoblogger_generated_at', true ),
|
||||
'keywords' => get_post_meta( $post->ID, '_prautoblogger_target_keywords', true ),
|
||||
];
|
||||
);
|
||||
?>
|
||||
<div class="prautoblogger-metabox">
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Generated:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['generated_at'] ?: __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
<?php echo esc_html( $meta['generated_at'] ? $meta['generated_at'] : __( 'Unknown', 'prautoblogger' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Topic:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['topic'] ?: '—' ); ?>
|
||||
<?php echo esc_html( $meta['topic'] ? $meta['topic'] : '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Type:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ?: 'article' ) ); ?>
|
||||
<?php echo esc_html( ucfirst( $meta['type'] ? $meta['type'] : 'article' ) ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Model:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( $meta['model'] ?: '—' ); ?>
|
||||
<?php echo esc_html( $meta['model'] ? $meta['model'] : '—' ); ?>
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Pipeline:', 'prautoblogger' ); ?></strong>
|
||||
@@ -47,8 +47,8 @@ $meta = [
|
||||
</p>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Editor Verdict:', 'prautoblogger' ); ?></strong>
|
||||
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ?: 'unknown' ); ?>">
|
||||
<?php echo esc_html( ucfirst( $meta['verdict'] ?: __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||
<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>
|
||||
</p>
|
||||
<?php if ( $meta['quality_score'] ) : ?>
|
||||
@@ -69,7 +69,7 @@ $meta = [
|
||||
<?php if ( $meta['keywords'] ) : ?>
|
||||
<p>
|
||||
<strong><?php esc_html_e( 'Keywords:', 'prautoblogger' ); ?></strong>
|
||||
<?php echo esc_html( implode( ', ', json_decode( $meta['keywords'], true ) ?: [] ) ); ?>
|
||||
<?php echo esc_html( implode( ', ', ( json_decode( $meta['keywords'], true ) ? json_decode( $meta['keywords'], true ) : array() ) ) ); ?>
|
||||
</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 ?: 'pending' );
|
||||
$verdict_class = 'prautoblogger-verdict-' . sanitize_html_class( $verdict ? $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 ?: '—' ); ?></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 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 '' !== $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">
|
||||
|
||||
@@ -209,6 +209,11 @@ if ( ! class_exists( 'WP_Post' ) ) {
|
||||
// Plugin URL and version constants used by admin enqueue methods.
|
||||
// Defined here so all tests that load admin classes can stub WP enqueue calls
|
||||
// without hitting "Undefined constant" errors.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
|
||||
}
|
||||
|
||||
// Plugin URL and version constants used by admin enqueue methods.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
|
||||
}
|
||||
|
||||
239
tests/unit/Admin/BoardGenLogQueryTest.php
Normal file
239
tests/unit/Admin/BoardGenLogQueryTest.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Board_Gen_Log_Query — binding order and result shape.
|
||||
*
|
||||
* The key regression: both get_generating_cards() and get_failed_cards() had
|
||||
* (a) a missing comma between bound args and (b) reversed argument order — the
|
||||
* integer limit was passed first, the datetime string second, which bound
|
||||
* created_at >= <int> and LIMIT <datetime>. These tests assert the correct
|
||||
* binding order (string first, int second) so any future regression is caught
|
||||
* before CI.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Admin
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Admin;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class BoardGenLogQueryTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Captured prepare() calls: each entry is ['sql' => string, 'args' => array].
|
||||
*
|
||||
* @var array<int, array{sql: string, args: array}>
|
||||
*/
|
||||
private array $prepare_calls = array();
|
||||
|
||||
/**
|
||||
* Build a fresh mock $wpdb whose get_results() always returns $rows.
|
||||
*
|
||||
* @param array $rows Rows to return from get_results().
|
||||
* @return \PHPUnit\Framework\MockObject\MockObject
|
||||
*/
|
||||
private function make_wpdb( array $rows = array() ): object {
|
||||
$wpdb = $this->create_mock_wpdb();
|
||||
$wpdb->prefix = 'wp_';
|
||||
$GLOBALS['wpdb'] = $wpdb;
|
||||
$self = $this;
|
||||
|
||||
$wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( string $sql, ...$args ) use ( $self ) {
|
||||
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
|
||||
$args = $args[0];
|
||||
}
|
||||
$self->prepare_calls[] = array( 'sql' => $sql, 'args' => $args );
|
||||
return 'PREPARED_SQL';
|
||||
}
|
||||
);
|
||||
|
||||
$wpdb->method( 'get_results' )->willReturn( $rows );
|
||||
return $wpdb;
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->prepare_calls = array();
|
||||
|
||||
Functions\when( 'get_transient' )->justReturn( false );
|
||||
Functions\when( 'admin_url' )->alias(
|
||||
function ( string $path = '' ) {
|
||||
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
|
||||
}
|
||||
);
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
if ( 'prautoblogger_board_column_limit' === $name ) {
|
||||
return 10;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
$this->prepare_calls = array();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ── get_generating_cards() binding order ────────────────────────────────
|
||||
|
||||
/**
|
||||
* get_generating_cards() must pass $since (string) as the FIRST bound arg
|
||||
* and the integer limit as the SECOND — matching placeholder order %s … %d.
|
||||
*
|
||||
* This would have caught the reversed-order bug (limit int passed first,
|
||||
* datetime string second) that silently bound created_at >= <int>.
|
||||
*/
|
||||
public function test_generating_cards_prepare_arg_order(): void {
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$query->get_generating_cards();
|
||||
|
||||
// Find the prepare() call containing LIMIT %d (the gen-log secondary query).
|
||||
$gen_call = null;
|
||||
foreach ( $this->prepare_calls as $call ) {
|
||||
if (
|
||||
str_contains( $call['sql'], 'LIMIT %d' ) &&
|
||||
str_contains( $call['sql'], 'post_id IS NULL' ) &&
|
||||
! str_contains( $call['sql'], "response_status = 'error'" )
|
||||
) {
|
||||
$gen_call = $call;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $gen_call, 'get_generating_cards() must call $wpdb->prepare() with LIMIT %d' );
|
||||
$this->assertCount( 2, $gen_call['args'], 'prepare() must receive exactly 2 bound args' );
|
||||
|
||||
// First arg must be a string (the $since datetime).
|
||||
$this->assertIsString( $gen_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
|
||||
// Second arg must be an integer (the column limit).
|
||||
$this->assertIsInt( $gen_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_generating_cards() must return an empty array (not error) when DB returns nothing.
|
||||
*/
|
||||
public function test_generating_cards_returns_empty_array_on_no_results(): void {
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$cards = $query->get_generating_cards();
|
||||
$this->assertIsArray( $cards );
|
||||
}
|
||||
|
||||
/**
|
||||
* When the status transient signals a running job, get_generating_cards()
|
||||
* returns a card from the transient (no DB call needed for that card).
|
||||
*/
|
||||
public function test_generating_cards_returns_transient_card_when_running(): void {
|
||||
$this->make_wpdb();
|
||||
|
||||
Functions\when( 'get_transient' )->alias(
|
||||
function ( $key ) {
|
||||
if ( 'prautoblogger_generation_status' === $key ) {
|
||||
return array(
|
||||
'status' => 'running',
|
||||
'stage' => 'Drafting…',
|
||||
'started' => time() - 30,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$cards = $query->get_generating_cards();
|
||||
|
||||
$this->assertNotEmpty( $cards, 'Transient running state must produce at least one card' );
|
||||
$this->assertSame( '', $cards[0]['run_id'], 'Transient card has empty run_id' );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
}
|
||||
|
||||
// ── get_failed_cards() binding order ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* get_failed_cards() must pass $since (string) as the FIRST bound arg
|
||||
* and the integer limit as the SECOND — matching placeholder order %s … %d.
|
||||
*/
|
||||
public function test_failed_cards_prepare_arg_order(): void {
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$query->get_failed_cards();
|
||||
|
||||
// Find the prepare() call for the failed query.
|
||||
$fail_call = null;
|
||||
foreach ( $this->prepare_calls as $call ) {
|
||||
if ( str_contains( $call['sql'], "response_status = 'error'" ) ) {
|
||||
$fail_call = $call;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $fail_call, 'get_failed_cards() must call $wpdb->prepare() with response_status filter' );
|
||||
$this->assertCount( 2, $fail_call['args'], 'prepare() must receive exactly 2 bound args' );
|
||||
|
||||
// First arg must be a string (the $since datetime).
|
||||
$this->assertIsString( $fail_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
|
||||
// Second arg must be an integer (the column limit).
|
||||
$this->assertIsInt( $fail_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
|
||||
}
|
||||
|
||||
/**
|
||||
* get_failed_cards() returns the correct card shape from a seeded result row.
|
||||
*/
|
||||
public function test_failed_cards_returns_correct_shape(): void {
|
||||
// Use a fresh wpdb with non-empty get_results rows.
|
||||
$this->make_wpdb(
|
||||
array(
|
||||
array(
|
||||
'run_id' => 'fail-run-abc',
|
||||
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
|
||||
'last_error' => 'Rate limit exceeded',
|
||||
'last_stage' => 'draft',
|
||||
'call_count' => '3',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$cards = $query->get_failed_cards();
|
||||
|
||||
$this->assertCount( 1, $cards );
|
||||
$this->assertSame( 'fail-run-abc', $cards[0]['run_id'] );
|
||||
$this->assertStringContainsString( 'Rate limit', $cards[0]['error_message'] );
|
||||
$this->assertSame( 'logs', $cards[0]['click_action'] );
|
||||
$this->assertArrayHasKey( 'started_at', $cards[0] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Column-limit option is honoured: limit 7 → second arg to prepare() is 7.
|
||||
*/
|
||||
public function test_column_limit_option_is_respected(): void {
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
if ( 'prautoblogger_board_column_limit' === $name ) {
|
||||
return 7;
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
);
|
||||
|
||||
$this->make_wpdb();
|
||||
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
|
||||
$query->get_generating_cards();
|
||||
|
||||
$gen_call = null;
|
||||
foreach ( $this->prepare_calls as $call ) {
|
||||
if ( str_contains( $call['sql'], 'LIMIT %d' ) && ! str_contains( $call['sql'], "response_status = 'error'" ) ) {
|
||||
$gen_call = $call;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $gen_call );
|
||||
$this->assertSame( 7, $gen_call['args'][1], 'Limit arg must match the option value' );
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,15 @@ class BoardHumanModifiedFlagTest extends BaseTestCase {
|
||||
);
|
||||
Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' );
|
||||
Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p );
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( string $name, $default = false ) {
|
||||
$map = array(
|
||||
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
|
||||
'prautoblogger_board_published_window_days' => 7,
|
||||
);
|
||||
return $map[ $name ] ?? $default;
|
||||
}
|
||||
);
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
|
||||
77
tests/unit/Admin/SettingsFieldsExtendedTest.php
Normal file
77
tests/unit/Admin/SettingsFieldsExtendedTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?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 +'
|
||||
);
|
||||
}
|
||||
}
|
||||
230
tests/unit/Core/GenerationCheckpointRunnerTest.php
Normal file
230
tests/unit/Core/GenerationCheckpointRunnerTest.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
|
||||
*
|
||||
* Locks the chained-cron checkpoint contract:
|
||||
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
|
||||
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class GenerationCheckpointRunnerTest 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 array<int, array> Transient writes captured from set_transient(). */
|
||||
private array $transients = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->scheduled = array();
|
||||
$this->options = array();
|
||||
$this->transients = array();
|
||||
|
||||
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' )->justReturn( true );
|
||||
Functions\when( 'wp_remote_post' )->justReturn( array() );
|
||||
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
|
||||
|
||||
Functions\when( 'set_transient' )->alias(
|
||||
function ( $key, $value, $ttl = 0 ) {
|
||||
$this->transients[] = array( 'key' => $key, 'value' => (array) $value );
|
||||
return 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( 'test-run-uuid-001' );
|
||||
// Monthly budget 0 = no limit.
|
||||
$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_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 = 'Test 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(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── kick_off() ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* kick_off() schedules exactly the ORCHESTRATE cron action and writes
|
||||
* an initial 'running' status transient. The pipeline is NOT called.
|
||||
*/
|
||||
public function test_kick_off_schedules_orchestrate_cron_and_writes_initial_transient(): void {
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
|
||||
|
||||
$this->assertContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION,
|
||||
$this->scheduled,
|
||||
'kick_off() must schedule ORCHESTRATE_ACTION'
|
||||
);
|
||||
|
||||
$found_running_transient = false;
|
||||
foreach ( $this->transients as $t ) {
|
||||
if ( isset( $t['value']['status'] ) && 'running' === $t['value']['status'] ) {
|
||||
$found_running_transient = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
|
||||
}
|
||||
|
||||
// ── on_generate_tick() ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* An empty queue causes generate tick to finalize without scheduling
|
||||
* another GENERATE action (no Article_Worker call).
|
||||
*/
|
||||
public function test_generate_tick_finalizes_when_queue_is_empty(): void {
|
||||
// No queue option => get_option returns false (default from setUp alias).
|
||||
// wire_wpdb_for_lock() provides $wpdb so Helpers::finalize() ->
|
||||
// Generation_Lock::release() can run without "Call to member on null".
|
||||
$this->wire_wpdb_for_lock();
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'Empty queue must not reschedule GENERATE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A halted run causes generate tick to abort: queue is cleaned up and
|
||||
* GENERATE is not rescheduled (Article_Worker would not be called again).
|
||||
*/
|
||||
public function test_generate_tick_aborts_on_halted_run_without_rescheduling(): void {
|
||||
$run_id = 'halted-run-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea() ),
|
||||
'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' => 'halted', 'ceiling_usd' => '0' ) );
|
||||
$wpdb->method( 'query' )->willReturn( 1 );
|
||||
$wpdb->method( 'get_var' )->willReturn( null );
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertArrayNotHasKey(
|
||||
'prautoblogger_article_queue',
|
||||
$this->options,
|
||||
'Halted run must trigger queue cleanup'
|
||||
);
|
||||
$this->assertNotContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'Halted run must not reschedule GENERATE'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* With 2 ideas in queue, first generate tick pops one, reschedules GENERATE,
|
||||
* and leaves 1 idea remaining in the persisted queue.
|
||||
*/
|
||||
public function test_generate_tick_pops_one_idea_and_reschedules_when_more_remain(): void {
|
||||
$run_id = 'multi-idea-run-uuid';
|
||||
$this->options['prautoblogger_article_queue'] = array(
|
||||
'run_id' => $run_id,
|
||||
'ideas' => array( $this->make_idea( 'Idea A' ), $this->make_idea( 'Idea B' ) ),
|
||||
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
|
||||
);
|
||||
$this->options['prautoblogger_checkpoint_run_id'] = $run_id;
|
||||
|
||||
$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 );
|
||||
|
||||
// Provide stub Article_Worker so CI doesn't need real LLM dependencies.
|
||||
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]; }
|
||||
}' );
|
||||
}
|
||||
if ( ! class_exists( 'PRAutoBlogger_Post_Assembler', false ) ) {
|
||||
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
|
||||
eval( 'class PRAutoBlogger_Post_Assembler { public static function amortize_research_costs($r){} }' );
|
||||
}
|
||||
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
|
||||
|
||||
$this->assertContains(
|
||||
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
|
||||
$this->scheduled,
|
||||
'More ideas remaining: GENERATE must be rescheduled'
|
||||
);
|
||||
if ( isset( $this->options['prautoblogger_article_queue'] ) ) {
|
||||
$remaining = $this->options['prautoblogger_article_queue']['ideas'] ?? array();
|
||||
$this->assertCount( 1, $remaining, 'Queue must have exactly 1 idea left after first tick' );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,9 @@ $hooks = array(
|
||||
'prautoblogger_opik_dispatch',
|
||||
'prautoblogger_rerun_stage_replay',
|
||||
'prautoblogger_rerun_from_stage',
|
||||
// v0.21.0 (M4): chained-cron checkpoint ticks.
|
||||
'prautoblogger_gen_orchestrate',
|
||||
'prautoblogger_gen_tick',
|
||||
);
|
||||
|
||||
foreach ( $hooks as $hook ) {
|
||||
|
||||
Reference in New Issue
Block a user