Jake Vanderwerf
2026-08-25 3d25ad7de4fdbb3eba2cde2a62422e6be5d79a0a
inc/managers/UploadManager.php
@@ -1,9 +1,14 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\base\Site;
use JVBase\Instance;
use Exception;
use Imagick;
use JVBase\managers\queue\executors\UploadExecutor;
use JVBase\managers\queue\mergers\UploadMerger;
use JVBase\managers\queue\Queue;
use JVBase\managers\queue\TypeConfig;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -11,11 +16,32 @@
}
/**
 * Flexible file upload manager for images, videos, and documents
 * Supports configurable directory structures and processing options
 * The goal here was mainly for membership sites, to organize files by userID, rather than the date structure.
 * This way, if a user deletes their account, or performs a data request, we can simply copy their directory.
 *
 * I started with user name so we could search by name, but issues arose if a user changed their name/display name. So I set it to go to user_id.
 *
 * The general flow is:
 *       -> Upload, ensure files are stored in appropriate directory, usually {root}/tmp
 *       -> Conversion is queued with Queue, as with the /tmp cleanup
 *
 * Proposed directory structure:
 *       Site::has('membership') || Site::has('is_directory')
 *          -> defaults to {user_id}/{post_type}/file
 *       Default:
 *          -> defaults to {post_type}/file
 *             Drawback: if a post is uploaded to one post type, then used in another, the organization is slightly flawed.
 *
 * Also, we wanted to ensure images were converted to webp, for speed.
 *    -> The front-end javascript attempts to do this in browser
 *    -> If this couldn't be handled, it is queued for server processing
 * Of course, some integrations (even google, sillies) only accept jpeg/png, so in some cases we need a jpeg fallback
 *
 *
 */
