From d6f9777f42a9d8167b558658241fc839af775ed8 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Fri, 3 Oct 2025 07:43:25 -0400
Subject: [PATCH 1/6] feat: introduce ClassificationService for streamlined
classification workflow
Added `ClassificationService` to centralize classification and processing logic, reducing duplication across event handlers. Updated related admin actions to use the new service for consistent error handling, metadata updates, and embedding generation. Included a placeholder bulk upload submenu for future enhancements.
---
.../plugins/postsecret-ai/postsecret-ai.php | 192 +++---------------
.../src/ClassificationService.php | 158 ++++++++++++++
2 files changed, 190 insertions(+), 160 deletions(-)
create mode 100644 wp-content/plugins/postsecret-ai/src/ClassificationService.php
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index dd91c61..29cb8eb 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -14,6 +14,7 @@
require __DIR__ . '/src/SchemaGuard.php';
require __DIR__ . '/src/Classifier.php';
require __DIR__ . '/src/EmbeddingService.php';
+require __DIR__ . '/src/ClassificationService.php';
// Utilities
require __DIR__ . '/src/Metadata.php';
@@ -100,6 +101,17 @@ function () {
'psai_upload_single',
['PSAI\\AdminSingleUpload', 'render']
);
+
+ add_submenu_page(
+ 'psai_postcards',
+ 'Bulk Upload',
+ 'Bulk Upload',
+ 'upload_files',
+ 'psai_bulk_upload',
+ function () {
+ echo 'Bulk Upload
Bulk upload interface coming soon.
';
+ }
+ );
});
/* Settings (tester page) */
@@ -209,71 +221,11 @@ function () {
$front_id = (int)$front_id;
$back_id = (int)$back_id ?: null;
- $env = get_option(\PSAI\Settings::OPTION, \PSAI\Settings::defaults());
- $api = $env['API_KEY'] ?? '';
- $model = $env['MODEL_NAME'] ?? 'gpt-4o-mini';
-
- // Precondition checks → set error and bail cleanly
- if (!$front_id) {
- set_transient('_ps_last_error_global', 'Missing front_id in processor.', 600);
- return;
- }
- if (get_post_meta($front_id, '_ps_duplicate_of', true)) {
- psai_set_last_error($front_id, 'Skipped: duplicate image.');
- return;
- }
- if (!$api) {
- psai_set_last_error([$front_id, $back_id], 'Missing OpenAI API key.');
- return;
- }
-
- try {
- // Data URLs so we don’t rely on public URLs
- $frontSrc = \PSAI\psai_make_data_url($front_id);
- $backSrc = $back_id ? \PSAI\psai_make_data_url($back_id) : null;
-
- // Classify + normalize to schema
- $payload = \PSAI\Classifier::classify($api, $model, $frontSrc, $backSrc);
- $payload = \PSAI\SchemaGuard::normalize($payload);
-
- // Store result (sets tags, model, prompt version, vetted flags)
- \PSAI\psai_store_result($front_id, $payload, $model);
-
- // Sync media fields (Alt/Caption/Description)
- \PSAI\AttachmentSync::sync_from_payload($front_id, $payload, $back_id);
-
- // Enrich quick orientation/color on both sides
- \PSAI\Metadata::compute_and_store($front_id);
- if ($back_id) \PSAI\Metadata::compute_and_store($back_id);
-
- // Optional export manifest
- \PSAI\psai_update_manifest($front_id, $payload);
-
- // Mirror some fields onto back (paired) for convenience
- if ($back_id) {
- update_post_meta($back_id, '_ps_pair_id', $front_id);
- update_post_meta($back_id, '_ps_side', 'back');
- update_post_meta($back_id, '_ps_payload', $payload);
- update_post_meta($back_id, '_ps_tags', get_post_meta($front_id, '_ps_tags', true));
- update_post_meta($back_id, '_ps_model', get_post_meta($front_id, '_ps_model', true));
- update_post_meta($back_id, '_ps_prompt_version', get_post_meta($front_id, '_ps_prompt_version', true));
- update_post_meta($back_id, '_ps_updated_at', wp_date('c'));
- $rs = get_post_meta($front_id, '_ps_review_status', true);
- update_post_meta($back_id, '_ps_review_status', $rs);
- update_post_meta($back_id, '_ps_is_vetted', $rs === 'auto_vetted' ? '1' : '0');
- }
-
- // Clear any previous errors on success
- psai_clear_last_error([$front_id, $back_id]);
-
- } catch (\Throwable $e) {
- $msg = substr($e->getMessage(), 0, 500);
- psai_set_last_error([$front_id, $back_id], $msg);
- }
+ \PSAI\ClassificationService::classify_and_store($front_id, $back_id, false);
}, 10, 2);
/* ---------------------------------------------------------------------------
- * “Process now” button on the attachment edit screen
+ * "Process now" button on the attachment edit screen
* ------------------------------------------------------------------------- */
add_action('admin_post_psai_process_now', function () {
if (!current_user_can('upload_files')) wp_die('Not allowed', 403);
@@ -285,51 +237,16 @@ function () {
exit;
}
- try {
- // If this is the back, flip to the front as canonical
- $maybePair = (int)get_post_meta($att, '_ps_pair_id', true);
- $side = get_post_meta($att, '_ps_side', true);
- $front_id = ($side === 'back' && $maybePair) ? $maybePair : $att;
-
- // Classify if we don't already have a payload
- $payload = get_post_meta($front_id, '_ps_payload', true);
- if (!is_array($payload)) {
- $env = get_option(\PSAI\Settings::OPTION, \PSAI\Settings::defaults());
- $api = $env['API_KEY'] ?? '';
- $model = $env['MODEL_NAME'] ?? 'gpt-4o-mini';
- if (!$api) throw new \RuntimeException('Missing OpenAI API key.');
-
- $frontSrc = \PSAI\psai_make_data_url($front_id);
- $payload = \PSAI\Classifier::classify($api, $model, $frontSrc, null);
- $payload = \PSAI\SchemaGuard::normalize($payload);
-
- \PSAI\psai_store_result($front_id, $payload, $model);
- \PSAI\AttachmentSync::sync_from_payload($front_id, $payload, null);
-
- // Generate and store embedding for new classifications
- $embed_model = $env['EMBEDDING_MODEL'] ?? 'text-embedding-3-small';
- \PSAI\EmbeddingService::generate_and_store($front_id, $payload, $api, $embed_model);
- }
-
- // Normalize flags from the saved payload + recompute metadata
- \PSAI\Ingress::normalize_from_existing_payload($front_id);
-
- // Also compute orientation/color for the side the user is viewing
- \PSAI\Metadata::compute_and_store($att);
-
- psai_clear_last_error($front_id);
+ $result = \PSAI\ClassificationService::process_attachment($att, false);
+ if ($result['success']) {
$url = add_query_arg(['psai_msg' => 'ok'], get_edit_post_link($att, ''));
wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=ok'));
- exit;
-
- } catch (\Throwable $e) {
- $msg = substr($e->getMessage(), 0, 500);
- psai_set_last_error($att, $msg);
+ } else {
$url = add_query_arg(['psai_msg' => 'err'], get_edit_post_link($att, ''));
wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=err'));
- exit;
}
+ exit;
});
/* -------------------------------------------------------------------------
@@ -345,75 +262,30 @@ function () {
exit;
}
- $error_details = [];
+ $result = \PSAI\ClassificationService::process_attachment($att, true);
- try {
- // If this is the back, flip to the front as canonical
- $maybePair = (int)get_post_meta($att, '_ps_pair_id', true);
+ if ($result['success']) {
+ // Check if there was a partial error (embedding failed but classification succeeded)
+ $front_id = $att;
$side = get_post_meta($att, '_ps_side', true);
- $front_id = ($side === 'back' && $maybePair) ? $maybePair : $att;
-
- // Get config
- $env = get_option(\PSAI\Settings::OPTION, \PSAI\Settings::defaults());
- $api = $env['API_KEY'] ?? '';
- $model = $env['MODEL_NAME'] ?? 'gpt-4o-mini';
- if (!$api) throw new \RuntimeException('Missing OpenAI API key.');
-
- // Force re-classification
- $frontSrc = \PSAI\psai_make_data_url($front_id);
- $payload = \PSAI\Classifier::classify($api, $model, $frontSrc, null);
- $payload = \PSAI\SchemaGuard::normalize($payload);
-
- \PSAI\psai_store_result($front_id, $payload, $model);
- \PSAI\AttachmentSync::sync_from_payload($front_id, $payload, null);
-
- // Generate and store embedding with detailed error capture
- $embed_model = $env['EMBEDDING_MODEL'] ?? 'text-embedding-3-small';
- $embedding_ok = \PSAI\EmbeddingService::generate_and_store($front_id, $payload, $api, $embed_model);
-
- if (!$embedding_ok) {
- // The error is already stored in _ps_last_error by EmbeddingService
- // Just get it to show in the notice
- $stored_error = get_post_meta($front_id, '_ps_last_error', true);
-
- // Also try transient for additional context
- $transient_error = get_transient('_ps_last_embedding_error');
-
- if ($stored_error) {
- $error_details[] = $stored_error;
- } elseif ($transient_error) {
- $error_details[] = $transient_error;
- // Store it persistently since transient might expire
- psai_set_last_error($front_id, 'Classification succeeded but embedding failed. ' . $transient_error);
- } else {
- $error_details[] = 'Embedding generation failed - check API key and model configuration';
- psai_set_last_error($front_id, 'Classification succeeded but embedding generation failed. Check API key and model configuration.');
- }
+ $maybePair = (int)get_post_meta($att, '_ps_pair_id', true);
+ if ($side === 'back' && $maybePair) {
+ $front_id = $maybePair;
}
- // Normalize flags + recompute metadata
- \PSAI\Ingress::normalize_from_existing_payload($front_id);
- \PSAI\Metadata::compute_and_store($att);
-
- // If embedding failed but classification succeeded, show partial error
- if (!$embedding_ok) {
+ $last_error = get_post_meta($front_id, '_ps_last_error', true);
+ if ($last_error && str_contains($last_error, 'embedding')) {
$url = add_query_arg(['psai_msg' => 'partial_err'], get_edit_post_link($att, ''));
wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=partial_err'));
- exit;
+ } else {
+ $url = add_query_arg(['psai_msg' => 'reclassified'], get_edit_post_link($att, ''));
+ wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=reclassified'));
}
-
- psai_clear_last_error($front_id);
-
- $url = add_query_arg(['psai_msg' => 'reclassified'], get_edit_post_link($att, ''));
- wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=reclassified'));
- exit;
-
- } catch (\Throwable $e) {
- psai_set_last_error($att, substr($e->getMessage(), 0, 500));
+ } else {
$url = add_query_arg(['psai_msg' => 'err'], get_edit_post_link($att, ''));
wp_safe_redirect($url ?: admin_url('upload.php?psai_msg=err'));
- exit;
}
+ exit;
});
/* Small admin notice so you know the button worked */
diff --git a/wp-content/plugins/postsecret-ai/src/ClassificationService.php b/wp-content/plugins/postsecret-ai/src/ClassificationService.php
new file mode 100644
index 0000000..6f33e29
--- /dev/null
+++ b/wp-content/plugins/postsecret-ai/src/ClassificationService.php
@@ -0,0 +1,158 @@
+ false, 'error' => 'Invalid front attachment ID.'];
+ }
+
+ // Check for duplicate
+ if (get_post_meta($front_id, '_ps_duplicate_of', true)) {
+ psai_set_last_error($front_id, 'Skipped: duplicate image.');
+ return ['success' => false, 'error' => 'Duplicate image.'];
+ }
+
+ // Get configuration
+ $env = get_option(Settings::OPTION, Settings::defaults());
+ $api = $env['API_KEY'] ?? '';
+ $model = $env['MODEL_NAME'] ?? 'gpt-4o-mini';
+ $embed_model = $env['EMBEDDING_MODEL'] ?? 'text-embedding-3-small';
+
+ if (!$api) {
+ $error = 'Missing OpenAI API key.';
+ psai_set_last_error([$front_id, $back_id], $error);
+ return ['success' => false, 'error' => $error];
+ }
+
+ try {
+ // Check if we need to classify (or force re-classification)
+ $payload = get_post_meta($front_id, '_ps_payload', true);
+ if (!is_array($payload) || $force) {
+ // Generate data URLs (not dependent on public URLs)
+ $frontSrc = psai_make_data_url($front_id);
+ $backSrc = $back_id ? psai_make_data_url($back_id) : null;
+
+ // Classify + normalize to schema
+ $payload = Classifier::classify($api, $model, $frontSrc, $backSrc);
+ $payload = SchemaGuard::normalize($payload);
+
+ // Store result (sets facets, model, prompt version, vetted flags)
+ psai_store_result($front_id, $payload, $model);
+
+ // Sync media fields (Alt/Caption/Description)
+ AttachmentSync::sync_from_payload($front_id, $payload, $back_id);
+
+ // Generate and store embedding
+ $embedding_ok = EmbeddingService::generate_and_store($front_id, $payload, $api, $embed_model);
+ if (!$embedding_ok) {
+ // Non-fatal: classification succeeded but embedding failed
+ $embed_error = get_post_meta($front_id, '_ps_last_error', true)
+ ?: 'Embedding generation failed.';
+ // Keep going - we still have a valid classification
+ }
+
+ // Optional export manifest
+ psai_update_manifest($front_id, $payload);
+ }
+
+ // Always normalize flags + compute metadata (even if classification wasn't needed)
+ Ingress::normalize_from_existing_payload($front_id);
+
+ // Enrich orientation/color on both sides
+ Metadata::compute_and_store($front_id);
+ if ($back_id) {
+ Metadata::compute_and_store($back_id);
+ }
+
+ // Mirror some fields onto back (paired) for convenience
+ if ($back_id) {
+ update_post_meta($back_id, '_ps_pair_id', $front_id);
+ update_post_meta($back_id, '_ps_side', 'back');
+ update_post_meta($back_id, '_ps_payload', $payload);
+ update_post_meta($back_id, '_ps_topics', get_post_meta($front_id, '_ps_topics', true));
+ update_post_meta($back_id, '_ps_feelings', get_post_meta($front_id, '_ps_feelings', true));
+ update_post_meta($back_id, '_ps_meanings', get_post_meta($front_id, '_ps_meanings', true));
+ update_post_meta($back_id, '_ps_model', get_post_meta($front_id, '_ps_model', true));
+ update_post_meta($back_id, '_ps_prompt_version', get_post_meta($front_id, '_ps_prompt_version', true));
+ update_post_meta($back_id, '_ps_updated_at', wp_date('c'));
+ $rs = get_post_meta($front_id, '_ps_review_status', true);
+ update_post_meta($back_id, '_ps_review_status', $rs);
+ update_post_meta($back_id, '_ps_is_vetted', $rs === 'auto_vetted' ? '1' : '0');
+ }
+
+ // Clear any previous errors on success
+ psai_clear_last_error([$front_id, $back_id]);
+
+ return ['success' => true, 'payload' => $payload];
+
+ } catch (\Throwable $e) {
+ $msg = substr($e->getMessage(), 0, 500);
+ psai_set_last_error([$front_id, $back_id], $msg);
+ return ['success' => false, 'error' => $msg];
+ }
+ }
+
+ /**
+ * Process a single attachment (discovers pair if needed).
+ *
+ * @param int $att_id Attachment ID (can be front or back)
+ * @param bool $force Force re-classification
+ * @return array{success: bool, error?: string, payload?: array}
+ */
+ public static function process_attachment(int $att_id, bool $force = false): array
+ {
+ // If this is the back, flip to the front as canonical
+ $maybePair = (int)get_post_meta($att_id, '_ps_pair_id', true);
+ $side = get_post_meta($att_id, '_ps_side', true);
+ $front_id = ($side === 'back' && $maybePair) ? $maybePair : $att_id;
+
+ // Get back_id if it exists
+ $back_id = null;
+ if ($side === 'front') {
+ $back_id = (int)get_post_meta($front_id, '_ps_pair_id', true) ?: null;
+ } elseif ($side === 'back') {
+ $back_id = $att_id;
+ }
+
+ return self::classify_and_store($front_id, $back_id, $force);
+ }
+}
From 89e7107f4ef9c86a5d8a4e1d284e8b0ef2b9a789 Mon Sep 17 00:00:00 2001
From: Flatts
Date: Fri, 3 Oct 2025 07:59:01 -0400
Subject: [PATCH 2/6] feat: introduce ClassificationService for streamlined
classification workflow
Added `ClassificationService` to centralize classification and processing logic, reducing duplication across event handlers. Updated related admin actions to use the new service for consistent error handling, metadata updates, and embedding generation. Included a placeholder bulk upload submenu for future enhancements.
---
.claude/settings.local.json | 3 +-
.../migrations/005_bulk_jobs.php | 70 ++
.../plugins/postsecret-ai/postsecret-ai.php | 252 +++++-
.../postsecret-ai/src/AdminBulkUpload.php | 731 ++++++++++++++++++
.../postsecret-ai/src/BulkJobService.php | 595 ++++++++++++++
5 files changed, 1644 insertions(+), 7 deletions(-)
create mode 100644 wp-content/plugins/postsecret-admin/migrations/005_bulk_jobs.php
create mode 100644 wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php
create mode 100644 wp-content/plugins/postsecret-ai/src/BulkJobService.php
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index e35f7a0..30ff846 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -5,7 +5,8 @@
"Bash(docker logs:*)",
"Bash(docker-compose:*)",
"Bash(docker volume rm:*)",
- "Bash(composer install:*)"
+ "Bash(composer install:*)",
+ "Bash(php:*)"
],
"deny": [],
"ask": []
diff --git a/wp-content/plugins/postsecret-admin/migrations/005_bulk_jobs.php b/wp-content/plugins/postsecret-admin/migrations/005_bulk_jobs.php
new file mode 100644
index 0000000..1680cee
--- /dev/null
+++ b/wp-content/plugins/postsecret-admin/migrations/005_bulk_jobs.php
@@ -0,0 +1,70 @@
+get_charset_collate();
+
+ // Jobs table
+ $table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+ $sql_jobs = "CREATE TABLE {$table_jobs} (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ uuid varchar(36) NOT NULL,
+ status varchar(20) NOT NULL DEFAULT 'new',
+ source varchar(255) NOT NULL,
+ staging_path varchar(500) NOT NULL,
+ total_items int(11) unsigned NOT NULL DEFAULT 0,
+ processed_items int(11) unsigned NOT NULL DEFAULT 0,
+ success_count int(11) unsigned NOT NULL DEFAULT 0,
+ fail_count int(11) unsigned NOT NULL DEFAULT 0,
+ last_error text,
+ settings text,
+ started_at datetime DEFAULT NULL,
+ created_at datetime NOT NULL,
+ updated_at datetime NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY uuid (uuid),
+ KEY status (status),
+ KEY created_at (created_at)
+ ) {$charset_collate};";
+
+ // Items table
+ $table_items = $wpdb->prefix . 'psai_bulk_items';
+ $sql_items = "CREATE TABLE {$table_items} (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ job_id bigint(20) unsigned NOT NULL,
+ file_path varchar(500) NOT NULL,
+ sha256 varchar(64) NOT NULL,
+ status varchar(20) NOT NULL DEFAULT 'pending',
+ attachment_id bigint(20) unsigned DEFAULT NULL,
+ attempts int(11) unsigned NOT NULL DEFAULT 0,
+ last_error text,
+ created_at datetime NOT NULL,
+ updated_at datetime NOT NULL,
+ PRIMARY KEY (id),
+ KEY job_id (job_id),
+ KEY status (status),
+ KEY sha256 (sha256),
+ KEY attachment_id (attachment_id)
+ ) {$charset_collate};";
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+ dbDelta($sql_jobs);
+ dbDelta($sql_items);
+}
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index 29cb8eb..857ce33 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -1,12 +1,17 @@
Bulk Upload
Bulk upload interface coming soon.
';
- }
+ ['PSAI\\AdminBulkUpload', 'render']
);
});
@@ -357,4 +364,237 @@ function () {
return new \WP_REST_Response(['exists' => true, 'lines' => $lines], 200);
},
]);
+});
+
+/* ---------------------------------------------------------------------------
+ * Bulk Upload AJAX Endpoints
+ * ------------------------------------------------------------------------- */
+
+// List jobs
+add_action('wp_ajax_psai_bulk_list_jobs', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $jobs = \PSAI\BulkJobService::list_jobs(50);
+
+ // Format for display
+ $formatted = array_map(function($job) {
+ return [
+ 'id' => (int)$job['id'],
+ 'uuid' => $job['uuid'],
+ 'status' => $job['status'],
+ 'source' => $job['source'],
+ 'total' => (int)$job['total_items'],
+ 'processed' => (int)$job['processed_items'],
+ 'success_count' => (int)$job['success_count'],
+ 'fail_count' => (int)$job['fail_count'],
+ 'last_error' => $job['last_error'] ? substr($job['last_error'], 0, 100) : null,
+ 'created' => wp_date('Y-m-d H:i', strtotime($job['created_at'])),
+ ];
+ }, $jobs);
+
+ wp_send_json_success(['jobs' => $formatted]);
+});
+
+// Get job detail
+add_action('wp_ajax_psai_bulk_get_job', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_REQUEST['job_id']) ? (int)$_REQUEST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $job = \PSAI\BulkJobService::get_job($job_id);
+ if (!$job) wp_send_json_error('Job not found');
+
+ wp_send_json_success([
+ 'id' => (int)$job['id'],
+ 'uuid' => $job['uuid'],
+ 'status' => $job['status'],
+ 'source' => $job['source'],
+ 'staging_path' => $job['staging_path'],
+ 'total' => (int)$job['total_items'],
+ 'processed' => (int)$job['processed_items'],
+ 'success_count' => (int)$job['success_count'],
+ 'fail_count' => (int)$job['fail_count'],
+ 'started_at' => $job['started_at'],
+ 'created_at' => $job['created_at'],
+ 'updated_at' => $job['updated_at'],
+ ]);
+});
+
+// Start job
+add_action('wp_ajax_psai_bulk_start_job', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $result = \PSAI\BulkJobService::update_job_status($job_id, \PSAI\BulkJobService::STATUS_RUNNING);
+ if ($result) {
+ wp_send_json_success();
+ } else {
+ wp_send_json_error('Failed to start job');
+ }
+});
+
+// Pause job
+add_action('wp_ajax_psai_bulk_pause_job', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $result = \PSAI\BulkJobService::update_job_status($job_id, \PSAI\BulkJobService::STATUS_PAUSED);
+ if ($result) {
+ wp_send_json_success();
+ } else {
+ wp_send_json_error('Failed to pause job');
+ }
+});
+
+// Stop job
+add_action('wp_ajax_psai_bulk_stop_job', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $result = \PSAI\BulkJobService::update_job_status($job_id, \PSAI\BulkJobService::STATUS_STOPPED);
+ if ($result) {
+ wp_send_json_success();
+ } else {
+ wp_send_json_error('Failed to stop job');
+ }
+});
+
+// Process batch (step)
+add_action('wp_ajax_psai_bulk_step', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ $batch_size = isset($_POST['batch_size']) ? (int)$_POST['batch_size'] : 25;
+
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $result = \PSAI\BulkJobService::process_batch($job_id, $batch_size);
+ wp_send_json_success($result);
+});
+
+// Retry failed items
+add_action('wp_ajax_psai_bulk_retry_failed', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $count = \PSAI\BulkJobService::retry_failed($job_id);
+ wp_send_json_success(['requeued' => $count]);
+});
+
+// Delete job
+add_action('wp_ajax_psai_bulk_delete_job', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $result = \PSAI\BulkJobService::delete_job($job_id);
+ if ($result) {
+ wp_send_json_success();
+ } else {
+ wp_send_json_error('Failed to delete job');
+ }
+});
+
+// Get errors
+add_action('wp_ajax_psai_bulk_get_errors', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_REQUEST['job_id']) ? (int)$_REQUEST['job_id'] : 0;
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $errors = \PSAI\BulkJobService::get_errors($job_id, 100);
+
+ $formatted = array_map(function($error) {
+ return [
+ 'id' => (int)$error['id'],
+ 'file_path' => $error['file_path'],
+ 'status' => $error['status'],
+ 'attempts' => (int)$error['attempts'],
+ 'last_error' => $error['last_error'],
+ 'updated_at' => wp_date('Y-m-d H:i:s', strtotime($error['updated_at'])),
+ ];
+ }, $errors);
+
+ wp_send_json_success(['errors' => $formatted]);
+});
+
+// Save job settings
+add_action('wp_ajax_psai_bulk_save_settings', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_POST['job_id']) ? (int)$_POST['job_id'] : 0;
+ $settings = isset($_POST['settings']) ? (array)$_POST['settings'] : [];
+
+ if (!$job_id) wp_send_json_error('Invalid job ID');
+
+ $result = \PSAI\BulkJobService::save_settings($job_id, $settings);
+ if ($result) {
+ wp_send_json_success();
+ } else {
+ wp_send_json_error('Failed to save settings');
+ }
+});
+
+// Export errors CSV
+add_action('wp_ajax_psai_bulk_export_errors', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+
+ $job_id = isset($_REQUEST['job_id']) ? (int)$_REQUEST['job_id'] : 0;
+ if (!$job_id) wp_die('Invalid job ID');
+
+ $csv = \PSAI\BulkJobService::export_errors_csv($job_id);
+
+ header('Content-Type: text/csv; charset=utf-8');
+ header('Content-Disposition: attachment; filename=psai-bulk-errors-' . $job_id . '.csv');
+ echo $csv;
+ exit;
+});
+
+// Create job (file upload)
+add_action('admin_post_psai_bulk_create_job', function() {
+ if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
+ check_admin_referer('psai_bulk_create_job', 'psai_bulk_nonce');
+
+ $result = \PSAI\BulkJobService::create_job($_FILES);
+
+ if ($result['success']) {
+ wp_redirect(add_query_arg([
+ 'page' => 'psai_bulk_upload',
+ 'psai_msg' => 'job_created',
+ 'job_id' => $result['job_id']
+ ], admin_url('admin.php')));
+ } else {
+ set_transient('_ps_bulk_error', $result['error'], 300);
+ wp_redirect(add_query_arg([
+ 'page' => 'psai_bulk_upload',
+ 'psai_msg' => 'err'
+ ], admin_url('admin.php')));
+ }
+ exit;
+});
+
+// Admin notices for bulk upload
+add_action('admin_notices', function() {
+ if (!isset($_GET['page']) || $_GET['page'] !== 'psai_bulk_upload') return;
+ if (!isset($_GET['psai_msg'])) return;
+
+ $msg = sanitize_text_field($_GET['psai_msg']);
+
+ if ($msg === 'job_created') {
+ echo 'Job created successfully! Click "Open" to start processing.
';
+ } elseif ($msg === 'err') {
+ $error = get_transient('_ps_bulk_error');
+ delete_transient('_ps_bulk_error');
+ echo 'Error: ' . esc_html($error ?: 'Unknown error') . '
';
+ }
});
\ No newline at end of file
diff --git a/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php b/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php
new file mode 100644
index 0000000..e21fb3f
--- /dev/null
+++ b/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php
@@ -0,0 +1,731 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+ 0 / 0 (0%)
+
+
+
+ 0
+
+
+
+ 0
+
+
+
+ —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false, 'error' => 'No files uploaded.'];
+ }
+
+ // Create staging directory
+ $upload_dir = wp_upload_dir();
+ $staging_base = trailingslashit($upload_dir['basedir']) . 'psai-bulk-staging';
+ wp_mkdir_p($staging_base);
+
+ $job_uuid = wp_generate_uuid4();
+ $staging_path = trailingslashit($staging_base) . sanitize_file_name($job_uuid);
+ wp_mkdir_p($staging_path);
+
+ // Handle multiple files
+ $file_paths = [];
+ $source = '';
+ $is_zip = false;
+
+ if (is_array($files['psai_bulk_files']['name'])) {
+ // Multiple files
+ $count = count($files['psai_bulk_files']['name']);
+ for ($i = 0; $i < $count; $i++) {
+ if ($files['psai_bulk_files']['error'][$i] !== UPLOAD_ERR_OK) {
+ continue;
+ }
+
+ $filename = sanitize_file_name($files['psai_bulk_files']['name'][$i]);
+ $tmp_name = $files['psai_bulk_files']['tmp_name'][$i];
+
+ // Check if it's a ZIP
+ if (pathinfo($filename, PATHINFO_EXTENSION) === 'zip') {
+ $is_zip = true;
+ $zip_path = trailingslashit($staging_path) . $filename;
+ move_uploaded_file($tmp_name, $zip_path);
+ $extracted = self::extract_zip($zip_path, $staging_path);
+ if (!$extracted['success']) {
+ return ['success' => false, 'error' => $extracted['error']];
+ }
+ $file_paths = array_merge($file_paths, $extracted['files']);
+ $source = 'zip:' . $filename;
+ } else {
+ // Regular image file
+ $dest = trailingslashit($staging_path) . $filename;
+ move_uploaded_file($tmp_name, $dest);
+ $file_paths[] = $dest;
+ }
+ }
+
+ if (!$is_zip) {
+ $source = 'files:' . $count;
+ }
+ } else {
+ // Single file
+ $filename = sanitize_file_name($files['psai_bulk_files']['name']);
+ $tmp_name = $files['psai_bulk_files']['tmp_name'];
+
+ if (pathinfo($filename, PATHINFO_EXTENSION) === 'zip') {
+ $is_zip = true;
+ $zip_path = trailingslashit($staging_path) . $filename;
+ move_uploaded_file($tmp_name, $zip_path);
+ $extracted = self::extract_zip($zip_path, $staging_path);
+ if (!$extracted['success']) {
+ return ['success' => false, 'error' => $extracted['error']];
+ }
+ $file_paths = $extracted['files'];
+ $source = 'zip:' . $filename;
+ } else {
+ $dest = trailingslashit($staging_path) . $filename;
+ move_uploaded_file($tmp_name, $dest);
+ $file_paths[] = $dest;
+ $source = 'file:' . $filename;
+ }
+ }
+
+ if (empty($file_paths)) {
+ return ['success' => false, 'error' => 'No valid files found.'];
+ }
+
+ // Filter to supported image formats
+ $supported_exts = ['jpg', 'jpeg', 'png', 'webp'];
+ $image_files = array_filter($file_paths, function($path) use ($supported_exts) {
+ $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+ return in_array($ext, $supported_exts, true);
+ });
+
+ if (empty($image_files)) {
+ return ['success' => false, 'error' => 'No supported image files found (JPEG, PNG, WebP).'];
+ }
+
+ // Create job record
+ $table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+ $wpdb->insert($table_jobs, [
+ 'uuid' => $job_uuid,
+ 'status' => self::STATUS_NEW,
+ 'source' => $source,
+ 'staging_path' => $staging_path,
+ 'total_items' => count($image_files),
+ 'processed_items' => 0,
+ 'success_count' => 0,
+ 'fail_count' => 0,
+ 'settings' => wp_json_encode(['batch_size' => 25, 'max_step_time' => 8]),
+ 'created_at' => current_time('mysql'),
+ 'updated_at' => current_time('mysql'),
+ ]);
+
+ $job_id = (int)$wpdb->insert_id;
+
+ // Create item records
+ $table_items = $wpdb->prefix . 'psai_bulk_items';
+ foreach ($image_files as $file_path) {
+ $relative_path = str_replace($staging_path, '', $file_path);
+ $sha256 = @hash_file('sha256', $file_path) ?: '';
+
+ // Check for duplicates
+ $existing = $wpdb->get_var($wpdb->prepare(
+ "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_ps_sha256' AND meta_value = %s LIMIT 1",
+ $sha256
+ ));
+
+ $status = $existing ? self::ITEM_SKIPPED : self::ITEM_PENDING;
+
+ $wpdb->insert($table_items, [
+ 'job_id' => $job_id,
+ 'file_path' => $relative_path,
+ 'sha256' => $sha256,
+ 'status' => $status,
+ 'attempts' => 0,
+ 'created_at' => current_time('mysql'),
+ 'updated_at' => current_time('mysql'),
+ ]);
+ }
+
+ return ['success' => true, 'job_id' => $job_id];
+ }
+
+ /**
+ * Extract ZIP file and return image file paths.
+ *
+ * @param string $zip_path Path to ZIP file
+ * @param string $dest_path Destination directory
+ * @return array{success: bool, files?: array, error?: string}
+ */
+ private static function extract_zip(string $zip_path, string $dest_path): array
+ {
+ if (!class_exists('ZipArchive')) {
+ return ['success' => false, 'error' => 'ZipArchive class not available.'];
+ }
+
+ $zip = new \ZipArchive();
+ if ($zip->open($zip_path) !== true) {
+ return ['success' => false, 'error' => 'Failed to open ZIP file.'];
+ }
+
+ $zip->extractTo($dest_path);
+ $zip->close();
+
+ // Recursively find all files
+ $files = [];
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($dest_path, \RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $files[] = $file->getPathname();
+ }
+ }
+
+ return ['success' => true, 'files' => $files];
+ }
+
+ /**
+ * Get job by ID.
+ *
+ * @param int $job_id
+ * @return array|null
+ */
+ public static function get_job(int $job_id): ?array
+ {
+ global $wpdb;
+ $table = $wpdb->prefix . 'psai_bulk_jobs';
+ $job = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $job_id), ARRAY_A);
+ return $job ?: null;
+ }
+
+ /**
+ * List recent jobs.
+ *
+ * @param int $limit
+ * @return array
+ */
+ public static function list_jobs(int $limit = 50): array
+ {
+ global $wpdb;
+ $table = $wpdb->prefix . 'psai_bulk_jobs';
+ $jobs = $wpdb->get_results(
+ $wpdb->prepare("SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit),
+ ARRAY_A
+ );
+ return $jobs ?: [];
+ }
+
+ /**
+ * Update job status.
+ *
+ * @param int $job_id
+ * @param string $status
+ * @return bool
+ */
+ public static function update_job_status(int $job_id, string $status): bool
+ {
+ global $wpdb;
+ $table = $wpdb->prefix . 'psai_bulk_jobs';
+ $result = $wpdb->update($table, [
+ 'status' => $status,
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $job_id]);
+
+ // Set started_at if transitioning to running
+ if ($status === self::STATUS_RUNNING) {
+ $job = self::get_job($job_id);
+ if ($job && !$job['started_at']) {
+ $wpdb->update($table, [
+ 'started_at' => current_time('mysql'),
+ ], ['id' => $job_id]);
+ }
+ }
+
+ return $result !== false;
+ }
+
+ /**
+ * Process a batch of items for a job.
+ *
+ * @param int $job_id
+ * @param int $batch_size
+ * @return array{success: bool, processed: int, status: string, has_more: bool}
+ */
+ public static function process_batch(int $job_id, int $batch_size = 25): array
+ {
+ global $wpdb;
+
+ $job = self::get_job($job_id);
+ if (!$job) {
+ return ['success' => false, 'processed' => 0, 'status' => 'unknown', 'has_more' => false];
+ }
+
+ // Only process if job is running
+ if ($job['status'] !== self::STATUS_RUNNING) {
+ return ['success' => true, 'processed' => 0, 'status' => $job['status'], 'has_more' => false];
+ }
+
+ $table_items = $wpdb->prefix . 'psai_bulk_items';
+
+ // Get pending items
+ $items = $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$table_items} WHERE job_id = %d AND status = %s ORDER BY id ASC LIMIT %d",
+ $job_id,
+ self::ITEM_PENDING,
+ $batch_size
+ ), ARRAY_A);
+
+ if (empty($items)) {
+ // No more items, mark job as completed
+ self::update_job_status($job_id, self::STATUS_COMPLETED);
+ return ['success' => true, 'processed' => 0, 'status' => self::STATUS_COMPLETED, 'has_more' => false];
+ }
+
+ $processed = 0;
+ $success_count = 0;
+ $fail_count = 0;
+
+ foreach ($items as $item) {
+ // Mark as processing
+ $wpdb->update($table_items, [
+ 'status' => self::ITEM_PROCESSING,
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $item['id']]);
+
+ // Attempt to process
+ $result = self::process_item($job, $item);
+
+ if ($result['success']) {
+ $wpdb->update($table_items, [
+ 'status' => self::ITEM_SUCCESS,
+ 'attachment_id' => $result['attachment_id'],
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $item['id']]);
+ $success_count++;
+ } else {
+ $attempts = (int)$item['attempts'] + 1;
+ $status = $attempts >= 3 ? self::ITEM_QUARANTINED : self::ITEM_ERROR;
+
+ $wpdb->update($table_items, [
+ 'status' => $status,
+ 'attempts' => $attempts,
+ 'last_error' => substr($result['error'], 0, 500),
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $item['id']]);
+ $fail_count++;
+ }
+
+ $processed++;
+
+ // Check if job was paused/stopped during processing
+ $current_job = self::get_job($job_id);
+ if ($current_job['status'] !== self::STATUS_RUNNING) {
+ break;
+ }
+ }
+
+ // Update job counts
+ $table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+ $wpdb->query($wpdb->prepare(
+ "UPDATE {$table_jobs} SET
+ processed_items = processed_items + %d,
+ success_count = success_count + %d,
+ fail_count = fail_count + %d,
+ updated_at = %s
+ WHERE id = %d",
+ $processed,
+ $success_count,
+ $fail_count,
+ current_time('mysql'),
+ $job_id
+ ));
+
+ // Check if there are more items
+ $remaining = $wpdb->get_var($wpdb->prepare(
+ "SELECT COUNT(*) FROM {$table_items} WHERE job_id = %d AND status = %s",
+ $job_id,
+ self::ITEM_PENDING
+ ));
+
+ $has_more = $remaining > 0;
+
+ // Update final job status
+ $final_job = self::get_job($job_id);
+ $status = $final_job['status'];
+
+ if (!$has_more && $status === self::STATUS_RUNNING) {
+ self::update_job_status($job_id, self::STATUS_COMPLETED);
+ $status = self::STATUS_COMPLETED;
+ }
+
+ return [
+ 'success' => true,
+ 'processed' => $processed,
+ 'status' => $status,
+ 'has_more' => $has_more
+ ];
+ }
+
+ /**
+ * Process a single item.
+ *
+ * @param array $job
+ * @param array $item
+ * @return array{success: bool, attachment_id?: int, error?: string}
+ */
+ private static function process_item(array $job, array $item): array
+ {
+ try {
+ $file_path = trailingslashit($job['staging_path']) . $item['file_path'];
+
+ if (!file_exists($file_path)) {
+ return ['success' => false, 'error' => 'File not found: ' . $item['file_path']];
+ }
+
+ // Sideload file
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+ require_once ABSPATH . 'wp-admin/includes/media.php';
+ require_once ABSPATH . 'wp-admin/includes/image.php';
+
+ $file_array = [
+ 'name' => basename($file_path),
+ 'tmp_name' => $file_path,
+ 'error' => 0,
+ 'size' => filesize($file_path),
+ ];
+
+ $att_id = media_handle_sideload($file_array, 0);
+
+ if (is_wp_error($att_id)) {
+ return ['success' => false, 'error' => $att_id->get_error_message()];
+ }
+
+ // Mark as front, index
+ update_post_meta($att_id, '_ps_side', 'front');
+ update_post_meta($att_id, '_ps_bulk_job_id', $job['id']);
+ Ingress::index($att_id);
+
+ // Classify and store
+ $result = ClassificationService::classify_and_store($att_id, null, false);
+
+ if (!$result['success']) {
+ return ['success' => false, 'error' => $result['error'] ?? 'Classification failed.'];
+ }
+
+ return ['success' => true, 'attachment_id' => $att_id];
+
+ } catch (\Throwable $e) {
+ return ['success' => false, 'error' => $e->getMessage()];
+ }
+ }
+
+ /**
+ * Get error items for a job.
+ *
+ * @param int $job_id
+ * @param int $limit
+ * @return array
+ */
+ public static function get_errors(int $job_id, int $limit = 100): array
+ {
+ global $wpdb;
+ $table = $wpdb->prefix . 'psai_bulk_items';
+ $errors = $wpdb->get_results($wpdb->prepare(
+ "SELECT * FROM {$table} WHERE job_id = %d AND (status = %s OR status = %s) ORDER BY updated_at DESC LIMIT %d",
+ $job_id,
+ self::ITEM_ERROR,
+ self::ITEM_QUARANTINED,
+ $limit
+ ), ARRAY_A);
+ return $errors ?: [];
+ }
+
+ /**
+ * Retry failed items.
+ *
+ * @param int $job_id
+ * @return int Number of items requeued
+ */
+ public static function retry_failed(int $job_id): int
+ {
+ global $wpdb;
+ $table = $wpdb->prefix . 'psai_bulk_items';
+ $result = $wpdb->query($wpdb->prepare(
+ "UPDATE {$table} SET status = %s, attempts = 0, last_error = NULL, updated_at = %s
+ WHERE job_id = %d AND (status = %s OR status = %s)",
+ self::ITEM_PENDING,
+ current_time('mysql'),
+ $job_id,
+ self::ITEM_ERROR,
+ self::ITEM_QUARANTINED
+ ));
+ return $result ?: 0;
+ }
+
+ /**
+ * Delete a job and its items.
+ *
+ * @param int $job_id
+ * @return bool
+ */
+ public static function delete_job(int $job_id): bool
+ {
+ global $wpdb;
+
+ $job = self::get_job($job_id);
+ if (!$job) {
+ return false;
+ }
+
+ // Delete items
+ $table_items = $wpdb->prefix . 'psai_bulk_items';
+ $wpdb->delete($table_items, ['job_id' => $job_id]);
+
+ // Delete job
+ $table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+ $wpdb->delete($table_jobs, ['id' => $job_id]);
+
+ // Clean up staging directory
+ if ($job['staging_path'] && is_dir($job['staging_path'])) {
+ self::delete_directory($job['staging_path']);
+ }
+
+ return true;
+ }
+
+ /**
+ * Recursively delete a directory.
+ *
+ * @param string $dir
+ */
+ private static function delete_directory(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $files = array_diff(scandir($dir), ['.', '..']);
+ foreach ($files as $file) {
+ $path = $dir . DIRECTORY_SEPARATOR . $file;
+ is_dir($path) ? self::delete_directory($path) : unlink($path);
+ }
+ rmdir($dir);
+ }
+
+ /**
+ * Save job settings.
+ *
+ * @param int $job_id
+ * @param array $settings
+ * @return bool
+ */
+ public static function save_settings(int $job_id, array $settings): bool
+ {
+ global $wpdb;
+ $table = $wpdb->prefix . 'psai_bulk_jobs';
+ $result = $wpdb->update($table, [
+ 'settings' => wp_json_encode($settings),
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $job_id]);
+ return $result !== false;
+ }
+
+ /**
+ * Export errors to CSV.
+ *
+ * @param int $job_id
+ * @return string CSV content
+ */
+ public static function export_errors_csv(int $job_id): string
+ {
+ $errors = self::get_errors($job_id, 10000);
+
+ $csv = "Item ID,File Path,Status,Attempts,Last Error,Last Updated\n";
+ foreach ($errors as $error) {
+ $csv .= sprintf(
+ "%d,\"%s\",\"%s\",%d,\"%s\",\"%s\"\n",
+ $error['id'],
+ $error['file_path'],
+ $error['status'],
+ $error['attempts'],
+ str_replace('"', '""', $error['last_error'] ?? ''),
+ $error['updated_at']
+ );
+ }
+
+ return $csv;
+ }
+}
From f1661e033b351774feae67cb3ebd01b6d8e5ca9b Mon Sep 17 00:00:00 2001
From: Flatts
Date: Fri, 3 Oct 2025 09:20:39 -0400
Subject: [PATCH 3/6] feat: add bulk upload debugging tools and enhance error
handling/output
Introduced bulk upload debugging pages for error monitoring and database setup validation, including recent jobs display and transient error checks. Enhanced error logging in `BulkJobService` with detailed execution traces and validation for table existence. Added WebP conversion functionality for attachments post-classification. Updated upload-related PHP configurations and improved admin UI with migration prompts and setup status checks. Enhanced upload UI responsiveness and layout refinements.
---
docker-compose.yml | 1 +
php-uploads.ini | 5 +
.../plugins/postsecret-ai/bulk-debug.php | 110 ++++++++++++++++++
.../postsecret-ai/check-bulk-setup.php | 87 ++++++++++++++
.../plugins/postsecret-ai/postsecret-ai.php | 64 ++++++++--
.../postsecret-ai/src/AdminBulkUpload.php | 36 +++++-
.../postsecret-ai/src/AdminMetaBox.php | 9 +-
.../postsecret-ai/src/AttachmentSync.php | 46 ++++++--
.../postsecret-ai/src/BulkJobService.php | 20 +++-
.../src/ClassificationService.php | 10 +-
.../plugins/postsecret-ai/src/Ingress.php | 87 +++++++++++++-
11 files changed, 435 insertions(+), 40 deletions(-)
create mode 100644 php-uploads.ini
create mode 100644 wp-content/plugins/postsecret-ai/bulk-debug.php
create mode 100644 wp-content/plugins/postsecret-ai/check-bulk-setup.php
diff --git a/docker-compose.yml b/docker-compose.yml
index 226a2dc..da55e8b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,6 +25,7 @@ services:
@ini_set('post_max_size', '128M');
volumes:
- ./wp-content:/var/www/html/wp-content
+ - ./php-uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
- wordpress_data:/var/www/html
depends_on:
db:
diff --git a/php-uploads.ini b/php-uploads.ini
new file mode 100644
index 0000000..edffdc4
--- /dev/null
+++ b/php-uploads.ini
@@ -0,0 +1,5 @@
+upload_max_filesize = 256M
+post_max_size = 256M
+max_execution_time = 300
+memory_limit = 512M
+max_input_time = 300
diff --git a/wp-content/plugins/postsecret-ai/bulk-debug.php b/wp-content/plugins/postsecret-ai/bulk-debug.php
new file mode 100644
index 0000000..ea6884f
--- /dev/null
+++ b/wp-content/plugins/postsecret-ai/bulk-debug.php
@@ -0,0 +1,110 @@
+Bulk Upload Debug\n";
+echo "\n";
+
+// Check if we're processing a test upload
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_FILES['test_file'])) {
+ echo "Processing Upload...
\n";
+ echo "";
+
+ try {
+ echo "Files received:\n";
+ print_r($_FILES);
+ echo "\n\n";
+
+ echo "Calling BulkJobService::create_job()...\n";
+ $result = \PSAI\BulkJobService::create_job($_FILES);
+
+ echo "\n\nResult:\n";
+ print_r($result);
+
+ if ($result['success']) {
+ echo "\n\n✓ SUCCESS! Job ID: {$result['job_id']}\n";
+ } else {
+ echo "\n\n✗ FAILED: {$result['error']}\n";
+ }
+ } catch (\Throwable $e) {
+ echo "\n\n✗ EXCEPTION: {$e->getMessage()}\n\n";
+ echo "Trace:\n{$e->getTraceAsString()}\n";
+ }
+
+ echo "";
+ echo "
\n";
+}
+
+// Check transient error
+$transient_error = get_transient('_ps_bulk_error');
+if ($transient_error) {
+ echo "";
+ echo "Last Error: " . esc_html($transient_error);
+ echo "
\n";
+}
+
+// Check database tables
+global $wpdb;
+$table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+$table_items = $wpdb->prefix . 'psai_bulk_items';
+
+echo "Database Status
\n";
+$jobs_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_jobs}'") === $table_jobs;
+$items_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_items}'") === $table_items;
+
+echo "Jobs table: " . ($jobs_exists ? "EXISTS" : "NOT FOUND") . "
\n";
+echo "Items table: " . ($items_exists ? "EXISTS" : "NOT FOUND") . "
\n";
+
+if ($jobs_exists) {
+ $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_jobs}");
+ echo "Total jobs: {$count}
\n";
+
+ if ($count > 0) {
+ echo "Recent Jobs:
\n";
+ $jobs = $wpdb->get_results("SELECT * FROM {$table_jobs} ORDER BY created_at DESC LIMIT 5", ARRAY_A);
+ echo "" . print_r($jobs, true) . "
\n";
+ }
+}
+
+// Test upload form
+echo "
\n";
+echo "Test Upload
\n";
+echo "\n";
+
+// Check upload directory
+echo "
\n";
+echo "Upload Directory
\n";
+$upload_dir = wp_upload_dir();
+$staging_base = trailingslashit($upload_dir['basedir']) . 'psai-bulk-staging';
+
+echo "Upload base: {$upload_dir['basedir']}
\n";
+echo "Staging dir: {$staging_base}
\n";
+echo "Staging exists: " . (is_dir($staging_base) ? "YES" : "NO (will be created)") . "
\n";
+echo "Parent writable: " . (is_writable($upload_dir['basedir']) ? "YES" : "NO") . "
\n";
+
+// Check PHP settings
+echo "
\n";
+echo "PHP Settings
\n";
+echo "upload_max_filesize: " . ini_get('upload_max_filesize') . "
\n";
+echo "post_max_size: " . ini_get('post_max_size') . "
\n";
+echo "max_file_uploads: " . ini_get('max_file_uploads') . "
\n";
+echo "ZipArchive available: " . (class_exists('ZipArchive') ? "YES" : "NO") . "
\n";
+
+echo "
\n";
+echo "← Back to Bulk Upload
\n";
diff --git a/wp-content/plugins/postsecret-ai/check-bulk-setup.php b/wp-content/plugins/postsecret-ai/check-bulk-setup.php
new file mode 100644
index 0000000..7c19a6c
--- /dev/null
+++ b/wp-content/plugins/postsecret-ai/check-bulk-setup.php
@@ -0,0 +1,87 @@
+PostSecret Bulk Upload - Setup Check\n";
+echo "\n";
+
+// Check tables
+$table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+$table_items = $wpdb->prefix . 'psai_bulk_items';
+
+echo "Database Tables
\n";
+
+$jobs_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_jobs}'") === $table_jobs;
+$items_exists = $wpdb->get_var("SHOW TABLES LIKE '{$table_items}'") === $table_items;
+
+if ($jobs_exists) {
+ echo "✓ Table {$table_jobs} exists
\n";
+ $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_jobs}");
+ echo "Jobs in table: {$count}
\n";
+
+ if ($count > 0) {
+ echo "Recent Jobs:
\n";
+ $jobs = $wpdb->get_results("SELECT * FROM {$table_jobs} ORDER BY created_at DESC LIMIT 5", ARRAY_A);
+ echo "" . print_r($jobs, true) . "
\n";
+ }
+} else {
+ echo "✗ Table {$table_jobs} does NOT exist
\n";
+ echo "Action needed: Run migrations at postsecret-admin/run-migrations.php
\n";
+}
+
+if ($items_exists) {
+ echo "✓ Table {$table_items} exists
\n";
+ $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_items}");
+ echo "Items in table: {$count}
\n";
+} else {
+ echo "✗ Table {$table_items} does NOT exist
\n";
+ echo "Action needed: Run migrations at postsecret-admin/run-migrations.php
\n";
+}
+
+// Check upload directory
+echo "Upload Directory
\n";
+$upload_dir = wp_upload_dir();
+$staging_base = trailingslashit($upload_dir['basedir']) . 'psai-bulk-staging';
+
+if (is_dir($staging_base)) {
+ echo "✓ Staging directory exists: {$staging_base}
\n";
+ echo "Writable: " . (is_writable($staging_base) ? 'Yes' : 'No') . "
\n";
+} else {
+ echo "Staging directory will be created on first upload: {$staging_base}
\n";
+ echo "Parent writable: " . (is_writable($upload_dir['basedir']) ? 'Yes' : 'No') . "
\n";
+}
+
+// Test AJAX endpoint
+echo "AJAX Endpoints
\n";
+echo "Testing psai_bulk_list_jobs...
\n";
+
+$_REQUEST['action'] = 'psai_bulk_list_jobs';
+ob_start();
+do_action('wp_ajax_psai_bulk_list_jobs');
+$response = ob_get_clean();
+
+echo "Response:
\n";
+echo "" . esc_html($response) . "
\n";
+
+if ($jobs_exists && $items_exists) {
+ echo "✓ Setup Complete
\n";
+ echo "Go to Bulk Upload Page
\n";
+} else {
+ echo "Setup Required
\n";
+ echo "1. Run migrations: Click here to run migrations
\n";
+ echo "2. Refresh this page to verify setup
\n";
+}
+
+echo "
\n";
+echo "← Back to Dashboard
\n";
diff --git a/wp-content/plugins/postsecret-ai/postsecret-ai.php b/wp-content/plugins/postsecret-ai/postsecret-ai.php
index 857ce33..560d5f6 100644
--- a/wp-content/plugins/postsecret-ai/postsecret-ai.php
+++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php
@@ -562,25 +562,65 @@ function () {
// Create job (file upload)
add_action('admin_post_psai_bulk_create_job', function() {
- if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
- check_admin_referer('psai_bulk_create_job', 'psai_bulk_nonce');
+ try {
+ error_log('PSAI Bulk: Handler called');
- $result = \PSAI\BulkJobService::create_job($_FILES);
+ if (!current_user_can('manage_options')) {
+ error_log('PSAI Bulk: Unauthorized user');
+ wp_die('Unauthorized', 403);
+ }
- if ($result['success']) {
- wp_redirect(add_query_arg([
- 'page' => 'psai_bulk_upload',
- 'psai_msg' => 'job_created',
- 'job_id' => $result['job_id']
- ], admin_url('admin.php')));
- } else {
- set_transient('_ps_bulk_error', $result['error'], 300);
+ error_log('PSAI Bulk: Checking nonce');
+ check_admin_referer('psai_bulk_create_job', 'psai_bulk_nonce');
+
+ // Check if tables exist first
+ global $wpdb;
+ $table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+ $tables_exist = $wpdb->get_var("SHOW TABLES LIKE '{$table_jobs}'") === $table_jobs;
+
+ error_log('PSAI Bulk: Tables exist: ' . ($tables_exist ? 'yes' : 'no'));
+
+ if (!$tables_exist) {
+ set_transient('_ps_bulk_error', 'Database tables not found. Please run migrations first.', 300);
+ wp_redirect(add_query_arg([
+ 'page' => 'psai_bulk_upload',
+ 'psai_msg' => 'err'
+ ], admin_url('admin.php')));
+ exit;
+ }
+
+ // Debug: Log the $_FILES array
+ error_log('PSAI Bulk Upload - $_FILES: ' . print_r($_FILES, true));
+
+ $result = \PSAI\BulkJobService::create_job($_FILES);
+
+ // Debug: Log the result
+ error_log('PSAI Bulk Upload - Result: ' . print_r($result, true));
+
+ if ($result['success']) {
+ wp_redirect(add_query_arg([
+ 'page' => 'psai_bulk_upload',
+ 'psai_msg' => 'job_created',
+ 'job_id' => $result['job_id']
+ ], admin_url('admin.php')));
+ } else {
+ set_transient('_ps_bulk_error', $result['error'], 300);
+ wp_redirect(add_query_arg([
+ 'page' => 'psai_bulk_upload',
+ 'psai_msg' => 'err'
+ ], admin_url('admin.php')));
+ }
+ exit;
+ } catch (\Throwable $e) {
+ error_log('PSAI Bulk FATAL ERROR: ' . $e->getMessage());
+ error_log('PSAI Bulk FATAL TRACE: ' . $e->getTraceAsString());
+ set_transient('_ps_bulk_error', 'Fatal error: ' . $e->getMessage(), 300);
wp_redirect(add_query_arg([
'page' => 'psai_bulk_upload',
'psai_msg' => 'err'
], admin_url('admin.php')));
+ exit;
}
- exit;
});
// Admin notices for bulk upload
diff --git a/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php b/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php
index e21fb3f..3e5c909 100644
--- a/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php
+++ b/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php
@@ -33,6 +33,11 @@ public static function render(): void
wp_die(__('You do not have sufficient permissions to access this page.', 'postsecret-ai'));
}
+ // Check if database tables exist
+ global $wpdb;
+ $table_jobs = $wpdb->prefix . 'psai_bulk_jobs';
+ $tables_exist = $wpdb->get_var("SHOW TABLES LIKE '{$table_jobs}'") === $table_jobs;
+
$plugin_version = defined('PSAI_VERSION') ? PSAI_VERSION : '0.0.5';
$prompt_version = Prompt::VERSION ?? 'unknown';
$env = get_option(Settings::OPTION, Settings::defaults());
@@ -46,11 +51,29 @@ public static function render(): void
+
+
+
+