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 "
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 "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 "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 "Writable: " . (is_writable($staging_base) ? 'Yes' : 'No') . "
\n";
+} else {
+ echo "Parent writable: " . (is_writable($upload_dir['basedir']) ? 'Yes' : 'No') . "
\n";
+}
+
+// Test AJAX endpoint
+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
-