Compare commits
7 Commits
claude/dep
...
claude/ado
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5145cd92 | ||
|
|
0bac82be52 | ||
|
|
7217c154bb | ||
|
|
1813d419e5 | ||
|
|
3890485dcb | ||
|
|
d770e8a86a | ||
|
|
326a1af900 |
36
.distignore
Executable file
36
.distignore
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
# Files excluded from the production zip.
|
||||||
|
# Used by the GitHub Actions deploy workflow (rsync --exclude-from).
|
||||||
|
# Keep in sync with .gitignore for dev-only artifacts.
|
||||||
|
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.distignore
|
||||||
|
.gitignore
|
||||||
|
.phpcs.xml
|
||||||
|
.editorconfig
|
||||||
|
|
||||||
|
# Development & testing
|
||||||
|
tests/
|
||||||
|
phpunit.xml
|
||||||
|
phpunit.xml.dist
|
||||||
|
composer.json
|
||||||
|
composer.lock
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Documentation (lives in repo, not in deployed plugin)
|
||||||
|
ARCHITECTURE.md
|
||||||
|
CONVENTIONS.md
|
||||||
|
CHANGELOG.md
|
||||||
|
CODE_OF_CONDUCT.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
|
||||||
|
# CI/CD tooling
|
||||||
|
scripts/
|
||||||
|
node_modules/
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# OS artifacts
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.log
|
||||||
@@ -35,25 +35,33 @@ add_action( 'rest_api_init', 'prautoblogger_deploy_register_route' );
|
|||||||
* Register the deploy REST route.
|
* Register the deploy REST route.
|
||||||
*/
|
*/
|
||||||
function prautoblogger_deploy_register_route(): void {
|
function prautoblogger_deploy_register_route(): void {
|
||||||
register_rest_route( 'prautoblogger-deploy/v1', '/deploy', [
|
register_rest_route(
|
||||||
|
'prautoblogger-deploy/v1',
|
||||||
|
'/deploy',
|
||||||
|
array(
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
'callback' => 'prautoblogger_deploy_handle',
|
'callback' => 'prautoblogger_deploy_handle',
|
||||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
||||||
] );
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// Health check endpoint (GET) — useful for monitoring.
|
// Health check endpoint (GET) — useful for monitoring.
|
||||||
register_rest_route( 'prautoblogger-deploy/v1', '/status', [
|
register_rest_route(
|
||||||
|
'prautoblogger-deploy/v1',
|
||||||
|
'/status',
|
||||||
|
array(
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'callback' => function () {
|
'callback' => function () {
|
||||||
return [
|
return array(
|
||||||
'status' => 'ready',
|
'status' => 'ready',
|
||||||
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
'plugin' => is_dir( WP_PLUGIN_DIR . '/prautoblogger' ) ? 'installed' : 'not_installed',
|
||||||
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
'active' => is_plugin_active( 'prautoblogger/prautoblogger.php' ),
|
||||||
'version' => prautoblogger_deploy_get_installed_version(),
|
'version' => prautoblogger_deploy_get_installed_version(),
|
||||||
];
|
);
|
||||||
},
|
},
|
||||||
'permission_callback' => 'prautoblogger_deploy_check_auth',
|
'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(
|
return new WP_Error(
|
||||||
'rate_limited',
|
'rate_limited',
|
||||||
'Deploy in progress or recently completed. Wait 60 seconds.',
|
'Deploy in progress or recently completed. Wait 60 seconds.',
|
||||||
[ 'status' => 429 ]
|
array( 'status' => 429 )
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
set_transient( 'prautoblogger_deploy_lock', time(), 60 );
|
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();
|
$files = $request->get_file_params();
|
||||||
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
|
if ( empty( $files['plugin']['tmp_name'] ) || ! is_uploaded_file( $files['plugin']['tmp_name'] ) ) {
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
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'];
|
$tmp_file = $files['plugin']['tmp_name'];
|
||||||
@@ -96,11 +104,11 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
if ( true !== $zip->open( $tmp_file ) ) {
|
if ( true !== $zip->open( $tmp_file ) ) {
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
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;
|
$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 ) ) {
|
if ( 'prautoblogger/prautoblogger.php' === $zip->getNameIndex( $i ) ) {
|
||||||
$has_main_file = true;
|
$has_main_file = true;
|
||||||
break;
|
break;
|
||||||
@@ -113,7 +121,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'invalid_plugin',
|
'invalid_plugin',
|
||||||
'Zip must contain prautoblogger/prautoblogger.php at root.',
|
'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' );
|
activate_plugin( 'prautoblogger/prautoblogger.php' );
|
||||||
}
|
}
|
||||||
delete_transient( 'prautoblogger_deploy_lock' );
|
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' );
|
delete_transient( 'prautoblogger_deploy_lock' );
|
||||||
error_log( '[PRAutoBlogger Deploy] Extraction failed. Rolled back to previous version.' );
|
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.
|
// 7. Activate plugin.
|
||||||
@@ -178,7 +186,7 @@ function prautoblogger_deploy_handle( WP_REST_Request $request ): WP_REST_Respon
|
|||||||
return new WP_Error(
|
return new WP_Error(
|
||||||
'activation_failed',
|
'activation_failed',
|
||||||
'Plugin activation failed: ' . $activation_result->get_error_message() . '. Rolled back.',
|
'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();
|
$old_version = prautoblogger_deploy_get_installed_version();
|
||||||
error_log( sprintf(
|
error_log(
|
||||||
|
sprintf(
|
||||||
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
'[PRAutoBlogger Deploy] Successfully deployed v%s (was: %s) at %s',
|
||||||
$incoming_version,
|
$incoming_version,
|
||||||
$old_version ?: 'fresh install',
|
$old_version ? $old_version : 'fresh install',
|
||||||
gmdate( 'Y-m-d H:i:s' )
|
gmdate( 'Y-m-d H:i:s' )
|
||||||
) );
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return new WP_REST_Response( [
|
return new WP_REST_Response(
|
||||||
|
array(
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'version' => $incoming_version,
|
'version' => $incoming_version,
|
||||||
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
'message' => "PRAutoBlogger v{$incoming_version} deployed and activated.",
|
||||||
], 200 );
|
),
|
||||||
|
200
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,7 +223,7 @@ function prautoblogger_deploy_get_installed_version(): string {
|
|||||||
if ( ! file_exists( $main_file ) ) {
|
if ( ! file_exists( $main_file ) ) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
$data = get_file_data( $main_file, [ 'Version' => 'Version' ] );
|
$data = get_file_data( $main_file, array( 'Version' => 'Version' ) );
|
||||||
return $data['Version'] ?? '';
|
return $data['Version'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
130
.github/workflows/ci.yml
vendored
130
.github/workflows/ci.yml
vendored
@@ -1,111 +1,53 @@
|
|||||||
name: CI — Lint & Test
|
##
|
||||||
|
# CI caller — PRAutoBlogger
|
||||||
# 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
|
# Thin caller that delegates to the reusable estate CI workflow in peptide-e2e.
|
||||||
# run its own validate job as a belt-and-suspenders check, but this CI workflow
|
# workflow_call is required so deploy.yml can gate on this workflow via
|
||||||
# is what gives PRs an immediate signal so fixes happen before merge.
|
# `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:
|
on:
|
||||||
|
pull_request: {}
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
workflow_call: {}
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
php-lint:
|
ci:
|
||||||
name: PHP Lint (${{ matrix.php }})
|
uses: peptiderepo/peptide-e2e/.github/workflows/ci.yml@main
|
||||||
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:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
tests: brain-monkey
|
||||||
coverage: none
|
secrets: inherit
|
||||||
- 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:
|
check-constants:
|
||||||
name: PHPCS (WordPress standards)
|
name: Check PRAUTOBLOGGER_* constants
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
- name: Setup PHP 8.1
|
||||||
ref: ${{ github.head_ref || github.ref_name }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: '8.1'
|
php-version: '8.1'
|
||||||
coverage: none
|
coverage: none
|
||||||
tools: composer:v2, phpcs
|
- name: Check PRAUTOBLOGGER_* constants
|
||||||
- name: Install WordPress Coding Standards
|
# Fails if any PRAUTOBLOGGER_* constant referenced in the shipped
|
||||||
run: |
|
# plugin PHP is neither defined in prautoblogger.php nor guarded
|
||||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
# by defined() at the reference site. Prevents the v0.19.0 incident
|
||||||
composer global require --no-interaction \
|
# class (constant deleted from bootstrap, test fallback masks the gap).
|
||||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
run: php scripts/check-constants.php
|
||||||
"wp-coding-standards/wpcs:^3.0" \
|
- name: Self-test check-constants scanner
|
||||||
"phpcsstandards/phpcsutils:^1.0" \
|
# Verifies the scanner itself can detect the failure cases it was
|
||||||
"phpcsstandards/phpcsextra:^1.0"
|
# designed to catch. If the scanner regresses, this step fails CI.
|
||||||
- name: Auto-fix with phpcbf (commit back to branch if changed)
|
# Added v0.21.0; restored in v0.22.0 (was accidentally dropped in rewrite).
|
||||||
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
|
run: php scripts/check-constants.php --self-test
|
||||||
# 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
|
|
||||||
|
|||||||
26
.github/workflows/deploy.yml
vendored
26
.github/workflows/deploy.yml
vendored
@@ -9,28 +9,16 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate:
|
ci:
|
||||||
name: PHP Lint Gate
|
name: CI Gate (reusable)
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/ci.yml
|
||||||
steps:
|
permissions:
|
||||||
- uses: actions/checkout@v4
|
contents: write
|
||||||
- uses: shivammathur/setup-php@v2
|
secrets: inherit
|
||||||
with:
|
|
||||||
php-version: '8.1'
|
|
||||||
coverage: none
|
|
||||||
- name: PHP Lint Check
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
find . -name '*.php' \
|
|
||||||
-not -path './vendor/*' \
|
|
||||||
-not -path './node_modules/*' \
|
|
||||||
-not -path './.git/*' \
|
|
||||||
-not -path './.github/*' \
|
|
||||||
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy (shared pipeline)
|
name: Deploy (shared pipeline)
|
||||||
needs: validate
|
needs: ci
|
||||||
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
uses: peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main
|
||||||
with:
|
with:
|
||||||
target_path: wp-content/plugins/prautoblogger
|
target_path: wp-content/plugins/prautoblogger
|
||||||
|
|||||||
@@ -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.
|
**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.
|
**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.
|
||||||
|
|||||||
103
CHANGELOG.md
103
CHANGELOG.md
@@ -5,15 +5,104 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
|||||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project uses [Semantic Versioning](https://semver.org/).
|
and this project uses [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
## [0.20.2] - 2026-06-13
|
## [Unreleased - Internal]
|
||||||
|
|
||||||
|
### 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
|
### Removed
|
||||||
- **`scripts/deploy.sh`** — belonged to the retired zip/REST deploy receiver flow
|
- **SSH-only workaround retired**: `wp eval 'do_action("prautoblogger_manual_generation")'` no
|
||||||
(`/wp-json/prautoblogger-deploy/v1/*`); the live pipeline is the shared rsync workflow
|
longer fires the pipeline synchronously in one PHP process. Use `wp prautoblogger generate`
|
||||||
(`peptiderepo/peptide-e2e/.github/workflows/deploy-app.yml@main`).
|
instead (routes through the chained-cron checkpoint machine). See ARCHITECTURE.md §25.
|
||||||
- **`.distignore`** — used by `scripts/deploy.sh` to build the production zip; no longer
|
|
||||||
consumed by any workflow (the shared rsync pipeline uses hardcoded `--exclude` flags).
|
|
||||||
|
|
||||||
|
|
||||||
## [0.20.1] - 2026-06-12
|
## [0.20.1] - 2026-06-12
|
||||||
|
|
||||||
|
|||||||
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( 'in_review', snapshot.in_review || [] );
|
||||||
renderColumn( 'published', snapshot.published || [] );
|
renderColumn( 'published', snapshot.published || [] );
|
||||||
renderColumn( 'failed', snapshot.failed || [] );
|
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 ) );
|
}( jQuery ) );
|
||||||
|
|||||||
@@ -19,5 +19,8 @@
|
|||||||
"psr-4": {
|
"psr-4": {
|
||||||
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
"PRAutoBlogger\\Tests\\": "tests/unit/"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vendor/bin/phpunit --testdox"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,84 +270,14 @@ class PRAutoBlogger_Admin_Page {
|
|||||||
/**
|
/**
|
||||||
* Sanitize a settings field value.
|
* Sanitize a settings field value.
|
||||||
*
|
*
|
||||||
|
* Delegates to PRAutoBlogger_Settings_Sanitizer (extracted for 300-line compliance).
|
||||||
|
*
|
||||||
* @param mixed $value The submitted value.
|
* @param mixed $value The submitted value.
|
||||||
* @return mixed Sanitized value.
|
* @return mixed Sanitized value.
|
||||||
|
*
|
||||||
|
* @see admin/class-settings-sanitizer.php
|
||||||
*/
|
*/
|
||||||
public function sanitize_field( $value ) {
|
public function sanitize_field( $value ) {
|
||||||
$option_name = '';
|
return PRAutoBlogger_Settings_Sanitizer::sanitize_field( $value );
|
||||||
$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 );
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
|||||||
array(
|
array(
|
||||||
'post_type' => 'post',
|
'post_type' => 'post',
|
||||||
'post_status' => 'draft',
|
'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(
|
'meta_query' => array(
|
||||||
array(
|
array(
|
||||||
'key' => '_prautoblogger_generated',
|
'key' => '_prautoblogger_generated',
|
||||||
@@ -144,7 +144,7 @@ class PRAutoBlogger_Board_Data_Provider {
|
|||||||
array(
|
array(
|
||||||
'post_type' => 'post',
|
'post_type' => 'post',
|
||||||
'post_status' => 'publish',
|
'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(
|
'date_query' => array(
|
||||||
array(
|
array(
|
||||||
'after' => $date_after,
|
'after' => $date_after,
|
||||||
|
|||||||
@@ -76,8 +76,9 @@ class PRAutoBlogger_Board_Gen_Log_Query {
|
|||||||
AND post_id IS NULL
|
AND post_id IS NULL
|
||||||
GROUP BY run_id
|
GROUP BY run_id
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 10",
|
LIMIT %d",
|
||||||
$since
|
$since,
|
||||||
|
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
);
|
||||||
@@ -148,8 +149,9 @@ class PRAutoBlogger_Board_Gen_Log_Query {
|
|||||||
AND post_id IS NULL
|
AND post_id IS NULL
|
||||||
GROUP BY run_id
|
GROUP BY run_id
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 20",
|
LIMIT %d",
|
||||||
$since
|
$since,
|
||||||
|
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,27 +4,27 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
* 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.
|
* Board columns: Generating | In Review | Published | Failed.
|
||||||
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
|
* 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):
|
* Card click-throughs (M1):
|
||||||
* - Generating → Activity Log filtered to run context
|
* - Generating -> Activity Log filtered to run context
|
||||||
* - In Review → Review Queue
|
* - In Review -> Review Queue
|
||||||
* - Published → Post edit screen
|
* - Published -> Post edit screen
|
||||||
* - Failed → Activity Log
|
* - Failed -> Activity Log
|
||||||
*
|
*
|
||||||
* M2 will rewire all click-throughs to the Article Dossier page.
|
* M2 will rewire all click-throughs to the Article Dossier page.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||||
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
|
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
|
||||||
*
|
*
|
||||||
* @see admin/class-board-data-provider.php — Supplies card data.
|
* @see admin/class-board-data-provider.php -- Supplies card data.
|
||||||
* @see templates/admin/board-page.php — HTML template.
|
* @see templates/admin/board-page.php -- HTML template.
|
||||||
* @see assets/js/board.js — Polling + DOM updates.
|
* @see assets/js/board.js -- Polling + DOM updates.
|
||||||
* @see ARCHITECTURE.md — §Board (kanban dashboard).
|
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Board_Page {
|
class PRAutoBlogger_Board_Page {
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ class PRAutoBlogger_Board_Page {
|
|||||||
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
|
||||||
* runs here, so get_plugin_page_hookname() resolves to the correct
|
* runs here, so get_plugin_page_hookname() resolves to the correct
|
||||||
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
|
* '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
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -128,6 +128,13 @@ class PRAutoBlogger_Board_Page {
|
|||||||
PRAUTOBLOGGER_VERSION,
|
PRAUTOBLOGGER_VERSION,
|
||||||
true
|
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 ) );
|
$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 ) );
|
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
|
||||||
@@ -138,6 +145,7 @@ class PRAutoBlogger_Board_Page {
|
|||||||
array(
|
array(
|
||||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||||
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
|
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
|
||||||
|
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
|
||||||
'pollInterval' => $poll_interval * 1000,
|
'pollInterval' => $poll_interval * 1000,
|
||||||
'publishedWindowDays' => $published_days,
|
'publishedWindowDays' => $published_days,
|
||||||
'action' => self::AJAX_ACTION,
|
'action' => self::AJAX_ACTION,
|
||||||
@@ -151,8 +159,12 @@ class PRAutoBlogger_Board_Page {
|
|||||||
'view' => __( 'View', 'prautoblogger' ),
|
'view' => __( 'View', 'prautoblogger' ),
|
||||||
'edit' => __( 'Edit', 'prautoblogger' ),
|
'edit' => __( 'Edit', 'prautoblogger' ),
|
||||||
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
'viewLog' => __( 'View Log', 'prautoblogger' ),
|
||||||
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
|
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
|
||||||
'humanModified' => __( 'Human-modified', '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.
|
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
|
||||||
*
|
*
|
||||||
* Side effects: DB reads only — no writes.
|
* Side effects: DB reads only -- no writes.
|
||||||
*
|
*
|
||||||
* @return void
|
* @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`.
|
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
|
||||||
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
|
* 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 core/class-article-worker.php — Generates articles from ideas.
|
||||||
* @see templates/admin/ideas-browser.php — Renders the HTML.
|
* @see templates/admin/ideas-browser.php — Renders the HTML.
|
||||||
|
* @see class-ideas-browser-query.php — Static query helpers.
|
||||||
*/
|
*/
|
||||||
class PRAutoBlogger_Ideas_Browser {
|
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. */
|
/** Register the Ideas submenu page under PRAutoBlogger. */
|
||||||
public function on_register_menu(): void {
|
public function on_register_menu(): void {
|
||||||
add_submenu_page(
|
add_submenu_page(
|
||||||
@@ -43,11 +37,12 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
|
||||||
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
|
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
|
||||||
|
|
||||||
$result = $this->query_ideas( $paged, $type_filter );
|
$result = PRAutoBlogger_Ideas_Browser_Query::query_ideas( $paged, $type_filter );
|
||||||
$rows = $result['rows'];
|
$rows = $result['rows'];
|
||||||
$total = $result['total'];
|
$total = $result['total'];
|
||||||
$total_pages = (int) ceil( $total / self::PER_PAGE );
|
$per_page_view = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', PRAutoBlogger_Ideas_Browser_Query::PER_PAGE_DEFAULT ) );
|
||||||
$type_counts = $this->get_type_counts();
|
$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';
|
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
|
||||||
}
|
}
|
||||||
@@ -74,23 +69,26 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$status_prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
|
||||||
|
$status_ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
|
||||||
|
|
||||||
// Build Article_Idea from the analysis row.
|
// 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 ) {
|
if ( null === $idea_data ) {
|
||||||
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
|
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store idea for the cron handler and set initial "running" status.
|
// 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(
|
set_transient(
|
||||||
self::STATUS_PREFIX . $idea_id,
|
$status_prefix . $idea_id,
|
||||||
array(
|
array(
|
||||||
'status' => 'running',
|
'status' => 'running',
|
||||||
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
'stage' => __( 'Starting generation…', 'prautoblogger' ),
|
||||||
'started' => time(),
|
'started' => time(),
|
||||||
),
|
),
|
||||||
self::STATUS_TTL
|
$status_ttl
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schedule background cron — passes idea_id as argument.
|
// Schedule background cron — passes idea_id as argument.
|
||||||
@@ -124,7 +122,7 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$idea_id = absint( $_POST['idea_id'] ?? 0 );
|
$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 ) ) {
|
if ( ! is_array( $status ) ) {
|
||||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||||
return;
|
return;
|
||||||
@@ -149,8 +147,11 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||||
@set_time_limit( 300 );
|
@set_time_limit( 300 );
|
||||||
|
|
||||||
$key = self::STATUS_PREFIX . $idea_id;
|
$prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
|
||||||
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
$ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
|
||||||
|
$key = $prefix . $idea_id;
|
||||||
|
|
||||||
|
$idea_data = get_transient( $prefix . 'data_' . $idea_id );
|
||||||
if ( ! is_array( $idea_data ) ) {
|
if ( ! is_array( $idea_data ) ) {
|
||||||
set_transient(
|
set_transient(
|
||||||
$key,
|
$key,
|
||||||
@@ -158,7 +159,7 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Idea data expired.',
|
'message' => 'Idea data expired.',
|
||||||
),
|
),
|
||||||
self::STATUS_TTL
|
$ttl
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -170,7 +171,7 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => 'Another generation is running.',
|
'message' => 'Another generation is running.',
|
||||||
),
|
),
|
||||||
self::STATUS_TTL
|
$ttl
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -180,7 +181,7 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
|
||||||
$cost_tracker->set_run_id( wp_generate_uuid4() );
|
$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 );
|
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
|
||||||
$result = $worker->generate( $idea );
|
$result = $worker->generate( $idea );
|
||||||
|
|
||||||
@@ -188,7 +189,7 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
|
||||||
|
|
||||||
// Find the generated post ID for the "View" link.
|
// 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(
|
set_transient(
|
||||||
$key,
|
$key,
|
||||||
@@ -199,7 +200,7 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
'cost' => $result['cost'],
|
'cost' => $result['cost'],
|
||||||
'post_id' => $post_id,
|
'post_id' => $post_id,
|
||||||
),
|
),
|
||||||
self::STATUS_TTL
|
$ttl
|
||||||
);
|
);
|
||||||
} catch ( \Throwable $e ) {
|
} catch ( \Throwable $e ) {
|
||||||
PRAutoBlogger_Logger::instance()->error(
|
PRAutoBlogger_Logger::instance()->error(
|
||||||
@@ -212,126 +213,11 @@ class PRAutoBlogger_Ideas_Browser {
|
|||||||
'status' => 'error',
|
'status' => 'error',
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
),
|
),
|
||||||
self::STATUS_TTL
|
$ttl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
PRAutoBlogger_Generation_Lock::release();
|
PRAutoBlogger_Generation_Lock::release();
|
||||||
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
|
delete_transient( $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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public static function get_fields(): array {
|
public static function get_fields(): array {
|
||||||
return array(
|
return array_merge(
|
||||||
|
array(
|
||||||
// ── Sources (extended) ──────────────────────────────────────
|
// ── Sources (extended) ──────────────────────────────────────
|
||||||
array(
|
array(
|
||||||
'id' => 'prautoblogger_research_model',
|
'id' => 'prautoblogger_research_model',
|
||||||
@@ -220,97 +221,8 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
|||||||
'default' => '1',
|
'default' => '1',
|
||||||
'description' => __( 'Add visible borders, padding, and alternating row colors to tables in generated articles.', 'prautoblogger' ),
|
'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
|
// 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(
|
\WP_CLI::add_command(
|
||||||
'prautoblogger opik:eval',
|
'prautoblogger opik:eval',
|
||||||
array( self::class, 'opik_eval_command' ),
|
array( self::class, 'opik_eval_command' ),
|
||||||
@@ -69,4 +77,27 @@ class PRAutoBlogger_WP_CLI_Commands {
|
|||||||
|
|
||||||
exit( $result['items_run'] > 0 ? 0 : 1 );
|
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.' );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ class PRAutoBlogger_Activator {
|
|||||||
'prautoblogger_schedule_time' => '03:00',
|
'prautoblogger_schedule_time' => '03:00',
|
||||||
'prautoblogger_log_level' => 'info',
|
'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 ) {
|
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
|
* Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
|
||||||
* chained cron events — each in its own PHP process. The lock is released
|
* runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
|
||||||
* by the pipeline when the last article completes, not here.
|
* 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 {
|
public function on_manual_generation(): void {
|
||||||
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
|
||||||
@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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
|
|||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
finished_at DATETIME DEFAULT NULL,
|
finished_at DATETIME DEFAULT NULL,
|
||||||
PRIMARY KEY (run_id),
|
PRIMARY KEY (run_id),
|
||||||
KEY status (status)
|
KEY status (status),
|
||||||
|
KEY started_at (started_at)
|
||||||
) {$charset_collate};";
|
) {$charset_collate};";
|
||||||
|
|
||||||
// Per-run per-stage state rows. The idempotency key is
|
// 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::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 );
|
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_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_Research_Reaper', 'on_cron' ) );
|
||||||
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_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,
|
metadata_json LONGTEXT DEFAULT NULL,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY type_relevance (analysis_type, relevance_score),
|
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};";
|
) {$charset_collate};";
|
||||||
|
|
||||||
// Generation log — every API call with cost tracking. `run_id`
|
// Generation log — every API call with cost tracking. `run_id`
|
||||||
@@ -100,7 +101,9 @@ class PRAutoBlogger_Schema_Installer {
|
|||||||
KEY post_id (post_id),
|
KEY post_id (post_id),
|
||||||
KEY run_id (run_id),
|
KEY run_id (run_id),
|
||||||
KEY created_at (created_at),
|
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};";
|
) {$charset_collate};";
|
||||||
|
|
||||||
// Content scores — post performance metrics for self-improvement.
|
// 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.
|
* API is unavailable.
|
||||||
*
|
*
|
||||||
* Triggered by: PRAutoBlogger_Pipeline_Runner (step 3).
|
* 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-analyzer.php — Produces the analysis results we consume.
|
||||||
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
* @see core/class-content-generator.php — Consumes the ranked ideas we produce.
|
||||||
* @see ARCHITECTURE.md — Data flow step 3.
|
* @see ARCHITECTURE.md — Data flow step 3.
|
||||||
@@ -67,7 +68,7 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
|
|
||||||
if ( ! $this->skip_dedup ) {
|
if ( ! $this->skip_dedup ) {
|
||||||
$topic = $result->get_topic();
|
$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 ) ) {
|
if ( $dedup->is_batch_duplicate( $topic, $topic_keywords, $accepted_keyword_sets ) ) {
|
||||||
++$deduped_count;
|
++$deduped_count;
|
||||||
@@ -165,133 +166,6 @@ class PRAutoBlogger_Idea_Scorer {
|
|||||||
return false;
|
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.
|
* 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 ─────────────────────────────────────────────────
|
// ── Private helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,4 +103,41 @@ class PRAutoBlogger_Pipeline_Status {
|
|||||||
'pipeline'
|
'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;
|
return false;
|
||||||
}
|
}
|
||||||
foreach ( $this->recent_titles as $title ) {
|
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 ) );
|
$overlap = count( array_intersect( $topic_keywords, $title_keywords ) );
|
||||||
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
if ( $overlap / count( $topic_keywords ) >= 0.6 ) {
|
||||||
return true;
|
return true;
|
||||||
@@ -241,131 +241,4 @@ class PRAutoBlogger_Semantic_Dedup {
|
|||||||
}
|
}
|
||||||
return $titles;
|
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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
|
||||||
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
|
$aspect = PRAutoBlogger_Image_Aspect_Ratio::snap( $width, $height );
|
||||||
|
|
||||||
// Snap to nearest standard aspect ratio.
|
|
||||||
$aspects = array(
|
|
||||||
array(
|
|
||||||
'w' => 1,
|
|
||||||
'h' => 1,
|
|
||||||
'r' => 1.0,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'w' => 3,
|
|
||||||
'h' => 2,
|
|
||||||
'r' => 1.5,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'w' => 2,
|
|
||||||
'h' => 3,
|
|
||||||
'r' => 0.6667,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'w' => 4,
|
|
||||||
'h' => 3,
|
|
||||||
'r' => 1.3333,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'w' => 3,
|
|
||||||
'h' => 4,
|
|
||||||
'r' => 0.75,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'w' => 16,
|
|
||||||
'h' => 9,
|
|
||||||
'r' => 1.7778,
|
|
||||||
),
|
|
||||||
array(
|
|
||||||
'w' => 9,
|
|
||||||
'h' => 16,
|
|
||||||
'r' => 0.5625,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
$best = $aspects[0];
|
|
||||||
$best_d = abs( $target - $best['r'] );
|
|
||||||
foreach ( $aspects as $c ) {
|
|
||||||
$d = abs( $target - $c['r'] );
|
|
||||||
if ( $d < $best_d ) {
|
|
||||||
$best = $c;
|
|
||||||
$best_d = $d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$aspect = $best['w'] . ':' . $best['h'];
|
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'model' => $model,
|
'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 {
|
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(
|
return array(
|
||||||
'model' => $model,
|
'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.
|
* Enforce the interface's dimension contract.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,19 +2,7 @@
|
|||||||
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
|
// phpcs:disable WordPress.WP.AlternativeFunctions -- curl_multi required for parallel image generation; no WP HTTP equivalent.
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
|
|
||||||
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parallel image generation via curl_multi for Runware.
|
* Parallel image generation via curl_multi for Runware.
|
||||||
|
|||||||
@@ -178,71 +178,14 @@ class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provid
|
|||||||
/**
|
/**
|
||||||
* {@inheritDoc}
|
* {@inheritDoc}
|
||||||
*
|
*
|
||||||
* Runware has no dedicated "validate key" endpoint, so we send a minimal
|
* Delegates to PRAutoBlogger_Runware_Validator (extracted for 300-line compliance).
|
||||||
* authentication task and look for `taskType=authentication` in the
|
*
|
||||||
* response data with no accompanying errors.
|
* @see providers/class-runware-image-validator.php
|
||||||
*/
|
*/
|
||||||
public function validate_credentials_detailed(): array {
|
public function validate_credentials_detailed(): array {
|
||||||
try {
|
return PRAutoBlogger_Runware_Validator::validate( $this->support );
|
||||||
$api_key = $this->support->get_api_key();
|
|
||||||
} catch ( \RuntimeException $e ) {
|
|
||||||
return array(
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => $e->getMessage(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = array(
|
|
||||||
array(
|
|
||||||
'taskType' => 'authentication',
|
|
||||||
'apiKey' => $api_key,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = wp_remote_post(
|
|
||||||
PRAutoBlogger_Runware_Image_Support::API_URL,
|
|
||||||
array(
|
|
||||||
'timeout' => 15,
|
|
||||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
|
||||||
'body' => wp_json_encode( $body ),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( is_wp_error( $response ) ) {
|
|
||||||
return array(
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
|
|
||||||
'debug' => $response->get_error_message(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
|
||||||
$raw = (string) wp_remote_retrieve_body( $response );
|
|
||||||
|
|
||||||
if ( 200 !== $status ) {
|
|
||||||
return array(
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
|
|
||||||
'debug' => substr( $raw, 0, 200 ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode( $raw, true );
|
|
||||||
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
|
||||||
$first = $decoded['errors'][0] ?? array();
|
|
||||||
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
|
|
||||||
return array(
|
|
||||||
'status' => 'error',
|
|
||||||
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
|
|
||||||
'debug' => substr( $msg, 0, 200 ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array(
|
|
||||||
'status' => 'ok',
|
|
||||||
'message' => __( 'Runware API key valid.', 'prautoblogger' ),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the JSON task-array body for a single imageInference request.
|
* 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 Name: PRAutoBlogger
|
||||||
* Plugin URI: https://peptiderepo.com/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.
|
* 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.2
|
* Version: 0.22.0
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 7.4
|
* Requires PHP: 7.4
|
||||||
* Author: PeptideRepo
|
* Author: PeptideRepo
|
||||||
@@ -35,10 +35,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
| Defined here so every file in the plugin can reference paths, versions,
|
| Defined here so every file in the plugin can reference paths, versions,
|
||||||
| and limits without magic strings.
|
| and limits without magic strings.
|
||||||
*/
|
*/
|
||||||
define( 'PRAUTOBLOGGER_VERSION', '0.20.2' );
|
define( 'PRAUTOBLOGGER_VERSION', '0.22.0' );
|
||||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' );
|
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
|
||||||
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
|
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
|
// 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
|
// request's completion budget (max_tokens) is RAISED by this amount so
|
||||||
// reasoning can never consume the visible-content budget and return an empty
|
// reasoning can never consume the visible-content budget and return an empty
|
||||||
|
|||||||
252
scripts/check-constants.php
Normal file
252
scripts/check-constants.php
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CI guard: every PRAUTOBLOGGER_* constant referenced in the shipped plugin PHP
|
||||||
|
* must be either (a) defined in prautoblogger.php, or (b) protected by a
|
||||||
|
* defined() guard at the reference site.
|
||||||
|
*
|
||||||
|
* Purpose: prevent the class of regression that caused the v0.19.0 admin-500
|
||||||
|
* incident — a constant deleted from prautoblogger.php while tests/bootstrap.php
|
||||||
|
* kept a fallback copy, masking the gap until prod boot.
|
||||||
|
*
|
||||||
|
* Usage (from repo root):
|
||||||
|
* php scripts/check-constants.php [--verbose] [--self-test]
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* Scanned for refs prautoblogger.php, includes/, templates/, uninstall.php
|
||||||
|
* Excluded from refs tests/ vendor/ .github/ .git/ node_modules/
|
||||||
|
* Excluded from defs everything except prautoblogger.php
|
||||||
|
*
|
||||||
|
* Guard window: a bare constant use is considered guarded if a defined() call
|
||||||
|
* for the same constant appears within GUARD_WINDOW lines above it in the same
|
||||||
|
* file (covers if/elseif blocks and multi-line && chains).
|
||||||
|
*
|
||||||
|
* @package PRAutoBlogger
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// How many lines above the bare use to look for a defined() guard.
|
||||||
|
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 = array( 'tests', 'vendor', '.github', '.git', 'node_modules' );
|
||||||
|
|
||||||
|
// Entry points for the reference scan (files and dirs relative to repo root).
|
||||||
|
const CONSTANTS_SCAN_ROOTS = array(
|
||||||
|
'prautoblogger.php',
|
||||||
|
'uninstall.php',
|
||||||
|
'includes',
|
||||||
|
'templates',
|
||||||
|
);
|
||||||
|
|
||||||
|
$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.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if ( ! file_exists( CONSTANTS_BOOTSTRAP_FILE ) ) {
|
||||||
|
fwrite( STDERR, "ERROR: bootstrap file not found -- run from repo root.\n" );
|
||||||
|
fwrite( STDERR, ' Expected: ' . CONSTANTS_BOOTSTRAP_FILE . "\n" );
|
||||||
|
exit( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
|
||||||
|
|
||||||
|
preg_match_all(
|
||||||
|
"/define\s*\(\s*'(PRAUTOBLOGGER_[A-Z0-9_]+)'/",
|
||||||
|
$bootstrap_src,
|
||||||
|
$def_matches
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @var array<string, int> $defined_set constant name => 1 */
|
||||||
|
$defined_set = array_flip( $def_matches[1] ?? array() );
|
||||||
|
|
||||||
|
if ( $verbose ) {
|
||||||
|
echo 'Defined in ' . CONSTANTS_BOOTSTRAP_FILE . ' (' . count( $defined_set ) . "):\n";
|
||||||
|
foreach ( array_keys( $defined_set ) as $constant_name ) {
|
||||||
|
echo " {$constant_name}\n";
|
||||||
|
}
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Collect shipped PHP files to scan.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect *.php paths under a directory, skipping excluded dirs.
|
||||||
|
*
|
||||||
|
* @param string $dir Directory to walk.
|
||||||
|
* @param string[] $files Accumulator (modified by reference).
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function constants_collect_php_files( string $dir, array &$files ): void {
|
||||||
|
$dir_iter = new DirectoryIterator( $dir );
|
||||||
|
foreach ( $dir_iter as $entry ) {
|
||||||
|
if ( $entry->isDot() ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$basename = $entry->getBasename();
|
||||||
|
if ( $entry->isDir() ) {
|
||||||
|
if ( ! in_array( $basename, CONSTANTS_EXCLUDE_DIRS, true ) ) {
|
||||||
|
constants_collect_php_files( $entry->getPathname(), $files );
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( $entry->isFile() && $entry->getExtension() === 'php' ) {
|
||||||
|
$files[] = $entry->getPathname();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var string[] $php_files */
|
||||||
|
$php_files = array();
|
||||||
|
|
||||||
|
foreach ( CONSTANTS_SCAN_ROOTS as $root ) {
|
||||||
|
if ( ! file_exists( $root ) ) {
|
||||||
|
// Template dir may not exist in very early checkouts -- skip silently.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( is_file( $root ) ) {
|
||||||
|
$php_files[] = $root;
|
||||||
|
} else {
|
||||||
|
constants_collect_php_files( $root, $php_files );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort( $php_files );
|
||||||
|
|
||||||
|
if ( $verbose ) {
|
||||||
|
echo 'Scanning ' . count( $php_files ) . " PHP files\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Scan each file for bare PRAUTOBLOGGER_* references.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @var array<int, array{string, int, string, string}> $errors */
|
||||||
|
$errors = array();
|
||||||
|
|
||||||
|
foreach ( $php_files as $php_path ) {
|
||||||
|
$raw = file_get_contents( $php_path );
|
||||||
|
if ( false === $raw ) {
|
||||||
|
fwrite( STDERR, "WARNING: cannot read {$php_path} -- skipping\n" );
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = explode( "\n", $raw );
|
||||||
|
|
||||||
|
foreach ( $lines as $idx => $raw_line ) {
|
||||||
|
// Fast pre-check: skip lines with no PRAUTOBLOGGER_ token.
|
||||||
|
if ( strpos( $raw_line, 'PRAUTOBLOGGER_' ) === false ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip pure comment lines (docblock * lines, // comments, # lines).
|
||||||
|
$trimmed = ltrim( $raw_line );
|
||||||
|
if (
|
||||||
|
str_starts_with( $trimmed, '*' ) ||
|
||||||
|
str_starts_with( $trimmed, '//' ) ||
|
||||||
|
str_starts_with( $trimmed, '#' )
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all constant names from this line.
|
||||||
|
if ( ! preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $match ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( $match[0] as $name ) {
|
||||||
|
// Skip if this line is itself a define() call for this name.
|
||||||
|
if ( preg_match( "/define\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $raw_line ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if the constant is production-defined in prautoblogger.php.
|
||||||
|
if ( isset( $defined_set[ $name ] ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a defined() guard in the preceding GUARD_WINDOW lines
|
||||||
|
// (inclusive of the current line to catch same-line chained &&).
|
||||||
|
$window_start = max( 0, $idx - CONSTANTS_GUARD_WINDOW );
|
||||||
|
$guarded = false;
|
||||||
|
for ( $w = $window_start; $w <= $idx; $w++ ) {
|
||||||
|
if ( preg_match( "/defined\s*\(\s*'" . preg_quote( $name, '/' ) . "'/", $lines[ $w ] ) ) {
|
||||||
|
$guarded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $guarded ) {
|
||||||
|
$errors[] = array( $php_path, $idx + 1, $name, $raw_line );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Report results.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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" );
|
||||||
|
fwrite( STDERR, ' ' . trim( $line_text ) . "\n" );
|
||||||
|
}
|
||||||
|
fwrite( STDERR, "\nTo fix: add define( '" . $errors[0][2] . "', ... ) to prautoblogger.php,\n" );
|
||||||
|
fwrite( STDERR, "or guard the reference with if ( defined( '" . $errors[0][2] . "' ) ) { ... }\n\n" );
|
||||||
|
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 );
|
||||||
160
scripts/deploy.sh
Executable file
160
scripts/deploy.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# PRAutoBlogger Manual Deploy Script
|
||||||
|
#
|
||||||
|
# Packages the plugin into a clean zip and deploys it to WordPress via the
|
||||||
|
# deploy-receiver REST endpoint. Use this for hotfixes or local deploys when
|
||||||
|
# you don't want to go through the full GitHub Actions pipeline.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/deploy.sh
|
||||||
|
#
|
||||||
|
# Environment variables (set in .env or export before running):
|
||||||
|
# DEPLOY_URL — Full URL to the deploy endpoint
|
||||||
|
# DEPLOY_KEY — The deploy authentication token
|
||||||
|
#
|
||||||
|
# If not set, the script will look for a .env file in the project root.
|
||||||
|
#
|
||||||
|
# @see .github/workflows/deploy.yml — Automated pipeline (same deploy step).
|
||||||
|
# @see ARCHITECTURE.md — Deploy architecture (Code Snippets snippet #8 on server).
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Colors for output.
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
info() { echo -e "${GREEN}[deploy]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[deploy]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[deploy]${NC} $*" >&2; }
|
||||||
|
|
||||||
|
# ── Load environment ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ -f "$PROJECT_ROOT/.env" ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$PROJECT_ROOT/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${DEPLOY_URL:-}" ]]; then
|
||||||
|
error "DEPLOY_URL not set. Export it or add to .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${DEPLOY_KEY:-}" ]]; then
|
||||||
|
error "DEPLOY_KEY not set. Export it or add to .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Read version ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VERSION=$(grep -oP 'Version:\s*\K[0-9]+\.[0-9]+\.[0-9]+' "$PROJECT_ROOT/prautoblogger.php" || echo "0.0.0")
|
||||||
|
info "PRAutoBlogger v${VERSION}"
|
||||||
|
|
||||||
|
# ── PHP lint check ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Running PHP syntax check..."
|
||||||
|
LINT_ERRORS=0
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
if ! php -l "$file" > /dev/null 2>&1; then
|
||||||
|
error "Syntax error in: $file"
|
||||||
|
php -l "$file"
|
||||||
|
LINT_ERRORS=$((LINT_ERRORS + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$PROJECT_ROOT" -name '*.php' -not -path '*/vendor/*' -not -path '*/tests/*' -not -path '*/.github/*' -print0)
|
||||||
|
|
||||||
|
if [[ $LINT_ERRORS -gt 0 ]]; then
|
||||||
|
error "$LINT_ERRORS file(s) have syntax errors. Aborting deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info "Lint passed."
|
||||||
|
|
||||||
|
# ── Package ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BUILD_DIR=$(mktemp -d)
|
||||||
|
PLUGIN_DIR="$BUILD_DIR/prautoblogger"
|
||||||
|
ZIP_PATH="$BUILD_DIR/prautoblogger-${VERSION}.zip"
|
||||||
|
|
||||||
|
info "Packaging plugin..."
|
||||||
|
|
||||||
|
mkdir -p "$PLUGIN_DIR"
|
||||||
|
|
||||||
|
# Copy production files, excluding dev artifacts.
|
||||||
|
rsync -a \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='.github' \
|
||||||
|
--exclude='.gitignore' \
|
||||||
|
--exclude='.distignore' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='.env.local' \
|
||||||
|
--exclude='tests/' \
|
||||||
|
--exclude='scripts/' \
|
||||||
|
--exclude='vendor/' \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='composer.json' \
|
||||||
|
--exclude='composer.lock' \
|
||||||
|
--exclude='phpunit.xml' \
|
||||||
|
--exclude='phpunit.xml.dist' \
|
||||||
|
--exclude='.phpcs.xml' \
|
||||||
|
--exclude='ARCHITECTURE.md' \
|
||||||
|
--exclude='CONVENTIONS.md' \
|
||||||
|
--exclude='CHANGELOG.md' \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='.DS_Store' \
|
||||||
|
"$PROJECT_ROOT/" "$PLUGIN_DIR/"
|
||||||
|
|
||||||
|
cd "$BUILD_DIR"
|
||||||
|
zip -rq "$ZIP_PATH" prautoblogger/
|
||||||
|
|
||||||
|
ZIP_SIZE=$(du -h "$ZIP_PATH" | cut -f1)
|
||||||
|
info "Built ${ZIP_PATH} (${ZIP_SIZE})"
|
||||||
|
|
||||||
|
# ── Pre-deploy health check ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
STATUS_URL="${DEPLOY_URL%/deploy}/status"
|
||||||
|
info "Checking deploy endpoint health..."
|
||||||
|
STATUS_RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||||
|
-H "X-Deploy-Key: ${DEPLOY_KEY}" \
|
||||||
|
"${STATUS_URL}" 2>&1) || true
|
||||||
|
|
||||||
|
STATUS_HTTP=$(echo "$STATUS_RESPONSE" | tail -1)
|
||||||
|
if [[ "$STATUS_HTTP" == "200" ]]; then
|
||||||
|
STATUS_BODY=$(echo "$STATUS_RESPONSE" | head -n -1)
|
||||||
|
info "Endpoint healthy: ${STATUS_BODY}"
|
||||||
|
else
|
||||||
|
warn "Health check returned HTTP ${STATUS_HTTP} — proceeding anyway."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Deploy ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
info "Deploying to ${DEPLOY_URL}..."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -sS -w "\n%{http_code}" \
|
||||||
|
-X POST "${DEPLOY_URL}" \
|
||||||
|
-H "X-Deploy-Key: ${DEPLOY_KEY}" \
|
||||||
|
-F "plugin=@${ZIP_PATH}" \
|
||||||
|
--max-time 120)
|
||||||
|
|
||||||
|
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
|
||||||
|
BODY=$(echo "$RESPONSE" | head -n -1)
|
||||||
|
|
||||||
|
if [[ "$HTTP_CODE" -eq 200 ]]; then
|
||||||
|
info "Deployment successful!"
|
||||||
|
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
|
||||||
|
else
|
||||||
|
error "Deployment FAILED (HTTP ${HTTP_CODE}):"
|
||||||
|
echo "$BODY" | python3 -m json.tool 2>/dev/null || echo "$BODY"
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Cleanup ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
info "Done. PRAutoBlogger v${VERSION} is live."
|
||||||
@@ -98,7 +98,16 @@ function prab_render_board_card( array $card, string $column_key ): void {
|
|||||||
<h1 class="prab-board-heading">
|
<h1 class="prab-board-heading">
|
||||||
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
|
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
|
||||||
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
|
<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>
|
</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' ); ?>">
|
<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>
|
<tbody>
|
||||||
<?php foreach ( $rows as $row ) : ?>
|
<?php foreach ( $rows as $row ) : ?>
|
||||||
<?php
|
<?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'] ?? '';
|
$suggested = $meta['suggested_title'] ?? '';
|
||||||
$key_points = $meta['key_points'] ?? [];
|
$key_points = $meta['key_points'] ?? array();
|
||||||
$keywords = $meta['target_keywords'] ?? [];
|
$keywords = $meta['target_keywords'] ?? array();
|
||||||
$score_pct = round( (float) $row['relevance_score'] * 100 );
|
$score_pct = round( (float) $row['relevance_score'] * 100 );
|
||||||
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
|
$type_label = ucfirst( $row['analysis_type'] ?? 'unknown' );
|
||||||
$type_class = 'prab-type-' . sanitize_html_class( $row['analysis_type'] );
|
$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 ) : ?>
|
<?php if ( $total_pages > 1 ) : ?>
|
||||||
<div class="prautoblogger-pagination" style="margin-top: 12px;">
|
<div class="prautoblogger-pagination" style="margin-top: 12px;">
|
||||||
<?php
|
<?php
|
||||||
echo wp_kses_post( paginate_links( [
|
echo wp_kses_post(
|
||||||
|
paginate_links(
|
||||||
|
array(
|
||||||
'base' => add_query_arg( 'paged', '%#%' ),
|
'base' => add_query_arg( 'paged', '%#%' ),
|
||||||
'format' => '',
|
'format' => '',
|
||||||
'current' => $paged,
|
'current' => $paged,
|
||||||
'total' => $total_pages,
|
'total' => $total_pages,
|
||||||
'prev_text' => '«',
|
'prev_text' => '«',
|
||||||
'next_text' => '»',
|
'next_text' => '»',
|
||||||
] ) );
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$base_url = admin_url( 'admin.php?page=prautoblogger-logs' );
|
$base_url = admin_url( 'admin.php?page=prautoblogger-logs' );
|
||||||
$level_counts = [
|
$level_counts = array(
|
||||||
'all' => __( 'All', 'prautoblogger' ),
|
'all' => __( 'All', 'prautoblogger' ),
|
||||||
'error' => __( 'Errors', 'prautoblogger' ),
|
'error' => __( 'Errors', 'prautoblogger' ),
|
||||||
'warning' => __( 'Warnings', 'prautoblogger' ),
|
'warning' => __( 'Warnings', 'prautoblogger' ),
|
||||||
'info' => __( 'Info', 'prautoblogger' ),
|
'info' => __( 'Info', 'prautoblogger' ),
|
||||||
'debug' => __( 'Debug', 'prautoblogger' ),
|
'debug' => __( 'Debug', 'prautoblogger' ),
|
||||||
];
|
);
|
||||||
?>
|
?>
|
||||||
<div class="wrap ab-wrap">
|
<div class="wrap ab-wrap">
|
||||||
<div class="ab-header">
|
<div class="ab-header">
|
||||||
@@ -46,7 +46,20 @@ $level_counts = [
|
|||||||
<div class="ab-log-filters">
|
<div class="ab-log-filters">
|
||||||
<div class="ab-log-level-tabs">
|
<div class="ab-log-level-tabs">
|
||||||
<?php foreach ( $level_counts as $lv => $lv_label ) : ?>
|
<?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' : ''; ?>">
|
class="ab-log-level-tab <?php echo $level === $lv ? 'ab-log-level-active' : ''; ?>">
|
||||||
<?php echo esc_html( $lv_label ); ?>
|
<?php echo esc_html( $lv_label ); ?>
|
||||||
</a>
|
</a>
|
||||||
@@ -119,14 +132,18 @@ $level_counts = [
|
|||||||
<?php if ( $total_pages > 1 ) : ?>
|
<?php if ( $total_pages > 1 ) : ?>
|
||||||
<div class="ab-log-pagination">
|
<div class="ab-log-pagination">
|
||||||
<?php
|
<?php
|
||||||
echo wp_kses_post( paginate_links( [
|
echo wp_kses_post(
|
||||||
|
paginate_links(
|
||||||
|
array(
|
||||||
'base' => add_query_arg( 'paged', '%#%' ),
|
'base' => add_query_arg( 'paged', '%#%' ),
|
||||||
'format' => '',
|
'format' => '',
|
||||||
'current' => $paged,
|
'current' => $paged,
|
||||||
'total' => $total_pages,
|
'total' => $total_pages,
|
||||||
'prev_text' => '«',
|
'prev_text' => '«',
|
||||||
'next_text' => '»',
|
'next_text' => '»',
|
||||||
] ) );
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$meta = [
|
$meta = array(
|
||||||
'topic' => get_post_meta( $post->ID, '_prautoblogger_topic', true ),
|
'topic' => get_post_meta( $post->ID, '_prautoblogger_topic', true ),
|
||||||
'type' => get_post_meta( $post->ID, '_prautoblogger_article_type', true ),
|
'type' => get_post_meta( $post->ID, '_prautoblogger_article_type', true ),
|
||||||
'model' => get_post_meta( $post->ID, '_prautoblogger_model_used', 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 ),
|
'seo_score' => get_post_meta( $post->ID, '_prautoblogger_seo_score', true ),
|
||||||
'generated_at' => get_post_meta( $post->ID, '_prautoblogger_generated_at', true ),
|
'generated_at' => get_post_meta( $post->ID, '_prautoblogger_generated_at', true ),
|
||||||
'keywords' => get_post_meta( $post->ID, '_prautoblogger_target_keywords', true ),
|
'keywords' => get_post_meta( $post->ID, '_prautoblogger_target_keywords', true ),
|
||||||
];
|
);
|
||||||
?>
|
?>
|
||||||
<div class="prautoblogger-metabox">
|
<div class="prautoblogger-metabox">
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Generated:', 'prautoblogger' ); ?></strong>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Topic:', 'prautoblogger' ); ?></strong>
|
<strong><?php esc_html_e( 'Topic:', 'prautoblogger' ); ?></strong>
|
||||||
<?php echo esc_html( $meta['topic'] ?: '—' ); ?>
|
<?php echo esc_html( $meta['topic'] ? $meta['topic'] : '—' ); ?>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Type:', 'prautoblogger' ); ?></strong>
|
<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>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Model:', 'prautoblogger' ); ?></strong>
|
<strong><?php esc_html_e( 'Model:', 'prautoblogger' ); ?></strong>
|
||||||
<?php echo esc_html( $meta['model'] ?: '—' ); ?>
|
<?php echo esc_html( $meta['model'] ? $meta['model'] : '—' ); ?>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Pipeline:', 'prautoblogger' ); ?></strong>
|
<strong><?php esc_html_e( 'Pipeline:', 'prautoblogger' ); ?></strong>
|
||||||
@@ -47,8 +47,8 @@ $meta = [
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Editor Verdict:', 'prautoblogger' ); ?></strong>
|
<strong><?php esc_html_e( 'Editor Verdict:', 'prautoblogger' ); ?></strong>
|
||||||
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ?: 'unknown' ); ?>">
|
<span class="prautoblogger-verdict-<?php echo esc_attr( $meta['verdict'] ? $meta['verdict'] : 'unknown' ); ?>">
|
||||||
<?php echo esc_html( ucfirst( $meta['verdict'] ?: __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
<?php echo esc_html( ucfirst( $meta['verdict'] ? $meta['verdict'] : __( 'Unknown', 'prautoblogger' ) ) ); ?>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<?php if ( $meta['quality_score'] ) : ?>
|
<?php if ( $meta['quality_score'] ) : ?>
|
||||||
@@ -69,7 +69,7 @@ $meta = [
|
|||||||
<?php if ( $meta['keywords'] ) : ?>
|
<?php if ( $meta['keywords'] ) : ?>
|
||||||
<p>
|
<p>
|
||||||
<strong><?php esc_html_e( 'Keywords:', 'prautoblogger' ); ?></strong>
|
<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>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</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 );
|
$quality_score = get_post_meta( $post_id, '_prautoblogger_quality_score', true );
|
||||||
$generated_at = get_post_meta( $post_id, '_prautoblogger_generated_at', true );
|
$generated_at = get_post_meta( $post_id, '_prautoblogger_generated_at', true );
|
||||||
$editor_notes = get_post_meta( $post_id, '_prautoblogger_editor_notes', 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 ); ?>">
|
<tr data-post-id="<?php echo esc_attr( (string) $post_id ); ?>">
|
||||||
<td class="check-column">
|
<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>
|
<div class="prautoblogger-editor-notes"><?php echo esc_html( $editor_notes ); ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td><?php echo esc_html( $topic ?: '—' ); ?></td>
|
<td><?php echo esc_html( $topic ? $topic : '—' ); ?></td>
|
||||||
<td><?php echo esc_html( ucfirst( $article_type ?: 'article' ) ); ?></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 ?: 'pending' ) ); ?></span></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 '' !== $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><?php echo $generated_at ? esc_html( wp_date( 'M j, g:i A', strtotime( $generated_at ) ) ) : '—'; ?></td>
|
||||||
<td class="prautoblogger-queue-actions">
|
<td class="prautoblogger-queue-actions">
|
||||||
|
|||||||
@@ -209,6 +209,11 @@ if ( ! class_exists( 'WP_Post' ) ) {
|
|||||||
// Plugin URL and version constants used by admin enqueue methods.
|
// Plugin URL and version constants used by admin enqueue methods.
|
||||||
// Defined here so all tests that load admin classes can stub WP enqueue calls
|
// Defined here so all tests that load admin classes can stub WP enqueue calls
|
||||||
// without hitting "Undefined constant" errors.
|
// 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' ) ) {
|
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
|
||||||
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
|
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( '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( '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();
|
\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_opik_dispatch',
|
||||||
'prautoblogger_rerun_stage_replay',
|
'prautoblogger_rerun_stage_replay',
|
||||||
'prautoblogger_rerun_from_stage',
|
'prautoblogger_rerun_from_stage',
|
||||||
|
// v0.21.0 (M4): chained-cron checkpoint ticks.
|
||||||
|
'prautoblogger_gen_orchestrate',
|
||||||
|
'prautoblogger_gen_tick',
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ( $hooks as $hook ) {
|
foreach ( $hooks as $hook ) {
|
||||||
|
|||||||
Reference in New Issue
Block a user