Jake Vanderwerf
2026-02-11 1ee52f219a516d831b4be6bd05bce224afa28189
inc/rest/routes/UploadRoutes.php
@@ -1,10 +1,14 @@
<?php
namespace JVBase\rest\routes;
use JVBase\JVB;
use JVBase\rest\RestRouteManager;
use JVBase\meta\MetaManager;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\TypeConfig;
use JVBase\rest\PermissionHandler;
use JVBase\rest\Rest;
use JVBase\meta\Meta;
use JVBase\managers\UploadManager;
use JVBase\rest\Route;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -13,134 +17,240 @@
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}
class UploadRoutes extends RestRouteManager
class UploadRoutes extends Rest
{
    public function __construct()
    {
      $this->action = 'dash-';
        parent::__construct();
        add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3);
      add_action('init', [$this, 'registerUploadExecutors'], 5);
    }
   /**
    * Register upload operation types with the queue's TypeRegistry
    */
   public function registerUploadExecutors(): void
   {
      $registry = JVB()->queue()->registry();
      $executor = new UploadExecutor();
      // Image uploads - chunked at 5 files
      $registry->register('image_upload', new TypeConfig(
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 5
      ));
      // Video uploads - one at a time (heavy processing)
      $registry->register('video_upload', new TypeConfig(
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 1
      ));
      // Document uploads - chunked at 10
      $registry->register('document_upload', new TypeConfig(
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 10
      ));
      // Metadata updates
      $registry->register('update_image_meta', new TypeConfig(
         executor: $executor
      ));
      // Cleanup - chunked at 5
      $registry->register('temporary_cleanup', new TypeConfig(
         executor: $executor,
         chunkKey: 'files',
         chunkSize: 5
      ));
      // Attach to content (depends on upload completing)
      $registry->register('attach_upload_to_content', new TypeConfig(
         executor: $executor
      ));
      // Process upload groups into posts
      $registry->register('process_upload_groups', new TypeConfig(
         executor: $executor,
         chunkKey: 'posts',
         chunkSize: 5
      ));
   }
    /**
     * Registers upload routes
     * @return void
     */
    public function registerRoutes():void
    {
        register_rest_route($this->namespace, '/uploads', [
            'methods' => 'POST',
            'callback' => [$this, 'handleUploadRequest'],
            'permission_callback' => [$this, 'checkPermission'],
            'args' => [
                'content' => [
                    'required' => true,
                    'type' => 'string'
                ],
                'user'   => [
                    'required'  => true,
                    'type'  => 'int'
                ],
                'post_id'   => [
                    'required'  => false,
                    'type'  => 'int'
                ],
                'term_id'   => [
                    'required'  => false,
                    'type'  => 'int'
                ],
                'field_name' => [
                    'required'  => false,
                    'type'  => 'string'
                ],
                'id' => [
                    'required'  => false,
                    'type'  => 'string'
                ],
                'mode' => [
                    'required'  => false,
                    'type'  => 'string',
                    'default' => 'direct'
                ]
            ]
        ]);
      Route::for('uploads')
         ->post([$this, 'handleUpload'])
         ->auth(PermissionHandler::combine(['nonce']))
         ->rateLimit(30)
         ->register();
      register_rest_route($this->namespace, '/uploads/groups', [
         'methods' => 'POST',
         'callback' => [$this, 'handleGroupingRequest'],
         'permission_callback' => [$this, 'checkPermission'],
         'args' => [
            'id' => [
               'required' => true,
               'type' => 'string',
               'description' => 'Original upload operation ID'
            ],
            'content' => [
               'required' => true,
               'type' => 'string'
            ],
            'user' => [
               'required' => true,
               'type' => 'integer'
            ],
         ]
      ]);
      Route::for('uploads/groups')
         ->post([$this, 'handleGroupingRequest'])
         ->auth(PermissionHandler::combine(['nonce']))
         ->rateLimit(30)
         ->args([
            'id'     => 'string|required',
            'content'   => 'string|required',
            'user'      => 'int|required'
         ])
         ->register();
      register_rest_route($this->namespace, '/uploads/meta', [
         'methods' => 'POST',
         'callback' => [$this, 'handleMetadataUpdate'],
         'permission_callback' => [$this, 'checkPermission'],
         'args' => [
            'id' => [
               'type' => 'string',
               'required' => false,
               'description' => 'Original upload operation ID (for updates during processing)'
            ],
            'attachment_ids' => [
               'type' => 'array',
               'required' => false,
               'description' => 'Direct attachment IDs (for updates after completion)',
               'items' => ['type' => 'integer']
            ],
            'upload_ids' => [
               'type' => 'array',
               'required' => false,
               'description' => 'Frontend upload IDs to map to attachments',
               'items' => ['type' => 'string']
            ],
            'metadata' => [
               'type' => 'object',
               'required' => true,
               'description' => 'Metadata changes to apply'
            ],
            'user' => [
               'type' => 'integer',
               'required' => true
            ]
         ]
      ]);
      Route::for('uploads/meta')
         ->post([$this, 'handleMetadataUpdate'])
         ->auth(PermissionHandler::combine(['nonce']))
         ->rateLimit(30)
         ->args([
            'user'   => 'int|required',
            'items'  => 'array|required'
         ])
         ->register();
    }
   /**
    * Build the main $context for UploadManager from the $request params
    * @param WP_REST_Request $request
    * @return array
    */
    protected function buildUploadArgs(WP_REST_Request $request):array
    {
        $data = $request->get_params();
      error_log('Upload Args: '.print_r($data, true));
        $args = [
         'content'        => (array_key_exists('content', $data) && (array_key_exists(jvbGetValidType($data['content']), JVB_CONTENT) || $data['content'] === 'options')) ? jvbGetValidType($data['content']) : false,
            'user'            => (array_key_exists('user', $data) && $this->userCheck($data['user'])) ? (int)$data['user'] : false,
            'id'    => (array_key_exists('id', $data) && is_string($data['id'])) ? sanitize_text_field(str_replace('_upload', '', $data['id'])) : false,
            'post_id'        => (array_key_exists('post_id', $data) && is_numeric($data['post_id'])) ? absint($data['post_id']) : false,
            'term_id'        => (array_key_exists('term_id', $data) && is_numeric($data['term_id'])) ? absint($data['term_id']) : false,
            'field_name'    => (array_key_exists('field_name', $data) && is_string($data['field_name'])) ? sanitize_text_field($data['field_name']) : false,
            'mode'            => (array_key_exists('mode', $data) && in_array($data['mode'], ['direct', 'selection'])) ? sanitize_text_field($data['mode']) : false,
        ];
      $args = [];
      foreach ($data as $key => $value) {
         switch ($key) {
            case 'depends_on':
               if (is_string($value) && !empty($value)) {
                  $args['depends_on'] = sanitize_text_field($value);
               }
               break;
            case 'item_id':
               if (is_numeric($value)) {
                  $args['item_id'] = absint($value);
                  if (!array_key_exists('post_id', $args)) {
                     $args['post_id'] = absint($value);
                  }
               }
               break;
            // Post Type/Taxonomy
            case 'content':
               $key = str_replace('-', '_', $key);
               if ($value === 'options' || array_key_exists($value, JVB_CONTENT) || Features::forTaxonomy($key)->has('is_content')) {
                  $args['content'] = $value;
               }
               break;
            case 'destination':
               if (in_array($value, ['meta', 'post', 'post_group'])) {
                  $args['destination'] = sanitize_text_field($value);
                  if (in_array($value, ['post', 'post_group']) && empty($data['content'])) {
                     throw new Exception("Content type required for destination: {$value}");
                  }
               }
               break;
            // User ID
            case 'user':
               if ($this->userCheck($value)) {
                  $args['user'] = (int) $value;
                  if (!array_key_exists('post_id', $args) &&
                     !array_key_exists('post_id', $data) &&
                     !array_key_exists('term_id', $data) &&
                     !array_key_exists('item_id', $data)) {
                     $args['post_id'] = (int)get_user_meta((int) $value, BASE.'link', true);
                  }
               }
               break;
            // Operation ID
            case 'id':
               if (is_string($value)) {
                  $value = sanitize_text_field($value);
                  $args['id'] =  $value;
                  $args['upload'] = $value.'_upload';
               }
               break;
            // Post ID
            case 'post_id':
               if (is_numeric($value)) {
                  $args['post_id'] = absint($value);
               }
               break;
            // Term ID
            case 'term_id':
               if (is_numeric($value)) {
                  $args['term_id'] = absint($value);
               }
               break;
            // Field Name, as defined for Meta.php
            case 'field_name':
               if (is_string($value)) {
                  $args['field_name'] = sanitize_text_field($value);
               }
               break;
            // Upload Mode
            case 'mode':
               if (in_array($value, ['direct', 'selection'])) {
                  $args['mode'] = sanitize_text_field($value);
               }
               break;
            case 'upload_ids':
               if (is_string($value)) {
                  // Parse JSON array
                  $decoded = json_decode($value, true);
                  if (is_array($decoded)) {
                     $args['upload_ids'] = $decoded;
                  }
               } elseif (is_array($value)) {
                  // Already an array (shouldn't happen with FormData JSON, but handle it)
                  $args['upload_ids'] = $value;
               }
               break;
            case 'metadata':
               if (!empty($value)) {
                  $metadata = is_string($value) ? json_decode($value, true) : $value;
                  if (is_array($metadata)) {
                     foreach ($metadata as $k => $v) {
                        if (in_array($k, ['title', 'caption', 'alt', 'depends_on'])) {
                           $args['metadata'][$k] = sanitize_text_field($v);
                        }
                     }
                  }
               }
               break;
            case 'posts':
               if (is_string($value)) {
                  $decoded = json_decode($value, true);
                  if (is_array($decoded)) {
                     $args['posts'] = $decoded;
                  }
               }
               break;
        if ((!$args['post_id'] && !$args['term_id']) && contentIsJVBUserType($args['content'])) {
            $args['post_id'] = (int)get_user_meta($args['user'], BASE.'link', true);
        }
        $args['upload'] = ($args['id']) ? $args['id'].'_upload' : false;
            case 'group_titles':
               if (is_string($value)) {
                  $decoded = json_decode($value, true);
                  if (is_array($decoded)) {
                     $args['group_titles'] = array_map('sanitize_text_field', $decoded);
                  }
               }
               break;
            // Other field info
            case 'field_key':
            case 'field_type':
            case 'subtype':
            case 'item_id':
            case 'context':
               if (is_string($value)) {
                  $args[$key] = sanitize_text_field($value);
               }
               break;
         }
      }
        return $args;
    }
