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/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-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/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 dd91c61..560d5f6 100644 --- a/wp-content/plugins/postsecret-ai/postsecret-ai.php +++ b/wp-content/plugins/postsecret-ai/postsecret-ai.php @@ -1,12 +1,17 @@ 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 +244,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 +269,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 */ @@ -485,4 +364,277 @@ 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() { + try { + error_log('PSAI Bulk: Handler called'); + + if (!current_user_can('manage_options')) { + error_log('PSAI Bulk: Unauthorized user'); + wp_die('Unauthorized', 403); + } + + 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; + } +}); + +// 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..3e5c909 --- /dev/null +++ b/wp-content/plugins/postsecret-ai/src/AdminBulkUpload.php @@ -0,0 +1,761 @@ +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()); + $model = $env['MODEL_NAME'] ?? 'gpt-4o-mini'; + + ?> +
+ +

+

+ +

+ + +
+

+ + +

+

+ + + + + + +

+
+ + + +
+

+ +
+ + + +
+
+

📦

+

+ +

+ +
+ +
+ +

+ +

+ + + +
+
+ + +
+

+
+ + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + + + + + +
+
+ + + + + ID), - 'psai_process_now_' . (int)$post->ID - ); + // Actions: Re-classify only $reclassify_url = wp_nonce_url( admin_url('admin-post.php?action=psai_reclassify&att=' . (int)$post->ID), 'psai_reclassify_' . (int)$post->ID ); echo '

'; - echo 'Process now '; - echo 'Re-classify'; + echo 'Re-classify'; echo '