class UploadManager
{
   use Instance;
   protected array $allowedTypes = [
      // Images
      'image/jpeg'    => 'image',
@@ -73,16 +99,179 @@
      'original_retention' => 2592000, // 30 days
   ];
   /******************************************************************
    * UPLOAD SETTINGS
   ******************************************************************/
   /**
    * @var array|int[] Max size of file types
    */
   protected array $maxSize = [
      'image'     => 5242880,    // 5MB
      'video'  => 104857600,  // 100MB
      'document'  => 15728640 // 15MB
   ];
   //Image Specific
   /**
    * @var bool Whether to convert the image
    */
   protected bool $convertImage = true;
   /**
    * @var string Default image conversion
    */
   protected string $convertImageTo = 'webp';
   /**
    * @var int Default image quality
    */
   protected int $convertQuality = 80;
   /**
    * @var bool Whether to optimize images
    */
   protected bool $optimizeImages = true;
   /**
    * @var bool Whether to create image thumbnails
    */
   protected bool $createThumbnails = true;
   //Video specific
   /**
    * @var bool Whether to automatically extract a thumbnail from a video
    */
   protected bool $extractVideoThumbnail = true;
   /**
    * @var int Time, in microseconds, to grab thumbnail from
    */
   protected int $videoThumbnailPosition = 0;
   //Document specific
   /**
    * @var bool Whether to extract thumbnail from document.
    */
   protected bool $extractDocumentThumbnail = false;
   /**
    * @var int How long to keep originals (default 3 months)
    */
   protected int $keep_originals;
   /******************************************************************
    * UPLOAD DIRECTORIES
   ******************************************************************/
   /**
    * @var string WordPress' base upload directory
    */
   protected string $upload_dir;
   /**
    * @var string WordPress' base upload directory url
    */
   protected string $upload_url;
   public function __construct()
   protected string $dir_pattern;
   /*****************************************************************
    * FEATURE FLAG
   *****************************************************************/
   protected bool $has_imagick = false;
   private function __construct()
   {
      $upload = wp_upload_dir();
      $this->upload_dir = $upload['basedir'];
      $this->upload_url = $upload['baseurl'];
      // Check for Imagick
      $this->defaultConfig['use_imagick'] = extension_loaded('imagick');
      if (Site::has('membership') || Site::has('is_directory')) {
         $this->dir_pattern = '{user_id}/{post_type}';
      } else {
         $this->dir_pattern = '{post_type}';
      }
      $this->has_imagick = extension_loaded('imagick');
      $this->keep_originals = MONTH_IN_SECONDS * 3;
      add_action('init', [$this, 'registerUploadExecutors'], 5);
   }
   /**
    * Register upload operation types with the queue's TypeRegistry
    */
   public function registerUploadExecutors(): void
   {
      $registry = Queue::getInstance()->registry();
      $executor = new UploadExecutor();
      $merger = new UploadMerger('secured_files');
      // Image uploads - chunked at 5 files
      $registry->register('image_upload', new TypeConfig(
         mergeable: $merger,
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 3
      ));
      // Video uploads - one at a time (heavy processing)
      $registry->register('video_upload', new TypeConfig(
         mergeable: $merger,
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 1
      ));
      // Document uploads - chunked at 10
      $registry->register('document_upload', new TypeConfig(
         mergeable: $merger,
         executor: $executor,
         chunkKey: 'secured_files',
         chunkSize: 5
      ));
      // 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
      ));
   }
   /**
    * @param string|array $file_path
    * @param array $context
    * @return ?string
    * @throws Exception
    */
   protected function testFile(string|array $file_path, array $context):?string
   {
      $file_type = null;
      if (is_array($file_path)) {
         if (!isset($file_path['tmp_name']) || $file_path['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('Invalid file upload');
         }
         $file_path = $file_path['tmp_name'];
      }
      // Validate file upload
      if (!isset($file_path)) {
         throw new Exception('Invalid file upload');
      }
      $mime_type = mime_content_type($file_path);
      $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
      if ($file_type === 'unknown') {
         throw new Exception('Unsupported file type: ' . $mime_type);
      }
      // Check allowed types if specified
      if (array_key_exists('allowed_types', $context) && !in_array($mime_type, $context['allowed_types'])) {
         throw new Exception('File type not allowed: ' . $mime_type);
      }
      return $file_type;
   }
   /**
@@ -94,26 +283,10 @@
   public function secureUploadedFile(array $file_data, array $context): array
   {
      try {
         // Validate file upload
         if (!isset($file_data['tmp_name']) || $file_data['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('Invalid file upload');
         }
         $file_type = $this->testFile($file_data, $context);
         $mime_type = mime_content_type($file_data['tmp_name']);
         $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
         if ($file_type === 'unknown') {
            throw new Exception('Unsupported file type: ' . $mime_type);
         }
         // Check allowed types if specified
         if ($this->defaultConfig['allowed_types'] && !in_array($mime_type, $this->defaultConfig['allowed_types'])) {
            throw new Exception('File type not allowed: ' . $mime_type);
         }
         $temp_dir = $this->generateDirectory($file_type, "{user_id}/temp", $context);
         // Ensure directories exist
         $this->ensureDirectories($temp_dir);
         $context['pattern'] = $this->dir_pattern.'/tmp';
         $temp_dir = $this->generateDirectory($file_type, $context);
         // Generate a unique filename for temporary storage
         $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
@@ -137,7 +310,7 @@
            'stored_at' => current_time('mysql')
         ];
      } catch (Exception $e) {
         JVB()->error()->log('Failed to store uploaded file: ' . print_r($e->getMessage(), true));
         Errors::getInstance()->log('UploadManager','Failed to store uploaded file: ' . print_r($e->getMessage(), true));
         return [];
      }
   }
@@ -152,60 +325,32 @@
   public function processUpload(string $temp_path, array $context): array|WP_Error
   {
      try {
         // Validate temp file exists
         if (!file_exists($temp_path)) {
            throw new Exception('Temporary file not found: ' . $temp_path);
         }
         // Validate MIME type
         $mime_type = mime_content_type($temp_path);
         if (!$this->validateMimeType($temp_path, $mime_type)) {
            throw new Exception('Invalid or potentially dangerous file type');
         }
         $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
         if ($file_type === 'unknown') {
            throw new Exception('Unsupported file type: ' . $mime_type);
         }
         // Merge config
         $config = array_merge($this->defaultConfig, $context['config'] ?? [], $context);
         $file_type = $this->testFile($temp_path, $context);
         // Extract context
         $user_id = $context['user_id'] ?? get_current_user_id();
         $post_id = $context['post_id'] ?? 0;
         // Check allowed types if specified
         if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) {
            throw new Exception('File type not allowed: ' . $mime_type);
         }
         // Validate file size
         $this->validateFileSize($temp_path, $file_type, $config);
         $this->validateFileSize($temp_path, $file_type, $context);
         // Generate directory structure for final storage
         $directory = $this->generateDirectory($file_type, $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}', $context);
         // Ensure directories exist
         $this->ensureDirectories($directory);
         $directory = $this->generateDirectory($file_type, $context);
         // Generate filename
         $original_name = $context['original_name'] ?? basename($temp_path);
         $filename = $this->generateFilename($original_name, $context);
         // Store original from temp
         $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename);
         // Process based on file type
         return match($file_type) {
            'image' => $this->processImage($original_path, $directory, $filename, $post_id, $config, $context),
            'video' => $this->processVideo($original_path, $directory, $filename, $post_id, $config, $context),
            'document' => $this->processDocument($original_path, $directory, $filename, $post_id, $config, $context),
            'image' => $this->processImage($temp_path, $directory, $filename, $post_id, $context),
            'video' => $this->processVideo($temp_path, $directory, $filename, $post_id, $context),
            'document' => $this->processDocument($temp_path, $directory, $filename, $post_id, $context),
            default => throw new Exception('Unknown file type')
         };
      } catch (Exception $e) {
         JVB()->error()->log(
         Errors::getInstance()->log(
            '[UploadManager]:processUpload',
            $e->getMessage(),
            ['temp_path' => $temp_path, 'context' => $context]
@@ -215,21 +360,6 @@
      }
   }
   /**
    * Store original file from temp location (not from PHP upload)
    */
   protected function storeOriginalFromTemp(string $temp_path, string $directory, string $filename): string
   {
      $ext = pathinfo($temp_path, PATHINFO_EXTENSION);
      $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}";
      // Copy from temp to final location
      if (!copy($temp_path, $original_path)) {
         throw new Exception('Failed to store original file');
      }
      return $original_path;
   }
   /**
    * Clean up empty temporary directories
@@ -262,24 +392,52 @@
         @rmdir($temp_base);
      }
   }
   /**
    * Generate directory structure based on pattern
    * @param string $file_type
    * @param array $config
    * @return string
    * @throws Exception
    */
   protected function generateDirectory(string $file_type, string $pattern, array $context): string
   public function generateDirectory(string $file_type, array $config): string
   {
      $replacements = [
         '{user_id}' => $context['user_id'] ?? get_current_user_id(),
         '{post_type}' => $context['content'] ?? '',
         '{content}' => $context['content'] ?? '',
      $pattern = array_key_exists('pattern', $config) && is_string($config['pattern']) ? $config['pattern'] : $this->dir_pattern;
      $pattern = explode('/', $pattern);
      $parts = [];
      foreach ($pattern as $part) {
         $newPart = match($part) {
            '{user_id}'             => $context['user_id'] ?? null,
            '{post_type}', '{content}' => $context['content'] ?? null,
            '{tax}', '{taxonomy}'      => $context['taxonomy'] ?? null,
            '{term}'             => $context['term'] ?? null,
         '{file_type}' => $file_type,
         '{year}' => date('Y'),
         '{month}' => date('m'),
      ];
            '{day}'                 => date('d'),
            default              => $part
         };
         if (is_null($newPart)) {
            error_log('[Uploader] Pattern key was not provided for '.$part);
         }
         if (str_contains($newPart, '{')) {
            error_log('[Uploader] Unhandled pattern found: '. $newPart);
         }
         $newPart = $this->sanitizeFileName($newPart);
         $parts[] = $newPart;
      }
      //Remove any null values
      $parts = array_filter($parts);
      $directory = str_replace(array_keys($replacements), array_values($replacements), $pattern);
      $directory = implode('/', $parts);
      // Allow filtering
      return apply_filters('jvb_upload_directory', $directory, $file_type, $context);
      $directory = apply_filters('jvb_upload_directory', $directory, $file_type, $config);
      // Ensure directories exist
      $this->ensureDirectories($directory);
      return $directory;
   }
   /**
@@ -288,8 +446,7 @@
   protected function ensureDirectories(string $rel_path): void
   {
      $dirs = [
         "{$this->upload_dir}/{$rel_path}",
         "{$this->upload_dir}/{$rel_path}/originals"
         "{$this->upload_dir}/{$rel_path}"
      ];
      foreach ($dirs as $dir) {
@@ -299,28 +456,26 @@
      }
   }
   /**
    * Store original file
    */
   protected function storeOriginal(string $tmp_file, string $directory, string $filename): string
   {
      $ext = pathinfo($tmp_file, PATHINFO_EXTENSION);
      $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}";
      if (!move_uploaded_file($tmp_file, $original_path)) {
         throw new Exception('Failed to store original file');
      }
      return $original_path;
   }
   /**
    * Validate file size based on type and config
    * @param string $file_path
    * @param string $file_type
    * @param array $config
    * @return void
    * @throws Exception
    */
   protected function validateFileSize(string $file_path, string $file_type, array $config): void
   {
      $size = filesize($file_path);
      $max_size = $config['max_size'][$file_type] ?? $this->defaultConfig['max_size'][$file_type];
      $max_size = $this->maxSize[$file_type];
      if (array_key_exists('max_size', $config)) {
         if (is_int($config['max_size'])) {
            $max_size = $config['max_size'];
         } else if (is_array($config['max_size']) && array_key_exists($file_type, $config['max_size'])) {
            $max_size = $config['max_size'][$file_type];
         }
      }
      if ($size > $max_size) {
         $max_mb = round($max_size / 1048576, 2);
@@ -354,7 +509,7 @@
    * Process image file
    * @throws Exception
    */
   protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
   protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $context): array
   {
      $full_dir = "{$this->upload_dir}/{$directory}";
      $original_mime = mime_content_type($original_path);
@@ -363,33 +518,37 @@
      $should_convert = false;
      $target_format = false;
      if ($config['convert'] && $config['convert'] !== false) {
         $target_format = strtolower($config['convert']);
      if ((array_key_exists('convert', $context) && $context['convert'] !== false) || $this->convertImage) {
         $target_format = empty($context['convertTo']??'') ? $this->convertImage : $context['convertTo'];
         if (!empty($target_format)) {
         // Validate format
         if (!isset($this->supportedFormats[$target_format])) {
            throw new Exception("Unsupported conversion format: {$target_format}");
         }
               error_log('[Uploader] Unsupported conversion format: '.$target_format.'... will not convert');
            } else {
         // Check if conversion is needed
         $target_mime = $this->supportedFormats[$target_format];
         $should_convert = ($original_mime !== $target_mime);
      }
         }
      }
      // Convert or copy based on configuration
      if ($should_convert) {
         $final_path = "{$full_dir}/{$filename}.{$target_format}";
         try {
            $this->convertImage(
               $original_path,
               $final_path,
               $target_format,
               $config['quality'],
               $config['use_imagick']
               array_key_exists('quality', $context) && is_int($context['quality']) && $context['quality'] <= 100 ?
                  $context['quality'] :
                  $this->convertQuality,
            );
         } catch (Exception $e) {
            JVB()->error()->log('Image conversion failed: ' . print_r($e->getMessage(), true));
            Errors::getInstance()->log('Uploader', 'Image conversion failed: ' . print_r($e->getMessage(), true));
            // Fallback to original
            $ext = pathinfo($original_path, PATHINFO_EXTENSION);
            $final_path = "{$full_dir}/{$filename}.{$ext}";
@@ -412,7 +571,8 @@
      }
      // Generate thumbnails
      if ($config['create_thumbnails']) {
      $hasOption = array_key_exists('create_thumbnails', $context);
      if (($hasOption && $context['create_thumbnails']) || (!$hasOption && $this->createThumbnails)) {
         $this->generateThumbnails($attachment_id);
      }
@@ -422,7 +582,8 @@
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path,
         'type' => 'image',
         'format' => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION)
         'format'    => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION),
         'context'   => $context
      ];
   }
