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 @@ + +
+ +

+

+ +

+ + +
+

+ +
+ + +
+
+

📦

+

+ +

+ +
+ +
+ +

+ +

+ + + +
+
+ + +
+

+
+ + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + +
+
+ + + + + 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"; +echo "

\n"; +echo "

\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

+ +
+

+ + +

+

+ + + + + + +

+
+ +

-
+ +
@@ -86,7 +109,7 @@ public static function render(): void

- +
@@ -292,8 +315,9 @@ class="small-text"