'; echo '
'; diff --git a/wp-content/plugins/postsecret-ai/src/AttachmentSync.php b/wp-content/plugins/postsecret-ai/src/AttachmentSync.php index ba5d15b..b44223e 100644 --- a/wp-content/plugins/postsecret-ai/src/AttachmentSync.php +++ b/wp-content/plugins/postsecret-ai/src/AttachmentSync.php @@ -17,7 +17,7 @@ */ final class AttachmentSync { - public static function sync_from_payload(int $front_id, array $payload, ?int $back_id = null): void + public static function sync_from_payload(int $front_id, array $payload, ?int $back_id = null, bool $force_update = false): void { $containsPII = (bool)($payload['moderation']['containsPII'] ?? false); @@ -36,7 +36,7 @@ public static function sync_from_payload(int $front_id, array $payload, ?int $ba $frontCaption = self::format_caption($allFacets); $frontDesc = $secretDesc; - self::apply_if_empty($front_id, $frontAlt, $frontCaption, $frontDesc, 'front', $containsPII); + self::apply_if_empty($front_id, $frontAlt, $frontCaption, $frontDesc, 'front', $containsPII, $force_update); // BACK (optional) if ($back_id) { @@ -46,34 +46,56 @@ public static function sync_from_payload(int $front_id, array $payload, ?int $ba $capBack = $frontCaption; // keep facets consistent $descBack = $containsPII ? '' : self::clean_str($backArt); // stay minimal on back - self::apply_if_empty($back_id, $altBack, $capBack, $descBack, 'back', $containsPII); + self::apply_if_empty($back_id, $altBack, $capBack, $descBack, 'back', $containsPII, $force_update); } } - private static function apply_if_empty(int $att_id, string $alt, string $caption, string $desc, string $side, bool $containsPII): void + private static function apply_if_empty(int $att_id, string $alt, string $caption, string $desc, string $side, bool $containsPII, bool $force_update = false): void { + error_log("AttachmentSync::apply_if_empty called for att_id={$att_id}, force_update=" . ($force_update ? 'true' : 'false')); + // ALT (short, no HTML) $alt = self::clip_words($alt ?: ($side === 'back' ? 'Back of postcard' : 'Postcard front'), 120); $existingAlt = get_post_meta($att_id, '_wp_attachment_image_alt', true); - if ($existingAlt === '' && $alt !== '') { + if (($existingAlt === '' || $force_update) && $alt !== '') { update_post_meta($att_id, '_wp_attachment_image_alt', $alt); } // CAPTION (facets → "#facet #facet …" up to 140 chars) $existingPost = get_post($att_id); $existingCap = is_object($existingPost) ? trim((string)$existingPost->post_excerpt) : ''; - if ($existingCap === '' && $caption !== '') { - // Guard against PII: captions won’t include transcription anyway, so safe + if (($existingCap === '' || $force_update) && $caption !== '') { + // Guard against PII: captions won't include transcription anyway, so safe wp_update_post(['ID' => $att_id, 'post_excerpt' => $caption]); } - // DESCRIPTION (only objective summary; skip if PII) + // DESCRIPTION (objective summary from secretDescription, which is always PII-free by design) + // Note: secretDescription is an AI-generated summary, not raw extracted text, so it's safe even when containsPII=true $existingDesc = is_object($existingPost) ? trim((string)$existingPost->post_content) : ''; - if ($existingDesc === '' && !$containsPII && $desc !== '') { - // Preserve line breaks; strip dangerous tags - $safe = esc_html($desc); + error_log("AttachmentSync: existingDesc='" . substr($existingDesc, 0, 100) . "', force_update=" . ($force_update ? 'true' : 'false') . ", containsPII=" . ($containsPII ? 'true' : 'false') . ", desc='" . substr($desc, 0, 100) . "'"); + if (($existingDesc === '' || $force_update) && $desc !== '') { + // Strip dangerous tags but preserve text as-is (no entity encoding) + // Use strip_tags instead of wp_kses to avoid any WP sanitization + $safe = strip_tags($desc); $safe = str_replace("\n", "\n\n", $safe); // WP autop likes blank lines - wp_update_post(['ID' => $att_id, 'post_content' => $safe]); + + // Debug: log what we're about to store + error_log("AttachmentSync: Storing desc for att_id={$att_id}, value=" . substr($safe, 0, 200)); + + // Update directly via wpdb to avoid wp_update_post's sanitization + global $wpdb; + $wpdb->update( + $wpdb->posts, + ['post_content' => $safe], + ['ID' => $att_id], + ['%s'], + ['%d'] + ); + clean_post_cache($att_id); + + // Debug: verify what was stored + $verify = $wpdb->get_var($wpdb->prepare("SELECT post_content FROM {$wpdb->posts} WHERE ID = %d", $att_id)); + error_log("AttachmentSync: Verified stored value=" . substr($verify, 0, 200)); } } diff --git a/wp-content/plugins/postsecret-ai/src/BulkJobService.php b/wp-content/plugins/postsecret-ai/src/BulkJobService.php new file mode 100644 index 0000000..c6b6fcb --- /dev/null +++ b/wp-content/plugins/postsecret-ai/src/BulkJobService.php @@ -0,0 +1,740 @@ + false, 'error' => 'No files uploaded.']; + } + + error_log('PSAI Bulk: Starting job creation'); + error_log('PSAI Bulk: Files structure: ' . print_r($files['psai_bulk_files'], true)); + + // 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; + + // Compute hashes for all files first + $file_hashes = []; + foreach ($image_files as $file_path) { + $relative_path = str_replace($staging_path, '', $file_path); + $sha256 = @hash_file('sha256', $file_path) ?: ''; + $file_hashes[] = [ + 'path' => $relative_path, + 'hash' => $sha256, + 'full_path' => $file_path, + ]; + } + + // Batch-fetch existing hashes to avoid N queries + $all_hashes = array_column($file_hashes, 'hash'); + $existing_hashes = []; + + if (!empty($all_hashes)) { + $placeholders = implode(',', array_fill(0, count($all_hashes), '%s')); + $query = $wpdb->prepare( + "SELECT DISTINCT meta_value FROM {$wpdb->postmeta} + WHERE meta_key = '_ps_sha256' AND meta_value IN ({$placeholders})", + ...$all_hashes + ); + $results = $wpdb->get_col($query); + $existing_hashes = array_flip($results); // Use as lookup set + } + + // Create item records + $table_items = $wpdb->prefix . 'psai_bulk_items'; + foreach ($file_hashes as $file_data) { + $status = isset($existing_hashes[$file_data['hash']]) ? self::ITEM_SKIPPED : self::ITEM_PENDING; + + $wpdb->insert($table_items, [ + 'job_id' => $job_id, + 'file_path' => $file_data['path'], + 'sha256' => $file_data['hash'], + 'status' => $status, + 'attempts' => 0, + 'created_at' => current_time('mysql'), + 'updated_at' => current_time('mysql'), + ]); + } + + error_log('PSAI Bulk: Job created successfully - ID: ' . $job_id); + return ['success' => true, 'job_id' => $job_id]; + + } catch (\Throwable $e) { + $error_msg = 'Job creation failed: ' . $e->getMessage(); + error_log('PSAI Bulk Error: ' . $error_msg); + error_log('PSAI Bulk Trace: ' . $e->getTraceAsString()); + return ['success' => false, 'error' => $error_msg]; + } + } + + /** + * Extract ZIP file and return image file paths. + * Validates each entry to prevent Zip Slip attacks and ZIP bombs. + * + * @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.']; + } + + // Normalize destination path for safe comparison + $dest_real = realpath($dest_path); + if ($dest_real === false) { + $zip->close(); + return ['success' => false, 'error' => 'Invalid destination path.']; + } + + // ZIP bomb protection thresholds + $max_files = 5000; + $max_total_bytes = 2 * 1024 * 1024 * 1024; // 2GB + $cumulative_size = 0; + $file_count = 0; + + $files = []; + $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + + // Manually extract each file after validation + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry_name = $zip->getNameIndex($i); + if ($entry_name === false) { + continue; + } + + // Security checks for path traversal (Zip Slip) + // 1. Reject absolute paths + if (strpos($entry_name, '/') === 0 || preg_match('/^[a-zA-Z]:/', $entry_name)) { + error_log("Bulk upload: Rejected absolute path in ZIP: {$entry_name}"); + continue; + } + + // 2. Reject directory traversal sequences + if (strpos($entry_name, '../') !== false || strpos($entry_name, '..\\') !== false) { + error_log("Bulk upload: Rejected directory traversal in ZIP: {$entry_name}"); + continue; + } + + // 3. Reject control characters and null bytes + if (preg_match('/[\x00-\x1F\x7F]/', $entry_name)) { + error_log("Bulk upload: Rejected entry with control characters: {$entry_name}"); + continue; + } + + // 4. Normalize and verify final path stays within destination + $target_path = $dest_path . DIRECTORY_SEPARATOR . $entry_name; + $target_real = realpath(dirname($target_path)); + + // If parent directory doesn't exist yet, create it safely + if ($target_real === false) { + $parent_dir = dirname($target_path); + if (!wp_mkdir_p($parent_dir)) { + error_log("Bulk upload: Failed to create directory: {$parent_dir}"); + continue; + } + $target_real = realpath($parent_dir); + } + + // Verify the target is within destination directory + if ($target_real === false || strpos($target_real, $dest_real) !== 0) { + error_log("Bulk upload: Rejected path outside destination: {$entry_name}"); + continue; + } + + // Skip directories + if (substr($entry_name, -1) === '/') { + continue; + } + + // Only extract allowed image file types + $ext = strtolower(pathinfo($entry_name, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed_extensions, true)) { + continue; + } + + // Extract the file + $content = $zip->getFromIndex($i); + if ($content === false) { + error_log("Bulk upload: Failed to read entry: {$entry_name}"); + continue; + } + + $content_size = strlen($content); + + // ZIP bomb protection: check cumulative size and file count + $cumulative_size += $content_size; + $file_count++; + + if ($file_count > $max_files) { + $zip->close(); + error_log("Bulk upload: ZIP bomb protection - exceeded max files ({$max_files})"); + return ['success' => false, 'error' => "ZIP contains too many files (max {$max_files})."]; + } + + if ($cumulative_size > $max_total_bytes) { + $zip->close(); + $max_gb = round($max_total_bytes / (1024 * 1024 * 1024), 1); + error_log("Bulk upload: ZIP bomb protection - exceeded max size ({$max_gb}GB)"); + return ['success' => false, 'error' => "ZIP decompressed size exceeds {$max_gb}GB limit."]; + } + + if (file_put_contents($target_path, $content) !== false) { + $files[] = $target_path; + } else { + error_log("Bulk upload: Failed to write file: {$target_path}"); + } + } + + $zip->close(); + + 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 { + // Validate and sanitize file path to prevent directory traversal + $staging_base = trailingslashit($job['staging_path']); + $staging_real = realpath($staging_base); + + if ($staging_real === false) { + return ['success' => false, 'error' => 'Invalid staging directory.']; + } + + // Concatenate the paths + $file_path = $staging_base . $item['file_path']; + + // Resolve the actual path + $file_real = realpath($file_path); + + // Security: Verify the resolved path is within staging directory + if ($file_real === false || strpos($file_real, $staging_real) !== 0) { + error_log("Bulk upload: Path traversal attempt blocked - file_path: {$item['file_path']}"); + return ['success' => false, 'error' => 'Invalid file path (security check failed).']; + } + + if (!file_exists($file_real)) { + return ['success' => false, 'error' => 'File not found: ' . basename($item['file_path'])]; + } + + // Use the validated path for all subsequent operations + $file_path = $file_real; + + // 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; + } +} 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..18a845d --- /dev/null +++ b/wp-content/plugins/postsecret-ai/src/ClassificationService.php @@ -0,0 +1,173 @@ + 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, $force); + + // 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'); + } + + // Convert to WebP after classification (so AI sees original format) + if (get_post_meta($front_id, '_ps_needs_webp_conversion', true)) { + $webp_success = Ingress::convert_to_webp($front_id); + if (!$webp_success) { + // Non-fatal: log error but keep flag for retry + error_log("WebP conversion failed for front attachment {$front_id}"); + } + } + if ($back_id && get_post_meta($back_id, '_ps_needs_webp_conversion', true)) { + $webp_success = Ingress::convert_to_webp($back_id); + if (!$webp_success) { + error_log("WebP conversion failed for back attachment {$back_id}"); + } + } + + // 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); + } +} diff --git a/wp-content/plugins/postsecret-ai/src/Ingress.php b/wp-content/plugins/postsecret-ai/src/Ingress.php index ee90431..ba7505c 100644 --- a/wp-content/plugins/postsecret-ai/src/Ingress.php +++ b/wp-content/plugins/postsecret-ai/src/Ingress.php @@ -40,6 +40,10 @@ public static function sideload(array $fileArr, string $side): ?int } $att_id = (int)$att_id; + // Convert to WebP after upload (raw image used for classification first) + // This happens later in ClassificationService after classification is done + update_post_meta($att_id, '_ps_needs_webp_conversion', '1'); + // Side + initial flags update_post_meta($att_id, '_ps_side', ($side === 'back') ? 'back' : 'front'); update_post_meta($att_id, '_ps_is_vetted', '0'); @@ -130,7 +134,7 @@ public static function pair(?int $front_id, ?int $back_id): void /** * Normalize flags from a previously saved AI payload. - * Useful for “Process now” or any repair action. + * Useful for "Process now" or any repair action. */ public static function normalize_from_existing_payload(int $att_id): void { @@ -151,6 +155,90 @@ public static function normalize_from_existing_payload(int $att_id): void \PSAI\Metadata::compute_and_store($att_id); } } + + /** + * Convert attachment to WebP format. + * + * @param int $att_id Attachment ID + * @return bool Success + */ + public static function convert_to_webp(int $att_id): bool + { + $file_path = get_attached_file($att_id); + if (!$file_path || !file_exists($file_path)) { + return false; + } + + // Skip if already WebP + $ext = strtolower(pathinfo($file_path, PATHINFO_EXTENSION)); + if ($ext === 'webp') { + delete_post_meta($att_id, '_ps_needs_webp_conversion'); + return true; + } + + // Load image editor + $editor = wp_get_image_editor($file_path); + if (is_wp_error($editor)) { + return false; + } + + // Get image dimensions + $size = $editor->get_size(); + $width = (int)($size['width'] ?? 0); + $height = (int)($size['height'] ?? 0); + + // Set quality for WebP (85 is a good balance) + $editor->set_quality(85); + + // Generate WebP filename + $dir = dirname($file_path); + $basename = wp_basename($file_path, '.' . $ext); + $webp_path = trailingslashit($dir) . $basename . '.webp'; + + // Save as WebP + $saved = $editor->save($webp_path, 'image/webp'); + if (is_wp_error($saved) || empty($saved['path'])) { + return false; + } + + // Update attachment metadata + update_attached_file($att_id, $saved['path']); + + // Update post MIME type + wp_update_post([ + 'ID' => $att_id, + 'post_mime_type' => 'image/webp', + ]); + + // Generate thumbnails for WebP + require_once ABSPATH . 'wp-admin/includes/image.php'; + $metadata = wp_generate_attachment_metadata($att_id, $saved['path']); + if (empty($metadata)) { + // Metadata generation failed - restore original state + update_attached_file($att_id, $file_path); + wp_update_post([ + 'ID' => $att_id, + 'post_mime_type' => wp_check_filetype($file_path)['type'] ?? 'image/jpeg', + ]); + @unlink($webp_path); + return false; + } + + wp_update_attachment_metadata($att_id, $metadata); + + // Re-index with new hash (only after successful metadata generation) + self::index($att_id); + + // Delete original file ONLY after all operations succeed + @unlink($file_path); + + // Mark conversion complete + delete_post_meta($att_id, '_ps_needs_webp_conversion'); + update_post_meta($att_id, '_ps_converted_to_webp', '1'); + update_post_meta($att_id, '_ps_original_format', $ext); + + return true; + } } /** diff --git a/wp-content/plugins/postsecret-search/postsecret-search.php b/wp-content/plugins/postsecret-search/postsecret-search.php index 903b1f2..5c48543 100644 --- a/wp-content/plugins/postsecret-search/postsecret-search.php +++ b/wp-content/plugins/postsecret-search/postsecret-search.php @@ -352,6 +352,12 @@ function handle_semantic_search(WP_REST_Request $request) $back_alt = get_post_meta($back_id, '_wp_attachment_image_alt', true) ?: ''; } + // Combine facets (topics, feelings, meanings) into tags array + $topics = (array)(get_post_meta($secret_id, '_ps_topics', true) ?: []); + $feelings = (array)(get_post_meta($secret_id, '_ps_feelings', true) ?: []); + $meanings = (array)(get_post_meta($secret_id, '_ps_meanings', true) ?: []); + $tags = array_values(array_merge($topics, $feelings, $meanings)); + $items[] = [ 'id' => $secret_id, 'similarity' => $similarity, @@ -362,7 +368,7 @@ function handle_semantic_search(WP_REST_Request $request) 'caption' => get_post_field('post_excerpt', $secret_id) ?: '', 'excerpt' => get_post_field('post_content', $secret_id) ?: '', 'date' => get_post_datetime($secret_id)?->format('c'), - 'tags' => array_values((array)(get_post_meta($secret_id, '_ps_tags', true) ?: [])), + 'tags' => $tags, 'primary' => get_post_meta($secret_id, '_ps_primary_hex', true) ?: '', 'orientation' => get_post_meta($secret_id, '_ps_orientation', true) ?: '', 'back_id' => $back_id, diff --git a/wp-content/themes/postsecret/archive-secrets.php b/wp-content/themes/postsecret/archive-secrets.php index f6d076c..e64c782 100644 --- a/wp-content/themes/postsecret/archive-secrets.php +++ b/wp-content/themes/postsecret/archive-secrets.php @@ -1,40 +1,10 @@ - -
-