@@ -430,7 +591,7 @@
    * Process video file
    * @throws Exception
    */
   protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
   protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $context): array
   {
      $full_dir = "{$this->upload_dir}/{$directory}";
      $ext = pathinfo($original_path, PATHINFO_EXTENSION);
@@ -443,14 +604,16 @@
      try {
         $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      } catch (Exception $e) {
         JVB()->error()->log('Attachment creation failed: '.print_r($e->getMessage(), true));
         Errors::getInstance()->log('Uploader', 'Attachment creation failed: '.print_r($e->getMessage(), true));
         return ['success' => false];
      }
      // Extract thumbnail if enabled
      if ($config['extract_video_thumbnail']) {
         $this->extractVideoThumbnail($attachment_id, $final_path, $config['video_thumbnail_time']);
      $hasOption = array_key_exists('extract_thumbnail', $context);
      if (($hasOption && $context['extract_thumbnail']) || (!$hasOption && $this->extractVideoThumbnail)) {
         $time = $context['thumbnail_time']??$this->videoThumbnailPosition;
         $this->extractVideoThumbnail($attachment_id, $final_path, $time);
      }
      return [
@@ -458,14 +621,15 @@
         'attachment_id' => $attachment_id,
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path,
         'type' => 'video'
         'type'      => 'video',
         'context'   => $context
      ];
   }
   /**
    * Process document file
    */
   protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
   protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, array $context): array
   {
      $full_dir = "{$this->upload_dir}/{$directory}";
      $ext = pathinfo($original_path, PATHINFO_EXTENSION);
@@ -478,36 +642,36 @@
      try {
         $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      } catch (Exception $e) {
         JVB()->error()->log('Conversion failed: '.print_r($e->getMessage(), true));
         Errors::getInstance()->log('Uploader','Conversion failed: '.print_r($e->getMessage(), true));
         return ['success' => false];
      }
      return [
         'success' => true,
         'attachment_id' => $attachment_id,
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path,
         'type' => 'document'
         'type'      => 'document',
         'context'   => $context
      ];
   }
   /**
    * Generate SEO-friendly filename
    */
   protected function generateFilename(string $original_name, array $context): string
   public function generateFilename(string $original_name, array $context): string
   {
      $name_parts = pathinfo($original_name);
      $base_name = sanitize_title($name_parts['filename']);
      $base_name = $this->sanitizeFileName($name_parts['filename']);
      $user_data = array_key_exists('user_id', $context) ? get_userdata((int)$context['user_id']) : get_current_user();
      $username = $user_data ? sanitize_title($user_data->display_name) : 'user';
      $timestamp = time();
      return apply_filters(
      return $this->sanitizeFileName(apply_filters(
         'jvb_upload_filename',
         "{$base_name}-{$timestamp}",
         $context
      );
      ));
   }
   protected function sanitizeFileName(string $filename): string