@@ -152,111 +262,32 @@
     *
     * @return WP_REST_Response
     */
   public function handleUploadRequest(WP_REST_Request $request): WP_REST_Response
   public function handleUpload(WP_REST_Request $request): WP_REST_Response
   {
      try {
         if (!isset($_FILES['files'])) {
            return $this->createStandardResponse(
               false,
               [],
               __('No files uploaded.', 'jvb')
            );
         }
         $files = $request->get_file_params();
         $args = $this->buildUploadArgs($request);
         error_log('Validating upload args: '.print_r($args, true));
         // Validation...
         $validation_errors = [];
         if (!$args['content'] && !$args['term_id'] && !$args['post_id']) $validation_errors[] = 'content or term or post id required';
         if (!$args['user']) $validation_errors[] = 'user';
         if (!$args['id']) $validation_errors[] = 'operation_id';
         if (!empty($validation_errors)) {
            error_log('Validation Errors: '.print_r($validation_errors, true));
            return $this->createStandardResponse(
               false,
               ['missing_fields' => $validation_errors],
               'Required fields missing: ' . implode(', ', $validation_errors)
            );
         if (!$args['user']) {
             return $this->unauthorized();
         }
         if (!$args['content']) {
            return $this->validationError(['message' => 'Missing content']);
         }
         $secured_files = $this->processFileUploads($_FILES['files'], $args);
         // Step 1: Secure all uploaded files
         $secured_files = $this->secureFiles($files, $args);
         if (empty($secured_files)) {
            error_log('Images not processed');
            return $this->createStandardResponse(
               false,
               [],
               'No valid files could be processed'
            );
            $this->logError('No valid files to upload');
            return $this->error('No valid files to upload');
         }
         // Step 2: Queue for processing via OperationQueue
         $operation_id = $this->queueProcessing($secured_files, $args);
         $queue = JVB()->queue();
         error_log('Queuing Operation...');
         $queue->queueOperation(
            'image_upload',
            $args['user'],
            [
               'secured_files' => $secured_files,
               'upload_map'   =>  explode(',',$request->get_param('upload_map')),
               'content' => $args['content'],
               'post_id' => $args['post_id'],
               'term_id' => $args['term_id'],
               'field_name' => $args['field_name'],
               'mode' => $args['mode'],
               'operation_id' => $args['id']
            ],
            [
               'operation_id' => $args['upload'],
               'chunk_key' => 'secured_files',
               'chunk_size' => 5,
               'priority' => 'high',
               'notification' => true,
            ]
         );
         error_log('Adding dependent operations...');
         if ($args['mode'] !== 'selection') {
            $queue->queueOperation(
               'attach_upload_to_content',
               $args['user'],
               $args,
               [
                  'operation_id' => $args['id'],
                  'priority' => 'high',
                  'depends_on' => $args['upload']
               ]
            );
         }
         $queue->queueOperation(
            'temporary_cleanup',
            $args['user'],
            [
               'files' => $secured_files,
               'content' => $args['content'],
               'user' => $args['user']
            ],
            [
               'priority' => 'low',
               'chunk_size'   => 5,
               'chunk_key' => 'files',
               'depends_on' => $args['upload'],
            ]
         );
         return $this->createStandardResponse(
            true,
            [
               'files_count' => count($secured_files),
               'operation_id' => $args['id'],
               'upload_operation_id' => $args['upload']
            ],
            'Files secured and queued for processing'
         );
         return $this->queued($operation_id, 'Files secured and queued for processing');
      } catch (Exception $e) {
         // Error handling...
@@ -269,195 +300,475 @@
               'trace' => $e->getTraceAsString()
            ]
         );
         return $this->createStandardResponse(
            false,
            ['error_code' => 'upload_failed'],
            'Upload processing failed: ' . $e->getMessage()
         );
         return $this->error($e->getMessage());
      }
   }
   protected function processFileUploads(array $files, array $args): array
   /**
    * Secure uploaded files to temporary storage
    */
   protected function secureFiles(array $files, array $args): array
   {
      $uploader = new UploadManager($args['content'], $args['user']);
      $uploader = new UploadManager();
      $secured_files = [];
      $errors = [];
      // Handle different file array structures
//    if ($args['mode'] === 'selection') {
//       // Selection mode: files[group][index]
//       foreach ($files['tmp_name'] as $group => $images) {
//          if (is_array($images)) {
//             foreach ($images as $index => $tmp_name) {
//                if ($files['error'][$group][$index] === UPLOAD_ERR_OK) {
//                   $file = [
//                      'name' => $files['name'][$group][$index],
//                      'type' => $files['type'][$group][$index],
//                      'tmp_name' => $tmp_name,
//                      'error' => $files['error'][$group][$index],
//                      'size' => $files['size'][$group][$index]
//                   ];
//                   $secured_files[$group][] = $uploader->secureUploadedFile($file);
//                }
//             }
//          }
//       }
//    } else {
         // Direct mode: files[index] or files[0][index]
         $tmp_names = isset($files['tmp_name'][0]) && is_array($files['tmp_name'][0])
            ? $files['tmp_name'][0]
            : $files['tmp_name'];
      $context = $args;
      unset($context['upload_ids']);
         foreach ($tmp_names as $index => $tmp_name) {
            $file_data = isset($files['tmp_name'][0]) && is_array($files['tmp_name'][0]) ? [
               'name' => $files['name'][0][$index],
               'type' => $files['type'][0][$index],
               'tmp_name' => $tmp_name,
               'error' => $files['error'][0][$index],
               'size' => $files['size'][0][$index]
            ] : [
               'name' => $files['name'][$index],
               'type' => $files['type'][$index],
               'tmp_name' => $tmp_name,
               'error' => $files['error'][$index],
               'size' => $files['size'][$index]
            ];
      $file_array = $files['files'] ?? $files;
      if (!is_array($file_array['tmp_name'])) {
         $file_array = [
            'name' => [$file_array['name']],
            'type' => [$file_array['type']],
            'tmp_name' => [$file_array['tmp_name']],
            'error' => [$file_array['error']],
            'size' => [$file_array['size']]
         ];
      }
      $tmp_names = $file_array['tmp_name'] ?? [];
            if ($file_data['error'] === UPLOAD_ERR_OK) {
               $secured_files[] = $uploader->secureUploadedFile($file_data);
            }
      foreach ($tmp_names as $index => $tmp_name) {
         $file_data = [
            'name' => $file_array['name'][$index],
            'type' => $file_array['type'][$index],
            'tmp_name' => $tmp_name,
            'error' => $file_array['error'][$index],
            'size' => $file_array['size'][$index]
         ];
         if ($file_data['error'] !== UPLOAD_ERR_OK) {
            $errors[$index] = $this->getUploadErrorMessage($file_data['error']);
            continue;
         }
//    }
      return $secured_files;
         try {
            $secured = $uploader->secureUploadedFile($file_data, $context);
            if (empty($secured)) {
               throw new Exception('Failed to secure file');
            }
            // Embed upload_id directly in the secured file data
            $secured['upload_id'] = $args['upload_ids'][$index] ?? 'upload_' . $index;
            $secured_files[] = $secured;
         } catch (Exception $e) {
            $errors[$index] = $e->getMessage();
         }
      }
      return [
         'files' => $secured_files,
         'errors' => $errors
      ];
   }
    /**
     * Process upload operation in the background
     */
    /**
     * @param WP_Error|array $result
     * @param object $operation
     * @param array $data
     *
     * @return array|WP_Error
     */
    public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
        switch ($operation->type) {
            case 'image_upload':
                return $this->processImageUpload($result, $operation, $data);
         case 'process_upload_groups':
            return $this->processUploadGroups($result, $operation, $data);
         case 'update_metadata':
            return $this->processMetadataUpdate($result, $operation, $data);
         case 'temporary_cleanup':
                return $this->processTemporaryCleanup($result, $operation, $data);
            case 'attach_upload_to_content':
                return $this->attachUploadToContent($result, $operation, $data);
            default:
                return $result;
        }
    }
   protected function getUploadErrorMessage(int $error_code): string
   {
      return match($error_code) {
         UPLOAD_ERR_INI_SIZE => 'File exceeds maximum upload size',
         UPLOAD_ERR_FORM_SIZE => 'File exceeds form maximum size',
         UPLOAD_ERR_PARTIAL => 'File was only partially uploaded',
         UPLOAD_ERR_NO_FILE => 'No file was uploaded',
         UPLOAD_ERR_NO_TMP_DIR => 'Missing temporary folder',
         UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
         UPLOAD_ERR_EXTENSION => 'Upload stopped by extension',
         default => 'Unknown upload error'
      };
   }
   /**
    * Process attachment metadata updates
    * Queue files for processing
    */
   protected function processAttachmentMetadata(WP_Error|array $result, object $operation, array $data): WP_Error|array
   protected function queueProcessing(array $secured_data, array $args): string
   {
      $operation_type = $this->determineOperationType($secured_data['files'][0] ?? []);
      $chunkSize = 5;
      if ($operation_type === 'video') {
         $chunkSize = 1;
      } elseif ($operation_type === 'document') {
         $chunkSize = 10;
      }
      JVB()->queue()->queueOperation(
         $operation_type,
         $args['user'],
         array_merge(
            ['secured_files' => $secured_data['files']],
            $args
         ),
         [
            'operation_id'  => $args['upload'],
            'chunk_key'     => 'secured_files',
            'chunk_size'    => $chunkSize
         ]
      );
      if ($args['mode'] !== 'selection') {
         $dependencies = [$args['upload']];
         if (!empty($args['depends_on'])) {
            $dependencies[] = $args['depends_on'];
         }
         JVB()->queue()->queueOperation(
            'attach_upload_to_content',
            $args['user'],
            $args,
            [
               'priority'      => 'high',
               'operation_id'  => $args['id'],
               'depends_on'    => $dependencies
            ]
         );
      }
      JVB()->queue()->queueOperation(
         'temporary_cleanup',
         $args['user'],
         [
            'files'     => $secured_data['files'],
            'context'   => $args,
         ],
         [
            'priority'      => 'low',
            'chunk_size'    => 5,
            'chunk_key'     => 'files',
            'depends_on'    => $args['upload']
         ]
      );
      return $args['id'];
   }
   /**
    * Determine operation type from file data
    */
   protected function determineOperationType(array $file): string
   {
      $file_type = $file['file_type'] ?? 'image';
      return match($file_type) {
         'video' => 'video_upload',
         'document' => 'document_upload',
         default => 'image_upload'
      };
   }
   /**
    * Step 3: Process operation from queue
    * Called by OperationQueue
    * @param WP_Error|array $result
    * @param object $operation
    * @param array $data
    *
    * @return array|WP_Error
    */
   public function processOperation(array $result, object $operation, array $data): array
   {
      // Only handle our operation types
      $handled_types = [
         'image_upload',
         'video_upload',
         'document_upload',
         'update_metadata',
         'temporary_cleanup',
         'attach_upload_to_content',
         'process_upload_groups'
      ];
      if (!in_array($operation->type, $handled_types)) {
         return $result; // Not our operation, pass through
      }
      try {
         // Route to appropriate handler
         $handler_result = match($operation->type) {
            'image_upload' => $this->processImageUpload($operation, $data),
            'video_upload' => $this->processVideoUpload($operation, $data),
            'document_upload' => $this->processDocumentUpload($operation, $data),
            'update_metadata' => $this->processUploadMeta($result, $operation, $data),
            'temporary_cleanup' => $this->processTemporaryCleanup($result, $operation, $data),
            'attach_upload_to_content' => $this->processAttachToContent($operation, $data),
            'process_upload_groups' => $this->processUploadGroups($result, $operation, $data),
            default => new WP_Error('unknown_type', 'Unknown operation type')
         };
         // Handle WP_Error
         if (is_wp_error($handler_result)) {
            return [
               'success' => false,
               'result' => $handler_result->get_error_message()
            ];
         }
         return $handler_result;
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processOperation',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'operation_type' => $operation->type
            ]
         );
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Standardize processing result
    */
   protected function standardizeResult(array $result): array
   {
      return [
         'attachment_id' => $result['attachment_id'],
         'url' => $result['url'],
         'file' => $result['file'],
         'upload_id' => $result['upload_id'] ?? null
      ];
   }
   protected function processAttachToContent(object $operation, array $data): array
   {
      try {
         $metadata_updates = $data['metadata_updates'];
         $updated_count = 0;
         $failed_count = 0;
         $results = [];
         // Get the results from the upload operation
         $upload_results = JVB()->queue()->getOperationValue($data['upload'], 'result', true);
         foreach ($metadata_updates as $update) {
            try {
               $attachment_id = (int)$update['attachment_id'];
               $upload_id = $update['upload_id'];
         if (empty($upload_results)) {
            throw new Exception('No upload results found for operation: ' . $data['upload']);
         }
               // Verify attachment exists and user has permission
               if (!$this->verifyAttachmentAccess($attachment_id, $operation->user_id)) {
                  $failed_count++;
                  $results[] = [
                     'upload_id' => $upload_id,
                     'attachment_id' => $attachment_id,
                     'success' => false,
                     'error' => 'Attachment not found or no permission'
                  ];
                  continue;
         if (empty($data['post_id']) || str_starts_with((string)($data['item_id'] ?? ''), 'new')) {
            foreach ($operation->dependencies as $depId) {
               $dep = JVB()->queue()->get($depId);
               if ($dep && $dep->type === 'content_update' && !empty($dep->result['new_posts'])) {
                  $itemId = $data['item_id'] ?? null;
                  if ($itemId && isset($dep->result['new_posts'][$itemId])) {
                     $data['post_id'] = $dep->result['new_posts'][$itemId];
                     break;
                  }
               }
               // Apply metadata updates
               $updated_fields = [];
               // Update alt text
               if (isset($update['alt'])) {
                  $alt_text = sanitize_text_field($update['alt']);
                  update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
                  $updated_fields[] = 'alt';
               }
               // Update title
               if (isset($update['title'])) {
                  $title = sanitize_text_field($update['title']);
                  wp_update_post([
                     'ID' => $attachment_id,
                     'post_title' => $title
                  ]);
                  $updated_fields[] = 'title';
               }
               // Update caption
               if (isset($update['caption'])) {
                  $caption = sanitize_textarea_field($update['caption']);
                  wp_update_post([
                     'ID' => $attachment_id,
                     'post_excerpt' => $caption
                  ]);
                  $updated_fields[] = 'caption';
               }
               // Update description
               if (isset($update['description'])) {
                  $description = sanitize_textarea_field($update['description']);
                  wp_update_post([
                     'ID' => $attachment_id,
                     'post_content' => $description
                  ]);
                  $updated_fields[] = 'description';
               }
               $updated_count++;
               $results[] = [
                  'upload_id' => $upload_id,
                  'attachment_id' => $attachment_id,
                  'success' => true,
                  'updated_fields' => $updated_fields
               ];
            } catch (Exception $e) {
               $failed_count++;
               $results[] = [
                  'upload_id' => $update['upload_id'] ?? 'unknown',
                  'attachment_id' => $update['attachment_id'] ?? 0,
                  'success' => false,
                  'error' => $e->getMessage()
               ];
            }
            if (empty($data['post_id'])) {
               throw new Exception('Could not resolve post_id from dependencies');
            }
         }
         // Now attach to the specified content
         if (!empty($data['field_name'])) {
            $this->updateFieldValue($data, $upload_results);
         }
         return [
            'success' => true,
            'data' => $results,
            'updated_count' => $updated_count,
            'failed_count' => $failed_count,
            'message' => "Metadata updated: {$updated_count} succeeded, {$failed_count} failed"
            'result' => 'Attachments linked to content'
         ];
      } catch (Exception $e) {
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Cleanup temporary files after processing
    */
   protected function cleanupTempFiles(array $secured_files, int $user_id): void
   {
      $uploader = new UploadManager();
      foreach ($secured_files as $secured) {
         if (!empty($secured['temp_path']) && file_exists($secured['temp_path'])) {
            @unlink($secured['temp_path']);
         }
      }
      // Clean up empty directories
      $uploader->cleanupEmptyTempDirs($user_id);
   }
   /**
    * Update field value with new attachment IDs
    */
   protected function updateFieldValue(array $data, array $results): void
   {
      if ((!array_key_exists('post_id', $data) && !array_key_exists('term_id', $data) && !array_key_exists('user', $data))  && !array_key_exists('field_name', $data)) {
         return;
      }
      $attachment_ids = array_column($results, 'attachment_id');
      if (array_key_exists('post_id', $data)) {
         $meta = Meta::forPost($data['post_id']);
      } elseif (array_key_exists('term_id', $data)) {
         $meta = Meta::forTerm($data['term_id']);
      } else {
         $link = (int)get_user_meta($data['user'], BASE.'link');
         $meta = Meta::forPost($link);
      }
      // Get existing value
      $existing = $meta->get($data['field_name']);
      $existing_ids = !empty($existing) ? explode(',', $existing) : [];
      // Merge with new IDs
      $all_ids = array_unique(array_merge($existing_ids, $attachment_ids));
      // Update with comma-separated string
      $meta->set($data['field_name'], implode(',', $all_ids));
   }
   /**
    * Generic file upload processor - works for images, videos, documents
    */
   protected function processFileUpload(object $operation, array $data, string $file_type): WP_Error|array
   {
      try {
         $uploader = new UploadManager();
         $processed_results = [];
         $config = $this->getFieldConfig($data);
         $args = $data;
         unset($args['secured_files']);
         foreach ($data['secured_files'] as $secured_file) {
            $result = $uploader->processUpload(
               $secured_file['temp_path'],
               array_merge(
                  $config,
                  [
                     'file_type' => $file_type,
                     'user_id' => $operation->user_id,
                     'post_id' => (int)($data['post_id'] ?? 0),
                     'term_id' => (int)($data['term_id'] ?? 0),
                     'original_name' => $secured_file['original_name'],
                     'mime_type' => $secured_file['mime_type'],
                     'content' => $data['content'] ?? '',
                  ]
               )
            );
            if (!is_wp_error($result)) {
               $standardized = $this->standardizeResult($result);
               // Use embedded upload_id from the secured file itself
               $standardized['upload_id'] = $secured_file['upload_id'] ?? null;
               if ($standardized['upload_id']) {
                  $processed_results[$standardized['upload_id']] = $standardized;
               } else {
                  $processed_results[] = $standardized;
               }
               // Apply frontend metadata if provided
               if (!empty($secured_file['metadata'])) {
                  $this->applyMeta($standardized['attachment_id'], $secured_file['metadata']);
               }
            }
         }
         $this->handleUploadDestination($data, $processed_results);
         // Cleanup temporary files
         $this->cleanupTempFiles($data['secured_files'], $operation->user_id);
         return [
            'success' => true,
            'result' => $processed_results
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processAttachmentMetadata',
            '[UploadRoutes]:processFileUpload',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'file_type' => $file_type,
               'user_id' => $operation->user_id
            ]
         );
         return [
            'success'   => false,
            'result'    => $e->getMessage()
         ];
      }
   }
   /**
    * Process image uploads
    */
   protected function processImageUpload(object $operation, array $data): WP_Error|array
   {
      return $this->processFileUpload($operation, $data, 'image');
   }
   /**
    * Process video uploads
    */
   protected function processVideoUpload(object $operation, array $data): WP_Error|array
   {
      return $this->processFileUpload($operation, $data, 'video');
   }
   /**
    * Process document uploads
    */
   protected function processDocumentUpload(object $operation, array $data): WP_Error|array
   {
      return $this->processFileUpload($operation, $data, 'document');
   }
   /**
    * Process temporary cleanup operation
    * Called by OperationQueue for 'temporary_cleanup' operations
    *
    * @param array $result
    * @param object $operation
    * @param array $data
    * @return array
    */
   protected function processTemporaryCleanup(array $result, object $operation, array $data): array
   {
      try {
         // Cleanup temporary files if they exist
         if (!empty($data['secured_files'])) {
            $this->cleanupTempFiles($data['secured_files'], $operation->user_id);
         }
         // If specific temp paths provided
         if (!empty($data['temp_paths']) && is_array($data['temp_paths'])) {
            foreach ($data['temp_paths'] as $temp_path) {
               if (file_exists($temp_path)) {
                  @unlink($temp_path);
               }
            }
         }
         // Cleanup empty temp directories for this user
         if (!empty($operation->user_id)) {
            $uploader = new UploadManager();
            $uploader->cleanupEmptyTempDirs($operation->user_id);
         }
         return [
            'success' => true,
            'result' => 'Temporary files cleaned up successfully'
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processTemporaryCleanup',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
@@ -465,17 +776,259 @@
            ]
         );
         return new WP_Error(
            'metadata_processing_failed',
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Handle metadata update requests
    */
   public function handleMetadataUpdate(WP_REST_Request $request): WP_REST_Response
   {
      try {
         $data = $request->get_params();
         error_log('Received data for meta change: '.print_r($data, true));
         $items = $data['items']??false;
         if (!$items) {
            return $this->sendResponse(
               true,
               [
               ],
               'No items to update'
            );
         }
         $pending = [];
         $attachments = array_filter($items, function ($item) {
            return array_key_exists('attachmentId', $item) || array_key_exists('uploadId', $item);
         });
         if (!empty($attachments)) {
            error_log('Attachments: '.print_r($attachments, true));
            return $this->queueMetaUpdate($attachments, $data['user']);
         }
         return $this->sendResponse(
            false,
            ['error_code' => 'missing_identifiers'],
            'Must provide either attachment_ids or operation_id'
         );
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:handleMetadataUpdate',
            $e->getMessage(),
            [
               'request_data' => $request->get_params(),
               'trace' => $e->getTraceAsString()
            ]
         );
         return $this->sendResponse(
            false,
            ['error_code' => 'metadata_update_failed'],
            'Metadata update failed: ' . $e->getMessage()
         );
      }
   }
   /**
    * Update metadata directly on completed attachments
    */
   protected function updateMeta(array $data, int $user): WP_REST_Response
   {
      $updated_count = 0;
      $errors = [];
      $ids = [];
      foreach ($data as $info) {
         try {
            $attachment_id = $info['attachmentId'];
            error_log('Updating attachment ID:'.print_r($attachment_id,true));
            $ids[] = $attachment_id;
            unset($info['attachmentId']);
            // Verify attachment exists and user has permission
            if (!$this->verifyAttachmentAccess($attachment_id, $user)) {
               $errors[] = "No permission to edit attachment {$attachment_id}";
               continue;
            }
            $this->applyMeta($attachment_id, $info);
            $updated_count++;
         } catch (Exception $e) {
            $errors[] = "Failed to update attachment {$attachment_id}: " . $e->getMessage();
         }
      }
      return $this->sendResponse(
         $updated_count > 0,
         [
            'updated_count' => $updated_count,
            'errors' => $errors,
            'attachment_ids' => $ids
         ],
         $updated_count > 0 ?
            "Updated metadata for {$updated_count} attachment(s)" :
            'No attachments were updated'
      );
   }
   /**
    * Queue metadata update with dependency on upload operation
    */
   protected function queueMetaUpdate(array $data, int $user): WP_REST_Response
   {
      $queue = JVB()->queue();
      $depends_on = [];
      $errors = [];
      $original = count($data);
      foreach ($data as $uploadID => $info) {
         if (array_key_exists('depends_on', $info) && !in_array($info['depends_on'], $depends_on)) {
            $depends_on[] = $info['depends_on'];
         }
      }
      $operationID = $queue->queueOperation(
         'update_image_meta',
         $user,
         $data,
         [
            'depends_on' => $depends_on,
         ]
      );
      return $this->sendResponse(
         true,
         [
            'operation_id' => $operationID,
            'message'      => "Successfully queued ".count($data)." of {$original} meta updates"
         ],
         'Metadata update queued - will apply after upload completes'
      );
   }
   /**
    * Process metadata update operation (called by queue processor)
    */
   public function processUploadMeta(WP_Error|array $result, object $operation, array $data): WP_Error|array
   {
      try {
         if (!is_array($operation->depends_on)) {
            $operation->depends_on = [$operation->depends_on];
         }
         $updated_count = 0;
         $errors = [];
         foreach ($operation->depends_on as $dependency) {
            $operationData = JVB()->queue()->getOperation($dependency);
            if (!$operationData || $operationData->state !== 'completed') {
               throw new Exception('Original upload operation not found or not completed');
            }
            $uploadResults = json_decode($operationData->result, true);
            if (!$uploadResults || !$uploadResults['success']) {
               throw new Exception('Original upload operation failed');
            }
            $attachmentsToUpdate = array_filter($data, function ($item) use ($dependency) {
               return $item['depends_on'] === $dependency;
            });
            $uploadIDToAttachment = $this->buildUploadToAttachmentMapping(
               array_keys($attachmentsToUpdate),
               $uploadResults['data']
            );
            if (empty($uploadIDToAttachment)) {
               throw new Exception('No valid upload ID to attachment ID mappings found');
            }
            foreach ($uploadIDToAttachment as $uploadId => $attachmentId) {
               try {
                  $this->applyMeta($attachmentId, $attachmentsToUpdate[$uploadId]);
                  $updated_count++;
               } catch (Exception $e) {
                  $errors[] = "Upload {$uploadId} (Attachment {$attachmentId}): " . $e->getMessage();
               }
            }
         }
         return [
            'success' => $updated_count > 0,
            'result' => [
               'updated_count' => $updated_count,
               'mappings' => $uploadIDToAttachment,
               'errors' => $errors,
               'message' => "Applied metadata to {$updated_count} attachment(s)"
            ]
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processUploadMeta',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'error_code' => 'metadata_update_error'
               'original_operation_id' => $data['original_operation_id'] ?? 'unknown'
            ]
         );
         return [
            'success'   => false,
            'result' => $e->getMessage()
         ];
      }
   }
   protected function applyMeta(int $attachment_id, array $metadata): void
   {
      // Update alt text
      if (!empty($metadata['image-alt-text'])) {
         update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($metadata['image-alt-text']));
      }
      $postUpdates = [];
      // Update title
      if (!empty($metadata['image-title'])) {
         $postUpdates['post_title'] = $metadata['image-title'];
      }
      // Update caption
      if (!empty($metadata['image-caption'])) {
         $postUpdates['post_excerpt'] = sanitize_textarea_field($metadata['image-caption']);
      }
      if (!empty($postUpdates)) {
         $postUpdates['ID'] = $attachment_id;
         wp_update_post($postUpdates);
      }
   }
   /**
    * Build mapping from frontend upload IDs to WordPress attachment IDs
    */
   protected function buildUploadToAttachmentMapping(array $upload_ids, array $results): array
   {
      $mapping = [];
      foreach ($results as $result) {
         if (!isset($result['upload_id']) || !isset($result['attachment_id'])) {
            continue;
         }
         $upload_id = $result['upload_id'];
         $attachment_id = $result['attachment_id'];
         // Validate that this upload_id was requested
         if (in_array($upload_id, $upload_ids)) {
            $mapping[$upload_id] = $attachment_id;
         }
      }
      return $mapping;
   }
   /**
    * Verify user has access to attachment
    */
@@ -491,177 +1044,58 @@
      return ($attachment->post_author == $user_id) || current_user_can('manage_options');
   }
   protected function processImageUpload(WP_Error|array $result, object $operation, array $data): WP_Error|array
   {
      try {
         $uploader = new UploadManager($data['content'], $operation->user_id);
         $processed_results = [];
         // Extract frontend metadata if available
         $frontend_metadata = $this->extractFrontendMetadata($data);
         foreach ($data['secured_files'] as $index => $secured_file) {
            $result = $uploader->processImageFromStorage(
               $secured_file['temp_path'],
               $data['content'],
               (int)($data['post_id'] ?? 0),
               (int)($data['term_id'] ?? 0),
               $secured_file['original_name'] ?? ''
            );
            if (!is_wp_error($result)) {
               $standardized = $this->standardizeImageResult($result);
               // FIX: Proper mapping using consistent naming
               if (isset($data['upload_map'][$index])) {
                  $standardized['upload_id'] = $data['upload_map'][$index];
               } else {
                  error_log("Warning: No upload_map found for index {$index}");
                  $standardized['upload_id'] = 'unknown_' . $index;
               }
               $file_metadata = $frontend_metadata[$index] ?? null;
               if ($file_metadata) {
                  $this->applyFrontendMetadata($standardized['attachment_id'], $file_metadata);
               }
               $processed_results[] = $standardized;
            }
         }
         // Update field metadata if specified
         if ($data['field_name']) {
            $this->updateFieldMetadata($data, $processed_results);
         }
         return [
            'success' => true,
            'result' => $processed_results
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processImageUpload',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'user_id' => $operation->user_id,
               'content' => $data['content'],
               'upload_map' => $data['upload_map'] ?? 'missing'
            ]
         );
         return [
            'success'   => false,
            'result' => $e->getMessage()
         ];
      }
   }
   protected function extractFrontendMetadata(array $data): array
   {
      $metadata = [];
      // Check if we have metadata in the request
      if (isset($_POST['metadata'])) {
         foreach ($_POST['metadata'] as $index => $meta_json) {
            $decoded = json_decode($meta_json, true);
            if ($decoded) {
               $metadata[$index] = $decoded;
            }
         }
      }
      return $metadata;
   }
   protected function applyFrontendMetadata(int $attachment_id, array $metadata): void
   {
      // Update alt text
      if (!empty($metadata['alt'])) {
         update_post_meta($attachment_id, '_wp_attachment_image_alt', sanitize_text_field($metadata['alt']));
      }
      // Update title
      if (!empty($metadata['title'])) {
         wp_update_post([
            'ID' => $attachment_id,
            'post_title' => sanitize_text_field($metadata['title'])
         ]);
      }
      // Update caption
      if (!empty($metadata['caption'])) {
         wp_update_post([
            'ID' => $attachment_id,
            'post_excerpt' => sanitize_textarea_field($metadata['caption'])
         ]);
      }
   }
   protected function countProcessedFiles(array $processed_results): int
   {
      $count = 0;
      foreach ($processed_results as $result) {
         if (is_array($result) && isset($result[0])) {
            // Grouped results
            $count += count($result);
         } else {
            // Single result
            $count++;
         }
      }
      return $count;
   }
   protected function updateFieldMetadata(array $data, array $processed_results): void
   {
      if (!$data['field_name']) return;
      $type = ($data['post_id']) ? 'post' : (($data['term_id']) ? 'term' : false);
      if (!$type) return;
      $ID = ($type == 'post') ? $data['post_id'] : $data['term_id'];
      $meta = new MetaManager($ID, $type);
      // Get attachment IDs from processed results
      $attachment_ids = [];
      if ($data['mode'] == 'selection') {
         // Selection mode: grouped results
         foreach ($processed_results as $group => $files) {
            foreach ($files as $file_result) {
               $attachment_ids[] = $file_result['attachment_id'];
            }
         }
      } else {
         // Direct mode: flat array
         foreach ($processed_results as $file_result) {
            $attachment_ids[] = $file_result['attachment_id'];
         }
      }
      if (!empty($attachment_ids)) {
         $value = implode(',', array_map('absint', $attachment_ids));
         $meta->updateValue($data['field_name'], $value);
      }
   }
   /**
    * Standardize image processing results for frontend
    * Get field configuration for upload processing
    */
   protected function standardizeImageResult(array $result): array
   protected function getFieldConfig(array $data): array
   {
      return [
         'attachment_id' => $result['attachment_id'],
         'url' => $result['url'],
         'file_path' => $result['file'] ?? '',
         'success' => $result['success'] ?? true,
         'metadata' => [
            'alt_text' => get_post_meta($result['attachment_id'], '_wp_attachment_image_alt', true),
            'title' => get_the_title($result['attachment_id']),
            'mime_type' => get_post_mime_type($result['attachment_id'])
         ]
      static $config_cache = [];
      $cache_key = md5(json_encode([
         $args['content'] ?? '',
         $args['field_name'] ?? ''
      ]));
      if (isset($config_cache[$cache_key])) {
         return $config_cache[$cache_key];
      }
      $config = [
         'allowed_types' => null,
         'max_size' => null,
         'convert' => 'webp',
         'quality' => 80,
         'create_thumbnails' => true,
      ];
      // Get field definition from registry if available
      if (!empty($args['content']) && !empty($args['field_name'])) {
         $content_type = $args['content'];
         $field_name = $args['field_name'];
         if (array_key_exists($content_type, JVB_CONTENT)) {
            $content_fields = JVB_CONTENT[$content_type]['fields'] ?? [];
            if (array_key_exists($field_name, $content_fields)) {
               $field_def = $content_fields[$field_name];
               // Extract relevant config from field definition
               $config = array_merge($config, [
                  'allowed_types' => $field_def['accepted_types'] ?? null,
                  'max_size' => $field_def['max_size'] ?? null,
                  'convert' => $field_def['convert'] ?? 'webp',
                  'quality' => $field_def['quality'] ?? 80,
                  'create_thumbnails' => $field_def['create_thumbnails'] ?? true,
               ]);
            }
         }
      }
      $config_cache[$cache_key] = $config;
      return $config;
   }
   /**
    * Get files info for error logging
    */
@@ -674,164 +1108,7 @@
      ];
   }
    /**
     * @param WP_Error $result
     * @param object $operation
     * @param array $data
     *
     * @return array|WP_Error
     */
    public function processTemporaryCleanup(WP_Error|array $result, object $operation, array $data):WP_Error|array
    {
      error_log('Processing Temporary Cleanup...');
        $JVB = JVB();
        $logger = $JVB->error();
      $uploader = new UploadManager($data['content'], $data['user']);
        // Check if we have files to clean up
        if (empty($data['files']) || !is_array($data['files'])) {
            return ['success' => true, 'result' => 'No files to clean up'];
        }
        $success_count = 0;
        $error_count = 0;
        $skipped_count = 0;
        foreach ($data['files'] as $file_data) {
            // Skip if we don't have a temp path
            if (empty($file_data['temp_path'])) {
                $skipped_count++;
                continue;
            }
            $temp_path = $file_data['temp_path'];
            try {
                // Only remove if the file exists
                if (file_exists($temp_path)) {
                    if (@unlink($temp_path)) {
                        $success_count++;
                    } else {
                        $error_count++;
                        $logger->log(
                            'temp_cleanup',
                            'Failed to delete temporary file',
                            ['file' => $temp_path],
                            'warning'
                        );
                    }
                } else {
                    // File already gone, count as success
                    $skipped_count++;
                }
            } catch (Exception $e) {
                $error_count++;
                $logger->log(
                    '[UploadManager]:processTempCleanupOperation',
                    'Error during temp file cleanup: ' . $e->getMessage(),
                    ['file' => $temp_path],
                    'warning'
                );
            }
        }
        // After cleaning individual files, check if directory is empty and can be removed
        $uploader->cleanupEmptyTempDirs($operation->user_id);
        return [
            'success' => true,
         'result' => [
            'removed' => $success_count,
            'errors' => $error_count,
            'skipped' => $skipped_count,
            'message' => "Temporary files cleanup completed: {$success_count} removed, {$error_count} errors, {$skipped_count} skipped"
         ]
        ];
    }
   protected function attachUploadToContent($result, $operation, $args):array|false
   {
      error_log('STARTING ATTACHING UPLOAD');
      error_log('Upload ID: '.print_r($args['upload'], true));
      $uploadOperation = JVB()->queue()->getOperation($args['upload'], true);
      error_log('Upload Operation from Attach Image to Content: '.print_r($uploadOperation, true));
      error_log('Args: '.print_r($args, true));
      $field = ($args['field_name']??false) ? $args['field_name'] : 'post_thumbnail';
      $ID = $args['post_id']??$args['term_id'];
      $return = [
         'success'   => false,
         'results'   => []
      ];
      $images = array_map('absint', array_column(json_decode($uploadOperation->result, true),'attachment_id'));
      error_log('Images: '.print_r($images, true));
      error_log('Parsed ID: '.print_r($ID, true));
      if (array_key_exists('content', $args) && $args['content'] === 'options') {
         $meta = new MetaManager(null, 'options');
         if (str_contains($args['field_name'], ':')) {
            $result = $meta->updateRepeaterRowField($args['field_name'], (count($images) === 1) ? $images[0] : $images);
         } else {
            $result = $meta->updateValue($args['field_name'], (count($images) === 1) ? $images[0] : $images);
         }
         $return = [
            'success'   => $result,
         ];
      } elseif (str_starts_with($field, 'new_') && !$ID) {
         //Create post
         $success = [
            'created'   => [],
            'errors' => []
         ];
         $i = 1;
         foreach ($images as $img) {
            $ID = wp_insert_post([
               'post_type'    => jvbCheckBase($args['content']),
               'post_author'  => $operation->user_id,
               'post_title'   => 'New '.JVB_CONTENT[$args['content']]['singular'].' '.$i,
               'post_content' => ' ',
               'post_excerpt' => ' '
            ], true);
            error_log('Created Post: '.print_r($ID, true));
            if ($ID && !is_wp_error($ID)) {
               $success['created'][] = $ID;
               set_post_thumbnail($ID, $img);
            }else {
               $success['errors'][] = $ID;
            }
            $i++;
         }
         $return = [
            'success'   => true,
            'result' => $success
         ];
      } else {
         if ($args['post_id']) {
            $type = 'post';
         } elseif ($args['term_id']) {
            $type = 'term';
         }
         error_log('ID: '.print_r($ID, true));
         error_log('Type: '.print_r($type, true));
         $meta = new MetaManager($ID, $type);
         $images = implode(',', $images);
         error_log('Processed Image IDs: '.$images);
         $success = $meta->updateValue($field, $images);
         $return = [
            'success'   => $success,
            'result' => 'Field '.$field.' updated successfully'
         ];
      }
      return $return;
   }
   protected function createStandardResponse(bool $success, array $data = [], string $message = '', string $operation_id = ''): WP_REST_Response
   protected function sendResponse(bool $success, array $data = [], string $message = '', string $operation_id = ''): WP_REST_Response
   {
      $response = [
         'success' => $success,
@@ -844,353 +1121,72 @@
         $response['operation_id'] = $operation_id;
      }
      return new WP_REST_Response($response);
      return $this->success($response);
   }
   /**
    * Handle metadata update requests
    */
   public function handleMetadataUpdate(WP_REST_Request $request): WP_REST_Response
   {
      try {
         $data = $request->get_params();
         // Validate user permissions
         if (!$this->userCheck($data['user'])) {
            return $this->createStandardResponse(
               false,
               ['error_code' => 'invalid_user'],
               'Invalid user specified'
            );
         }
         if (!empty($data['attachment_ids'])) {
            // Phase 2B: Direct attachment update (images already processed)
            return $this->updateAttachmentMetadataDirect($data);
         }
         elseif (!empty($data['operation_id'])) {
            // Phase 2A: Queue metadata update with dependency on upload operation
            return $this->queueMetadataUpdate($data);
         }
         return $this->createStandardResponse(
            false,
            ['error_code' => 'missing_identifiers'],
            'Must provide either attachment_ids or operation_id'
         );
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:handleMetadataUpdate',
            $e->getMessage(),
            [
               'request_data' => $request->get_params(),
               'trace' => $e->getTraceAsString()
            ]
         );
         return $this->createStandardResponse(
            false,
            ['error_code' => 'metadata_update_failed'],
            'Metadata update failed: ' . $e->getMessage()
         );
      }
   }
   /**
    * Update metadata directly on completed attachments
    */
   protected function updateAttachmentMetadataDirect(array $data): WP_REST_Response
   {
      $updated_count = 0;
      $errors = [];
      foreach ($data['attachment_ids'] as $attachment_id) {
         try {
            // Verify attachment exists and user has permission
            if (!$this->canUserEditAttachment($data['user'], $attachment_id)) {
               $errors[] = "No permission to edit attachment {$attachment_id}";
               continue;
            }
            $this->applyMetadataToAttachment($attachment_id, $data['metadata']);
            $updated_count++;
         } catch (Exception $e) {
            $errors[] = "Failed to update attachment {$attachment_id}: " . $e->getMessage();
         }
      }
      return $this->createStandardResponse(
         $updated_count > 0,
         [
            'updated_count' => $updated_count,
            'errors' => $errors,
            'attachment_ids' => $data['attachment_ids']
         ],
         $updated_count > 0 ?
            "Updated metadata for {$updated_count} attachment(s)" :
            'No attachments were updated'
      );
   }
   /**
    * Queue metadata update with dependency on upload operation
    */
   protected function queueMetadataUpdate(array $data): WP_REST_Response
   {
      $queue = JVB()->queue();
      // Generate unique operation ID for this metadata update
      $metadata_operation_id = 'meta_' . uniqid();
      $queue->queueOperation(
         'update_metadata',
         $data['user'],
         [
            'original_operation_id' => $data['operation_id'],
            'upload_ids' => $data['upload_ids'] ?? [],
            'metadata' => $data['metadata']
         ],
         [
            'operation_id' => $metadata_operation_id,
            'depends_on' => $data['operation_id'], // Wait for upload completion
            'priority' => 'medium',
            'notification' => false // Don't spam notifications for metadata updates
         ]
      );
      return $this->createStandardResponse(
         true,
         [
            'metadata_operation_id' => $metadata_operation_id,
            'depends_on' => $data['operation_id'],
            'upload_ids' => $data['upload_ids'] ?? []
         ],
         'Metadata update queued - will apply after upload completes'
      );
   }
   /**
    * Process metadata update operation (called by queue processor)
    */
   public function processMetadataUpdate(WP_Error|array $result, object $operation, array $data): WP_Error|array
   {
      try {
         // Get the completed upload operation to map upload IDs to attachment IDs
         $uploadOperation = JVB()->queue()->getOperation($data['original_operation_id']);
         if (!$uploadOperation || $uploadOperation->status !== 'completed') {
            throw new Exception('Original upload operation not found or not completed');
         }
         $uploadResults = json_decode($uploadOperation->result, true);
         if (!$uploadResults || !$uploadResults['success']) {
            throw new Exception('Original upload operation failed');
         }
         // Build mapping from upload IDs to attachment IDs
         $uploadIdToAttachment = $this->buildUploadToAttachmentMapping(
            $data['upload_ids'],
            $uploadResults['data']
         );
         if (empty($uploadIdToAttachment)) {
            throw new Exception('No valid upload ID to attachment ID mappings found');
         }
         // Apply metadata to each mapped attachment
         $updated_count = 0;
         $errors = [];
         foreach ($uploadIdToAttachment as $uploadId => $attachmentId) {
            try {
               $this->applyMetadataToAttachment($attachmentId, $data['metadata']);
               $updated_count++;
            } catch (Exception $e) {
               $errors[] = "Upload {$uploadId} (Attachment {$attachmentId}): " . $e->getMessage();
            }
         }
         return [
            'success' => $updated_count > 0,
            'result' => [
               'updated_count' => $updated_count,
               'mappings' => $uploadIdToAttachment,
               'errors' => $errors,
               'message' => "Applied metadata to {$updated_count} attachment(s)"
            ]
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processMetadataUpdate',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'original_operation_id' => $data['original_operation_id'] ?? 'unknown'
            ]
         );
         return [
            'success'   => false,
            'result' => $e->getMessage()
         ];
      }
   }
   /**
    * Build mapping from frontend upload IDs to WordPress attachment IDs
    */
   protected function buildUploadToAttachmentMapping(array $uploadIds, array $uploadResults): array
   {
      $mapping = [];
      // Handle both flat array and grouped results
      if (isset($uploadResults[0]) && is_array($uploadResults[0]) && isset($uploadResults[0]['attachment_id'])) {
         // Flat array of results
         foreach ($uploadResults as $index => $result) {
            if (isset($uploadIds[$index]) && isset($result['attachment_id'])) {
               $mapping[$uploadIds[$index]] = $result['attachment_id'];
            }
         }
      } else {
         // Grouped results (selection mode)
         $resultIndex = 0;
         foreach ($uploadResults as $group) {
            if (is_array($group)) {
               foreach ($group as $result) {
                  if (isset($uploadIds[$resultIndex]) && isset($result['attachment_id'])) {
                     $mapping[$uploadIds[$resultIndex]] = $result['attachment_id'];
                  }
                  $resultIndex++;
               }
            }
         }
      }
      return $mapping;
   }
   /**
    * Apply metadata to a WordPress attachment
    */
   protected function applyMetadataToAttachment(int $attachment_id, array $metadata): void
   {
      $updates = [];
      // Handle title update
      if (isset($metadata['title']) && !empty($metadata['title'])) {
         $updates['post_title'] = sanitize_text_field($metadata['title']);
      }
      // Handle caption update
      if (isset($metadata['caption'])) {
         $updates['post_excerpt'] = sanitize_textarea_field($metadata['caption']);
      }
      // Update post if we have title or caption changes
      if (!empty($updates)) {
         $updates['ID'] = $attachment_id;
         $result = wp_update_post($updates);
         if (is_wp_error($result)) {
            throw new Exception('Failed to update attachment post: ' . $result->get_error_message());
         }
      }
      // Handle alt text update (stored as post meta)
      if (isset($metadata['alt'])) {
         $alt_result = update_post_meta(
            $attachment_id,
            '_wp_attachment_image_alt',
            sanitize_text_field($metadata['alt'])
         );
         if ($alt_result === false) {
            throw new Exception('Failed to update alt text meta');
         }
      }
      // Handle any custom metadata fields
      if (isset($metadata['custom']) && is_array($metadata['custom'])) {
         foreach ($metadata['custom'] as $key => $value) {
            $meta_key = 'jvb_' . sanitize_key($key);
            update_post_meta($attachment_id, $meta_key, sanitize_text_field($value));
         }
      }
      // Clear any attachment caches
      clean_attachment_cache($attachment_id);
   }
   /**
    * Check if user can edit attachment
    */
   protected function canUserEditAttachment(int $user_id, int $attachment_id): bool
   {
      // Basic permission check - attachment must exist
      $attachment = get_post($attachment_id);
      if (!$attachment || $attachment->post_type !== 'attachment') {
         return false;
      }
      // User must be the author or have edit capabilities
      if ($attachment->post_author == $user_id) {
         return true;
      }
      // Check if user has edit_posts capability
      $user = get_user_by('id', $user_id);
      return $user && user_can($user, 'skip_moderation');
   }
   public function handleGroupingRequest(WP_REST_Request $request): WP_REST_Response
   {
      error_log('Handling Group Request from UploadRoutes.php');
      try {
         $data = $request->get_params();
         error_log('Processing Data: '.print_r($data, true));
         $files = $request->get_file_params();
         $args = $this->buildUploadArgs($request);
         if (!array_key_exists($data['content'], JVB_CONTENT)) {
            return $this->createStandardResponse(
               false,
               ['error_code' => 'invalid_content'],
               'Invalid content type specified'
            );
         if (!array_key_exists('user', $args) || $args['user'] === 0){
            return $this->unauthorized();
         }
         if (!array_key_exists('content', $args) || empty($args['content'])) {
            return $this->validationError(['message'=>'Missing required content']);
         }
         if (!array_key_exists('posts', $args) || empty($args['posts'])) {
            return $this->validationError(['message' => 'Missing posts required']);
         }
         // Secure files to temporary storage
         $secured_files = $this->secureFiles($files, $args);
         //Get the operationIDs of the image uploads for the depends on
         $operationIDs = [];
         foreach ($data['posts'] as $index => $post) {
            foreach ($post['images'] as $img) {
               if (array_key_exists('operationId', $img)) {
                  $operationIDs[] = $img['operationId'];
               }
            }
         if (empty($secured_files['files'])) {
            return $this->error('No valid files to upload');
         }
         $operationIDs = array_unique($operationIDs);
         // Queue file upload operation
         $operation_type = $this->determineOperationType($secured_files['files'][0] ?? []);
         $chunkSize = 5;
         if ($operation_type === 'video') {
            $chunkSize = 1;
         } elseif ($operation_type === 'document') {
            $chunkSize = 10;
         }
         $queue = JVB()->queue();
         $queue->queueOperation(
            'process_upload_groups',
            $data['user'],
            $data,
         JVB()->queue()->queueOperation(
            $operation_type,
            $args['user'],
            array_merge(
               ['secured_files' => $secured_files['files']],
               $args
            ),
            [
               'operation_id' => $data['id'],
               'depends_on'   => $operationIDs
               'operation_id' => $args['upload'],
               'chunk_key' => 'secured_files',
               'chunk_size' => $chunkSize
            ]
         );
         return $this->createStandardResponse(
            true,
         $ID = JVB()->queue()->queueOperation(
            'process_upload_groups',
            $args['user'],
            $args,
            [
               'operation_id' => $data['id'],
               'count' => count($data['posts'] ?? [])
            ],
            'Operation queued successfully'
               'operation_id' => $args['id'],
               'depends_on' => [$args['upload']],
               'priority' => 'high',
               'chunk_key' => 'posts'
            ]
         );
         return $this->queued($ID['operation_id'], 'Files uploaded and posts queued for creation');
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:handleGroupingRequest',
@@ -1200,68 +1196,94 @@
               'trace' => $e->getTraceAsString()
            ]
         );
         return $this->createStandardResponse(
            false,
            ['error_code' => 'grouping_failed'],
            'Grouping operation failed: ' . $e->getMessage()
         );
         return $this->error('Grouping operation failed: '.$e->getMessage());
      }
   }
   protected function processUploadGroups(WP_Error|array $result, object $operation, array $data): WP_Error|array
   {
      try {
         error_log('Processing Upload Groups - Data: ' . print_r($data, true));
         $queue = JVB()->queue();
         $all_uploaded_images = [];
         $used_upload_ids = [];
         // Collect all uploaded images from dependencies
         foreach(json_decode($operation->dependencies, true) as $operationID) {
            $o = $queue->getOperation($operationID);
            $r = json_decode($o->result, true);
         // Get the upload operation ID from dependencies
         $dependencies = json_decode($operation->dependencies, true);
            error_log('Saved operation'.print_r($r, true));
            if ($r['success'] && isset($r['data'])) {
               foreach ($r['data'] as $img) {
                  $uploadKey = $img['upload_id'];
                  $all_uploaded_images[$uploadKey] = [
                     'upload_id' => $img['upload_id'],
                     'attachment_id' => (int)$img['attachment_id'],
                     'operation_id' => $operationID
                  ];
         if (empty($dependencies)) {
            return [
               'success'   => false,
               'result'    => 'No dependencies found'
            ];
         }
         // Collect uploads from all dependency operations
         $uploads = [];
         foreach ($dependencies as $dependency) {
            // Use getOperationValue like ContentRoutes does
            $res = $queue->getOperationValue($dependency, 'result');
            if (empty($res)) {
               continue;
            }
            // Results are stored at root level, keyed by upload_id
            // Filter to only include actual upload results (arrays with attachment_id)
            foreach ($res as $key => $value) {
               if (is_array($value) && isset($value['attachment_id'])) {
                  $uploads[$key] = $value;
               }
            }
         }
         if (empty($uploads)) {
            return [
               'success'   => false,
               'result'    => 'No uploads to process'
            ];
         }
         // Build map of upload_id => attachment_id
         foreach ($uploads as $upload_id => $img) {
            $all_uploaded_images[$upload_id] = [
               'upload_id' => $upload_id,
               'attachment_id' => (int)$img['attachment_id']
            ];
         }
         $content = jvbCheckBase($data['content']);
         if (Features::forContent($data['content'])->has('is_timeline')) {
            return $this->processTimelineUploads($data, $uploads, $all_uploaded_images, $operation);
         }
         $user = (int)$data['user'];
         $created_posts = [];
         $used_upload_ids = [];
         // Create posts from groups
         foreach ($data['posts'] as $index => $post) {
            $new_post_id = wp_insert_post([
            $post_title = !empty($post['fields']['post_title'])
               ? sanitize_text_field($post['fields']['post_title'])
               : 'New ' . JVB_CONTENT[$data['content']]['singular'] . ' ' . ($index + 1);
            $post_excerpt = !empty($post['fields']['post_excerpt'])
               ? sanitize_textarea_field($post['fields']['post_excerpt'])
               : '';
            $args = [
               'post_type' => $content,
               'post_author' => $user,
               'post_status' => 'draft',
               'post_title' => array_key_exists('post_title', $post['fields'])
                  ? sanitize_text_field($post['fields']['post_title'])
                  : 'New ' . JVB_CONTENT[$data['content']]['singular'],
               'post_excerpt' => array_key_exists('post_excerpt', $post['fields'])
                  ? sanitize_text_field($post['fields']['post_excerpt'])
                  : '',
            ]);
               'post_title' => $post_title,
               'post_excerpt' => $post_excerpt,
            ];
            $new_post_id = wp_insert_post($args);
            if ($new_post_id && !is_wp_error($new_post_id)) {
               $created_posts[] = $new_post_id;
               // Extract featured image
               $featured_upload_id = array_key_exists('featured', $post['fields'])
                  ? $post['fields']['featured']
                  : $post['images'][0]['upload_id'] ?? null;
               // Get featured image upload_id - string, not int!
               $featured_upload_id = $post['fields']['featured'] ?? null;
               $featured_attachment_id = null;
               $gallery_attachment_ids = [];
@@ -1278,24 +1300,25 @@
                     } else {
                        $gallery_attachment_ids[] = $attachment_id;
                     }
                  } else {
                     error_log("Warning: Could not find attachment for upload_id: {$upload_id}");
                  }
               }
               // Set featured image
               if ($featured_attachment_id) {
                  set_post_thumbnail($new_post_id, (int)$featured_attachment_id);
                  set_post_thumbnail($new_post_id, $featured_attachment_id);
               } elseif (!empty($gallery_attachment_ids)) {
                  set_post_thumbnail($new_post_id, $gallery_attachment_ids[0]);
                  array_shift($gallery_attachment_ids);
               }
               // Set gallery images
               if (!empty($gallery_attachment_ids)) {
                  $meta = new MetaManager($new_post_id, 'post');
                  $fields = jvbGetFields($data['content'], 'post');
                  $meta = Meta::forPost($new_post_id);
                  $fields = jvbGetFields($content, 'post');
                  foreach ($fields as $name => $config) {
                     if ($config['type'] === 'gallery') {
                        $meta->updateValue($name, implode(',', $gallery_attachment_ids));
                        $meta->set($name, implode(',', $gallery_attachment_ids));
                        break;
                     }
                  }
@@ -1303,18 +1326,12 @@
            }
         }
         // Cleanup unused uploaded images
         $unused_images = array_diff_key($all_uploaded_images, array_flip($used_upload_ids));
         $cleanup_result = $this->cleanupUnusedImages($unused_images);
         return [
            'success' => true,
            'result' => [
            'result' => [
               'created_posts' => $created_posts,
               'total_posts' => count($created_posts),
               'used_images' => count($used_upload_ids),
               'cleaned_up_images' => $cleanup_result['cleaned_count'],
               'cleanup_errors' => $cleanup_result['errors'],
               'message' => "Created " . count($created_posts) . " post(s) from uploads"
            ]
         ];
@@ -1325,8 +1342,106 @@
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'user_id' => $operation->user_id,
               'posts_count' => count($data['posts'] ?? [])
               'user_id' => $operation->user_id
            ]
         );
         return [
            'success' => false,
            'result' => $e->getMessage()
         ];
      }
   }
   protected function processTimelineUploads(array $data, array $uploads, array $uploadMap, object $operation):array
   {
      try {
         $user = (int)$data['user'];
         $created_posts = [];
         $used_upload_ids = [];
         $content = jvbCheckBase($data['content']);
         $config = Features::getConfig($content);
         $defaultTitle = 'New '.$config['singular']. ' ';
         foreach ($data['posts'] as $index=> $post) {
            $title = !empty($post['fields']['post_title'])
               ? sanitize_text_field($post['fields']['post_title'])
               : $defaultTitle.($index + 1);
            $excerpt = !empty($post['fields']['post_excerpt'])
               ? sanitize_textarea_field($post['fields']['post_excerpt'])
               : '';
            $args =[
               'post_type'    => $content,
               'post_author'  => $user,
               'post_status'  => 'draft',
               'post_title'   => $title,
               'post_excerpt' => $excerpt
            ];
            $parent = wp_insert_post($args);
            if ($parent && !is_wp_error($parent)) {
               //Get the attachment IDs first
               $childPosts = [];
               $featured = $post['fields']['featured']??null;
               $featuredID = null;
               foreach ($post['images'] as $key => $img) {
                  $upload_id = $img['upload_id'];
                  $used_upload_ids[] = $upload_id;
                  if (isset($uploadMap[$upload_id])) {
                     $attachment_id = (int)$uploadMap[$upload_id]['attachment_id'];
                     if ($upload_id === $featured) {
                        $featuredID = $attachment_id;
                     } else {
                        $childPosts[] = $attachment_id;
                     }
                  }
               }
               // Set the featured image for the parent
               if ($featuredID) {
                  set_post_thumbnail($parent, $featuredID);
               } elseif (!empty($childPosts)) {
                  //use first image if no set featured
                  set_post_thumbnail($parent, (int)$childPosts[0]);
                  array_shift($childPosts);
               }
               //Create Child Posts
               if (!empty($childPosts)) {
                  $args['post_parent'] = $parent;
                  $created_posts[$parent] = [];
                  foreach ($childPosts as $i => $imgID) {
                     $treatment = $i + 1;
                     $childTitle = $title.' - Treatment '.$treatment;
                     $childDesc = '';
                     $args['post_title'] = $childTitle;
                     $args['post_excerpt'] = $childDesc;
                     $child = wp_insert_post($args);
                     if ($child && !is_wp_error($child)) {
                        $created_posts[$parent][] = $child;
                        set_post_thumbnail($child, $imgID);
                     }
                  }
               }
            }
         }
         return [
            'success'   => true,
            'result' => [
               'created_posts'   => $created_posts,
               'used_images'  => $used_upload_ids
            ]
         ];
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadRoutes]:processTimelineUploads',
            $e->getMessage(),
            [
               'operation_id' => $operation->id,
               'user_id' => $operation->user_id
            ]
         );
@@ -1353,7 +1468,6 @@
               if ($deleted) {
                  $cleaned_count++;
                  error_log("Cleaned up unused image: attachment_id {$attachment_id}, upload_id {$upload_id}");
               } else {
                  $errors[] = "Failed to delete attachment {$attachment_id} for upload {$upload_id}";
               }
@@ -1375,9 +1489,6 @@
   function getAttachmentID(array $image, array $storedResults): int|false
   {
      error_log('Looking for upload_id: ' . ($image['upload_id'] ?? 'NOT SET'));
      error_log('Available results: ' . print_r(array_keys($storedResults), true));
      foreach ($storedResults as $operationID => $uploads) {
         foreach ($uploads as $upload) {
            // FIX: Handle both case variations
@@ -1385,13 +1496,10 @@
            $search_upload_id = $image['upload_id'];
            if ($stored_upload_id === $search_upload_id) {
               error_log("Found match! upload_id: {$search_upload_id} -> attachment_id: {$upload['attachment_id']}");
               return (int)$upload['attachment_id'];
            }
         }
      }
      error_log("No match found for upload_id: " . ($image['upload_id'] ?? 'MISSING'));
      return false;
   }
@@ -1523,4 +1631,255 @@
      return $title;
   }
   /**
    * Determine how to save uploaded files based on configuration
    */
   protected function handleUploadDestination(array $data, array $results): void
   {
      // Determine destination from config
      $destination = $data['destination'] ?? 'meta';
      switch ($destination) {
         case 'meta':
            // Save to post/term/user meta
            $this->saveToMeta($data, $results);
            break;
         case 'post':
            // Create individual posts for each file
            $this->createIndividualPosts($data, $results);
            break;
         case 'post_group':
            // Create posts with grouped files
            $this->createGroupedPosts($data, $results);
            break;
         default:
            // No destination specified - files processed but not attached
            break;
      }
   }
   /**
    * Infer destination from existing data (backward compatibility)
    */
   protected function inferDestination(array $data): string
   {
      // If field_name exists → saving to meta
      if (!empty($data['field_name'])) {
         return 'meta';
      }
      // If post_type exists without field_name → creating posts
      if (!empty($data['content'])) {
         return 'post';
      }
      // No destination
      return 'none';
   }
   private function getMetaManager(array $data): ?Meta
   {
      if (!empty($data['post_id'])) {
         return Meta::forPost($data['post_id']);
      }
      if (!empty($data['term_id'])) {
         return Meta::forTerm($data['term_id']);
      }
      if (!empty($data['user'])) {
         $link = (int)get_user_meta($data['user'], BASE . 'link', true);
         if ($link) {
            return Meta::forPost($link);
         }
      }
      return null;
   }
   /**
    * Save attachment IDs to meta field
    */
   private function saveToMeta(array $data, array $results): void
   {
      if (empty($data['field_name'])) {
         return;
      }
      $attachmentIds = array_column($results, 'attachment_id');
      $meta = $this->getMetaManager($data);
      if (!$meta) {
         return;
      }
      $fieldType = $data['field_type'] ?? 'single';
      if ($fieldType === 'single') {
         // Single field: replace with latest upload
         $meta->set($data['field_name'], end($attachmentIds));
      } else {
         // Multi field: merge with existing
         $existing = $meta->get($data['field_name']);
         $existingIds = !empty($existing) ? explode(',', $existing) : [];
         $allIds = array_unique(array_merge($existingIds, $attachmentIds));
         $meta->set($data['field_name'], implode(',', $allIds));
      }
   }
   /**
    * Create individual posts from uploads
    */
   protected function createIndividualPosts(array $data, array $results): array
   {
      if (empty($data['content'])) {
         return [];
      }
      $created_posts = [];
      foreach ($results as $result) {
         $attachment_id = $result['attachment_id'];
         $attachment = get_post($attachment_id);
         // Create post
         $post_data = [
            'post_type' => jvbCheckBase($data['content']),
            'post_title' => $attachment->post_title,
            'post_status' => 'draft',
            'post_author' => $data['user'] ?? get_current_user_id(),
         ];
         $post_id = wp_insert_post($post_data);
         if (!is_wp_error($post_id)) {
            // Set as featured image or attach to gallery
            $this->attachFileToPost($post_id, $attachment_id, $data);
            $created_posts[] = [
               'post_id' => $post_id,
               'attachment_id' => $attachment_id,
            ];
         }
      }
      return $created_posts;
   }
   /**
    * Create posts with grouped uploads
    */
   protected function createGroupedPosts(array $data, array $results): array
   {
      if (empty($data['content'])) {
         return [];
      }
      $id_map = [];
      foreach ($results as $result) {
         if (isset($result['upload_id'], $result['attachment_id'])) {
            $id_map[$result['upload_id']] = $result['attachment_id'];
         }
      }
      // Groups come from frontend as metadata
      $groups = $data['groups'] ?? [array_column($results, 'attachment_id')];
      $created_posts = [];
      foreach ($groups as $group_index => $group_upload_ids) {
         $group_attachment_ids = array_filter(
            array_map(fn($uid) => $id_map[$uid] ?? null, $group_upload_ids)
         );
         if (empty($group_attachment_ids)) continue;
         // Create post for this group
         $first_attachment = get_post($group_attachment_ids[0]);
         $post_data = [
            'post_type' => jvbCheckBase($data['content']),
            'post_title' => $data['group_titles'][$group_index] ?? $first_attachment->post_title,
            'post_status' => $data['post_status'] ?? 'draft',
            'post_author' => $data['user'] ?? get_current_user_id(),
         ];
         $post_id = wp_insert_post($post_data);
         if (!is_wp_error($post_id)) {
            // Attach all files in group
            foreach ($group_attachment_ids as $index => $attachment_id) {
               if ($index === 0) {
                  // First is featured
                  set_post_thumbnail($post_id, $attachment_id);
               } else {
                  // Others go to gallery
                  $meta = Meta::forPost($post_id);
                  $existing = $meta->get('gallery');
                  $existing_ids = !empty($existing) ? explode(',', $existing) : [];
                  $existing_ids[] = $attachment_id;
                  $meta->set('gallery', implode(',', $existing_ids));
               }
            }
            $created_posts[] = [
               'post_id' => $post_id,
               'attachment_ids' => $group_attachment_ids,
            ];
         }
      }
      return $created_posts;
   }
   /**
    * Attach file to post based on file type
    */
   protected function attachFileToPost(int $post_id, int $attachment_id, array $data): void
   {
      $attachment = get_post($attachment_id);
      $mime_type = $attachment->post_mime_type;
      // Determine file type
      if (str_starts_with($mime_type, 'image/')) {
         // Set as featured image
         set_post_thumbnail($post_id, $attachment_id);
      } elseif (str_starts_with($mime_type, 'video/')) {
         // Save to video field
         $meta = Meta::forPost($post_id);
         $meta->set('video', $attachment_id);
      } else {
         // Documents - save to documents field
         $meta = Meta::forPost($post_id);
         $existing = $meta->get('documents');
         $existing_ids = !empty($existing) ? explode(',', $existing) : [];
         $existing_ids[] = $attachment_id;
         $meta->set('documents', implode(',', $existing_ids));
      }
   }
}