- - -
- -
- - - -

- -
- -

Search Results for: "${escapeHtml(query)}"

${data.total} secret${data.total !== 1 ? 's' : ''} found

-
+
`; container.innerHTML = ''; @@ -249,22 +250,26 @@ function renderNextBatch(grid) { const cardTemplate = document.getElementById('psai-card-tpl'); - const useMustache = cardTemplate && window.Mustache; - const template = useMustache ? cardTemplate.innerHTML : null; + // Mustache and template are required - fail explicitly if missing + if (!cardTemplate) { + console.error('Card template (#psai-card-tpl) not found'); + throw new Error('Card template not found'); + } + if (!window.Mustache) { + console.error('Mustache library not loaded'); + throw new Error('Mustache library not loaded'); + } + + const template = cardTemplate.innerHTML; const start = displayedCount; const end = Math.min(start + ITEMS_PER_PAGE, allResults.length); for (let i = start; i < end; i++) { const item = allResults[i]; - - if (useMustache) { - const cardData = prepareCardData(item); - const html = window.Mustache.render(template, cardData); - grid.insertAdjacentHTML('beforeend', html); - } else { - renderSimpleCard(grid, item); - } + const cardData = prepareCardData(item); + const html = window.Mustache.render(template, cardData); + grid.insertAdjacentHTML('beforeend', html); } displayedCount = end; @@ -324,16 +329,22 @@ const date = item.date ? new Date(item.date) : null; const similarityPercent = item.similarity ? Math.round(item.similarity * 100) : 0; + // Format date to match front page (e.g., "Oct 3, 2025") + const dateFmt = date ? date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }) : ''; + return { id: item.id, src: item.src, width: item.width, height: item.height, alt: item.alt || 'Secret postcard', - altFallback: item.alt || item.excerpt || 'Secret postcard', + altFallback: item.alt || 'Secret postcard', caption: item.caption, - excerpt: item.excerpt ? item.excerpt.substring(0, 140) + (item.excerpt.length > 140 ? '…' : '') : '', - dateFmt: date ? date.toLocaleDateString() : '', + dateFmt: dateFmt, displayTags: displayTags, overflowCount: overflowCount > 0 ? overflowCount : null, advisory: false, // Set based on content flags if available @@ -348,23 +359,6 @@ }; } - function renderSimpleCard(grid, item) { - const card = document.createElement('article'); - card.className = 'ps-card'; - - // Format similarity score as percentage - const similarityPercent = item.similarity ? Math.round(item.similarity * 100) : 0; - - card.innerHTML = ` - - ${escapeHtml(item.alt || 'Secret')} - ${item.excerpt ? `

${escapeHtml(item.excerpt.substring(0, 140))}

` : ''} - ${item.similarity ? `${similarityPercent}% match` : ''} -
- `; - grid.appendChild(card); - } - function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; diff --git a/wp-content/themes/postsecret/dark-overrides.css b/wp-content/themes/postsecret/dark-overrides.css index a6a3f72..365658b 100644 --- a/wp-content/themes/postsecret/dark-overrides.css +++ b/wp-content/themes/postsecret/dark-overrides.css @@ -304,7 +304,8 @@ input:focus { } /* Search form styling - Best practices */ -.ps-search { +.ps-search, +.ps-semantic-search { display: flex; align-items: stretch; background: transparent; @@ -315,7 +316,14 @@ input:focus { position: relative; } -.ps-search__input { +.ps-search-wrapper { + display: flex; + align-items: stretch; + gap: 0; +} + +.ps-search__input, +.ps-search-input { background: var(--wp--preset--color--bg); border: 1px solid var(--wp--preset--color--border); border-right: none; @@ -333,23 +341,27 @@ input:focus { appearance: none; } -.ps-search__input:hover { +.ps-search__input:hover, +.ps-search-input:hover { border-color: var(--wp--preset--color--muted); } -.ps-search__input:focus { +.ps-search__input:focus, +.ps-search-input:focus { border-color: var(--wp--preset--color--accent); border-right: 1px solid var(--wp--preset--color--accent); z-index: 1; position: relative; } -.ps-search__input::placeholder { +.ps-search__input::placeholder, +.ps-search-input::placeholder { color: var(--wp--preset--color--muted); opacity: 0.7; } -.ps-search__btn { +.ps-search__btn, +.ps-search-button { background: var(--wp--preset--color--bg); border: 1px solid var(--wp--preset--color--border); border-left: 1px solid var(--wp--preset--color--border); @@ -368,44 +380,51 @@ input:focus { appearance: none; } -.ps-search__btn:hover { +.ps-search__btn:hover, +.ps-search-button:hover { background: var(--wp--preset--color--accent); color: #ffffff; border-color: var(--wp--preset--color--accent); } -.ps-search__btn:focus { +.ps-search__btn:focus, +.ps-search-button:focus { outline: 2px solid var(--wp--preset--color--accent); outline-offset: 2px; z-index: 2; position: relative; } -.ps-search__btn:active { +.ps-search__btn:active, +.ps-search-button:active { transform: scale(0.98); } /* Ensure input and button align properly */ -.ps-search__input:focus + .ps-search__btn { +.ps-search__input:focus + .ps-search__btn, +.ps-search-input:focus + .ps-search-button { border-left-color: var(--wp--preset--color--accent); } /* Dark mode specific overrides for search */ -html[data-theme="dark"] .ps-search__input::placeholder { +html[data-theme="dark"] .ps-search__input::placeholder, +html[data-theme="dark"] .ps-search-input::placeholder { color: var(--wp--preset--color--muted); opacity: 0.7; } /* Responsive search */ @media (max-width: 768px) { - .ps-search__input { + .ps-search__input, + .ps-search-input { width: 150px; min-width: 120px; font-size: 0.8125rem; padding: 0.5rem 0.75rem; } - .ps-search__btn { + .ps-search__btn, + .ps-search-button { padding: 0.5rem 0.75rem; } } @@ -430,7 +449,8 @@ html[data-theme="dark"] .ps-search__input::placeholder { font-size: 1.125rem; } - .ps-search__input { + .ps-search__input, + .ps-search-input { width: 150px; } } diff --git a/wp-content/themes/postsecret/functions.php b/wp-content/themes/postsecret/functions.php index 506e189..c62b90b 100644 --- a/wp-content/themes/postsecret/functions.php +++ b/wp-content/themes/postsecret/functions.php @@ -3,6 +3,7 @@ // Enqueue scripts and styles add_action('wp_enqueue_scripts', function () { + // Stream styles used by both front page and search wp_enqueue_style( 'ps-stream', get_stylesheet_directory_uri() . '/assets/css/ps-stream.css', @@ -10,14 +11,6 @@ filemtime(get_stylesheet_directory() . '/assets/css/ps-stream.css') ); - // Semantic search styles - wp_enqueue_style( - 'ps-semantic-search', - get_stylesheet_directory_uri() . '/assets/css/semantic-search.css', - [], - filemtime(get_stylesheet_directory() . '/assets/css/semantic-search.css') - ); - // Mustache (templating) — keep it global so any template can use it wp_enqueue_script( 'mustache', @@ -110,4 +103,9 @@ // Make our child theme the default fallback (prevents "twentytwentyfive" errors) if (!defined('WP_DEFAULT_THEME')) { define('WP_DEFAULT_THEME', 'ollie-child'); -} \ No newline at end of file +} + +// Add shared Mustache card template to footer (used by both front-page and search) +add_action('wp_footer', function () { + get_template_part('parts/card-mustache-template'); +}); \ No newline at end of file diff --git a/wp-content/themes/postsecret/index.php b/wp-content/themes/postsecret/index.php index 07edbb9..5ba54f1 100644 --- a/wp-content/themes/postsecret/index.php +++ b/wp-content/themes/postsecret/index.php @@ -1,29 +1,10 @@ - -
- -

- -
- - + diff --git a/wp-content/themes/postsecret/parts/card.php b/wp-content/themes/postsecret/parts/card.php deleted file mode 100644 index 2c1e26f..0000000 --- a/wp-content/themes/postsecret/parts/card.php +++ /dev/null @@ -1,62 +0,0 @@ - -
> - -
- -
- -
- - -
-
-

- -

-
- - -
- - - - 3 ) : ?> - + - -
- - -
- -
-
-
-
-
diff --git a/wp-content/themes/postsecret/search.php b/wp-content/themes/postsecret/search.php index f6ad974..a2e7b9e 100644 --- a/wp-content/themes/postsecret/search.php +++ b/wp-content/themes/postsecret/search.php @@ -30,49 +30,5 @@ - - - - + +