@@ -570,7 +734,7 @@
    * @param bool $replace Replace original file (default: true)
    * @return array|WP_Error Result with new attachment details
    */
   public function convertImageTo(int $attachment_id, string $format, int $quality = 80, bool $replace = true): array|WP_Error
   public function convertImageTo(int $attachment_id, string $format, int $quality = 80, bool $replace = false): array|WP_Error
   {
      try {
         // Validate format
@@ -608,8 +772,7 @@
         $new_path = $path_info['dirname'] . '/' . $new_filename;
         // Perform conversion
         $use_imagick = $this->defaultConfig['use_imagick'];
         $this->convertImage($original_path, $new_path, $format, $quality, $use_imagick);
         $this->convertImage($original_path, $new_path, $format, $quality);
         if ($replace) {
            // Replace the original file
@@ -645,16 +808,31 @@
   /**
    * Generic image conversion method
    */
   protected function convertImage(string $source, string $destination, string $format, int $quality, bool $use_imagick): void
   /**
    * @param string $source
    * @param string $destination
    * @param string $format
    * @param int $quality
    * @return void
    * @throws Exception
    */
   protected function convertImage(string $source, string $destination, string $format, int $quality): void
   {
      if ($use_imagick) {
      if ($this->has_imagick) {
         $this->convertWithImagick($source, $destination, $format, $quality);
      } else {
         $this->convertWithGd($source, $destination, $format, $quality);
      }
   }
   /**
    * Convert image with Imagick (supports multiple formats)
    * Convert image with Imagick
    * @param string $source
    * @param string $destination
    * @param string $format
    * @param int $quality
    * @return void
    * @throws Exception
    */
   protected function convertWithImagick(string $source, string $destination, string $format, int $quality): void
   {
@@ -678,14 +856,19 @@
         $image->writeImage($destination);
         $image->clear();
         $image->destroy();
      } catch (Exception $e) {
         throw new Exception("Imagick conversion to {$format} failed: " . $e->getMessage());
      }
   }
   /**
    * Convert image with GD (supports multiple formats)
    * Convert image with GD
    * @param string $source
    * @param string $destination
    * @param string $format
    * @param int $quality
    * @return void
    * @throws Exception
    */
   protected function convertWithGd(string $source, string $destination, string $format, int $quality): void
   {
@@ -750,160 +933,4 @@
      // Placeholder for future implementation
      apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time);
   }
   /**
    * Phase 1: Validate and process file on disk (no DB writes)
    * Safe to run outside transactions — only does file I/O
    *
    * @return array Prepared file info for registerFile()
    */
   public function prepareFile(string $temp_path, array $context): array
   {
      if (!file_exists($temp_path)) {
         throw new Exception('Temporary file not found: ' . $temp_path);
      }
      $mime_type = mime_content_type($temp_path);
      if (!$this->validateMimeType($temp_path, $mime_type)) {
         throw new Exception('Invalid or potentially dangerous file type');
      }
      $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
      if ($file_type === 'unknown') {
         throw new Exception('Unsupported file type: ' . $mime_type);
      }
      $config  = array_merge($this->defaultConfig, $context['config'] ?? [], $context);
      $post_id = $context['post_id'] ?? 0;
      if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) {
         throw new Exception('File type not allowed: ' . $mime_type);
      }
      $this->validateFileSize($temp_path, $file_type, $config);
      $directory = $this->generateDirectory(
         $file_type,
         $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}',
         $context
      );
      $this->ensureDirectories($directory);
      $original_name = $context['original_name'] ?? basename($temp_path);
      $filename      = $this->generateFilename($original_name, $context);
      $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename);
      // File-type-specific I/O (conversion, copying) — no DB
      $final_path = match ($file_type) {
         'image'    => $this->prepareImage($original_path, $directory, $filename, $config),
         'video'    => $this->prepareMedia($original_path, $directory, $filename),
         'document' => $this->prepareMedia($original_path, $directory, $filename),
         default    => throw new Exception('Unknown file type'),
      };
      return [
         'final_path' => $final_path,
         'file_type'  => $file_type,
         'filename'   => $filename,
         'post_id'    => $post_id,
         'config'     => $config,
         'context'    => $context,
      ];
   }
   /**
    * Phase 2: Register processed file in WordPress (DB writes only)
    * Quick operation — individual WP functions handle their own queries
    *
    * @param array $prepared Output from prepareFile()
    * @return array Standard upload result
    */
   public function registerFile(array $prepared): array
   {
      $attachment_id = $this->createAttachment(
         $prepared['final_path'],
         $prepared['filename'],
         $prepared['context'],
         $prepared['post_id']
      );
      // Type-specific post-registration
      if ($prepared['file_type'] === 'image') {
         $alt_text = apply_filters('jvb_upload_alt_text', '', $prepared['context']);
         if ($alt_text !== '') {
            update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
         }
      }
      if ($prepared['file_type'] === 'video' && ($prepared['config']['extract_video_thumbnail'] ?? false)) {
         $this->extractVideoThumbnail(
            $attachment_id,
            $prepared['final_path'],
            $prepared['config']['video_thumbnail_time'] ?? 0
         );
      }
      return [
         'success'       => true,
         'attachment_id' => $attachment_id,
         'url'           => wp_get_attachment_url($attachment_id),
         'file'          => $prepared['final_path'],
         'type'          => $prepared['file_type'],
      ];
   }
   /**
    * Image file I/O: convert or copy to final location
    */
   protected function prepareImage(string $original_path, string $directory, string $filename, array $config): string
   {
      $full_dir      = "{$this->upload_dir}/{$directory}";
      $original_mime = mime_content_type($original_path);
      $should_convert = false;
      $target_format  = false;
      if ($config['convert'] && $config['convert'] !== false) {
         $target_format = strtolower($config['convert']);
         if (!isset($this->supportedFormats[$target_format])) {
            throw new Exception("Unsupported conversion format: {$target_format}");
         }
         $should_convert = ($original_mime !== $this->supportedFormats[$target_format]);
      }
      if ($should_convert) {
         $final_path = "{$full_dir}/{$filename}.{$target_format}";
         try {
            $this->convertImage($original_path, $final_path, $target_format, $config['quality'], $config['use_imagick']);
         } catch (Exception $e) {
            JVB()->error()->log('Image conversion failed: ' . $e->getMessage());
            $ext = pathinfo($original_path, PATHINFO_EXTENSION);
            $final_path = "{$full_dir}/{$filename}.{$ext}";
            copy($original_path, $final_path);
         }
      } else {
         $ext = pathinfo($original_path, PATHINFO_EXTENSION);
         $final_path = "{$full_dir}/{$filename}.{$ext}";
         copy($original_path, $final_path);
      }
      return $final_path;
   }
   /**
    * Video/document file I/O: copy to final location
    */
   protected function prepareMedia(string $original_path, string $directory, string $filename): string
   {
      $full_dir   = "{$this->upload_dir}/{$directory}";
      $ext        = pathinfo($original_path, PATHINFO_EXTENSION);
      $final_path = "{$full_dir}/{$filename}.{$ext}";
      copy($original_path, $final_path);
      return $final_path;
   }
}