Jake Vanderwerf
2026-09-04 32ce5c542c90be348895328078e392dde2052957
inc/managers/UploadManager.php
@@ -1,338 +1,452 @@
<?php
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\meta\MetaManager;
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')) {
    exit; // Exit if accessed directly
   exit;
}
/**
 * Handles file uploads for edmonton.ink dashboard
 * Includes image processing, validation, optimization, and SEO-friendly naming
 * 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
{
    /**
     * @var array Default configuration
     */
   protected array $config = [
      'allowed_types' => [
         'image/jpeg',
         'image/png',
         'image/gif',
         'image/webp',
      ],
      'max_size' => 5242880, // 5MB
      'convert_to_webp' => true,
      'webp_quality' => 80,
      'optimize_images' => true,
      'create_thumbnails' => true,
      'original_retention' => 2592000, // 30 days in seconds
      'use_imagick' => null // Will be set in constructor
   use Instance;
   protected array $allowedTypes = [
      // Images
      'image/jpeg'    => 'image',
      'image/png'     => 'image',
      'image/gif'     => 'image',
      'image/webp'    => 'image',
      // Videos
      'video/mp4'     => 'video',
      'video/webm'    => 'video',
      'video/ogg'     => 'video',
      'video/ogv'     => 'video',
      'video/quicktime' => 'video',
      'video/x-msvideo' => 'video',
      // Documents
      'application/pdf' => 'document',
      'application/msword' => 'document',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'document',
      'application/vnd.ms-excel' => 'document',
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'document',
      'text/plain'    => 'document',
      'text/csv'      => 'document',
      'application/rtf' => 'document'
   ];
    protected string $post_type;
    protected int $user_id;
   protected int $term_id;
    protected string $upload_dir;
    protected string $upload_url;
    protected object $cache;
    protected object $notifications;
    protected int $max_retries = 3;
    protected int $retry_delay = 300; // 5 minutes
    public function __construct($post_type, $user_id, $config = [])
    {
        $this->post_type = $post_type;
        $this->user_id = $user_id;
        $this->config = wp_parse_args($config, $this->config);
        // Check for Imagick availability
        $this->config['use_imagick'] = extension_loaded('imagick');
   protected array $supportedFormats = [
      'webp' => 'image/webp',
      'jpeg' => 'image/jpeg',
      'jpg'  => 'image/jpeg',
      'png'  => 'image/png',
      'gif'  => 'image/gif'
   ];
        $this->setupUploadDirs();
   protected array $defaultConfig = [
      'allowed_types' => null, // null = all types allowed
      'max_size' => [
         'image' => 5242880,    // 5MB
         'video' => 104857600,  // 100MB
         'document' => 10485760 // 10MB
      ],
      // Image settings
      'convert' => 'webp',
      'quality' => 80,
      'optimize_images' => true,
      'create_thumbnails' => true,
      'use_imagick' => null,
      // Video settings
      'extract_video_thumbnail' => true,
      'video_thumbnail_time' => 0,
      // Document settings
      'extract_document_preview' => false,
      // Directory structure
      'directory_pattern' => '{user_id}/{post_type}',
      // General
      'original_retention' => 2592000, // 30 days
   ];
        // Schedule cleanup of original files
//        if (!wp_next_scheduled('jvb_cleanup_original_uploads')) {
//            wp_schedule_event(time(), 'daily', 'jvb_cleanup_original_uploads');
//        }
//        add_action('jvb_cleanup_original_uploads', [$this, 'cleanupOriginalFiles']);
        // Track upload statistics
        add_action('jvb_upload_complete', [$this, 'trackUploadStats']);
        add_action('jvb_upload_failed', [$this, 'track_failed_upload']);
    }
    /**
     * @param array $file_data
     *
     * @return array
     * @throws Exception
     */
   public function secureUploadedFile(array $file_data): array
   /******************************************************************
    * 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;
   protected string $dir_pattern;
   /*****************************************************************
    * FEATURE FLAG
   *****************************************************************/
   protected bool $has_imagick = false;
   private function __construct()
   {
      // Create a persistent temporary directory if it doesn't exist
      $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$this->user_id}";
      wp_mkdir_p($temp_dir);
      $upload = wp_upload_dir();
      $this->upload_dir = $upload['basedir'];
      $this->upload_url = $upload['baseurl'];
      // Generate a unique filename for temporary storage
      $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
      $temp_path = "{$temp_dir}/{$temp_filename}";
      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;
      // Move the uploaded file to our temporary storage
      if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) {
         // Fallback to copy if needed
         if (!copy($file_data['tmp_name'], $temp_path)) {
            throw new Exception('Failed to store uploaded file');
      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');
      }
      // Return metadata about the stored file
      return [
         'temp_path' => $temp_path,
         'original_name' => $file_data['name'],
         'mime_type' => $file_data['type'],
         'file_size' => $file_data['size'],
         'stored_at' => current_time('mysql')
      ];
   }
    /**
     * @param string $temp_path
     * @param string $post_type
     * @param int $post_id
     * @param int $term_id
     * @param string $original_filename
     *
     * @return array
     * @throws Exception
     */
   public function processImageFromStorage(string $temp_path, string $post_type, int $post_id = 0, int $term_id = 0, string $original_filename = ''): array
   {
      if (!file_exists($temp_path)) {
         throw new Exception('Stored file no longer exists: ' . $temp_path);
      $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);
      }
      $this->term_id = $term_id;
      $this->post_type = $post_type;
      // Get base upload directory based on content type
      $rel_path = $this->getUploadDirectory();
      // Ensure the directory exists
      $full_upload_path = $this->upload_dir . '/' . $rel_path;
      if (!wp_mkdir_p($full_upload_path)) {
         throw new Exception("Failed to create upload directory: {$full_upload_path}");
      // 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);
      }
      // Generate filename WITHOUT extension (generateFilename should not include extension)
      $base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id));
      // Process the image directly from our temporary storage
      return $this->processImage($temp_path, $rel_path, $base_filename, $post_id);
   }
    /**
     * @return void
     * @throws Exception
     */
    protected function setupUploadDirs():void
    {
        $upload_info = wp_upload_dir();
        if ($upload_info['error']) {
            throw new Exception($upload_info['error']);
        }
        $this->upload_dir = $upload_info['basedir'];
        $this->upload_url = $upload_info['baseurl'];
    }
    /**
     * @return string
     */
    public function getUploadDirectory():string
    {
      // Default WordPress organization: year/month
      $default_path = date('Y/m');
      /**
       * Filter the upload directory structure
       *
       * @param string $path The default upload path
       * @param string $post_type The post type being uploaded
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_directory', $default_path, $this->post_type, $this->user_id, $this->term_id);
    }
   /**
    * Generate filename with extensible filtering
    * Default: generic SEO-friendly format
    *
    * @param string $original_name
    * @param object $user_data
    * @return string
    */
   public function generateFilename(string $original_name, object $user_data): string
   {
      // Default generic filename: {post_type}-{user_id}-{counter}
      $filename = sprintf(
         '%s-%s',
         sanitize_title($this->post_type),
         $this->user_id
      );
      /**
       * Filter the generated filename (without extension)
       *
       * @param string $filename The generated filename
       * @param string $original_name The original filename
       * @param object $user_data WordPress user data object
       * @param string $post_type The post type
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_filename', $filename, $original_name, $user_data, $this->post_type, $this->user_id, $this->term_id).'-'.$this->getNextFileCounter();
   }
    /**
     * @return string
     */
   protected function getNextFileCounter(): string
   {
      // Get counter key for this post type
      $counter_key = 'upload_counter_' . str_replace(['/', '\\'], '_', $this->post_type);
      // Get current counter value, default to 0
      $counter = (int)get_user_meta($this->user_id, $counter_key, true) ?: 0;
      // Increment counter
      $counter++;
      // Update counter in user meta
      update_user_meta($this->user_id, $counter_key, $counter);
      // Return formatted counter
      return sprintf('%08d', $counter);
   }
   /**
    * Generate alt text with filtering for customization
    * Default: basic or empty alt text
    *
    * @param string $file
    * @param object $user_data
    * @param int|null $post_id
    * @return string
    */
   protected function generateAltText(string $file, object $user_data, int|null $post_id = null): string
   {
      // Default: basic alt text or empty
      $alt_text = '';
      /**
       * Filter the generated alt text
       *
       * @param string $alt_text The generated alt text
       * @param string $file The file path
       * @param object $user_data WordPress user data object
       * @param int|null $post_id The post ID (if applicable)
       * @param string $post_type The post type
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_alt_text', $alt_text, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
      return $file_type;
   }
   /**
    * Generate image title with filtering for customization
    * Default: WordPress default behavior (filename-based)
    *
    * @param string $file
    * @param object $user_data
    * @param int|null $post_id
    * @return string
    * Initial, basic processing of upload data for later processing through OperationQueue.php
    * @param array $file_data
    * @param array $context
    * @return array
    */
   protected function generateImageTitle(string $file, object $user_data, int|null $post_id = null): string
   {
      // Default: Use filename without extension (WordPress default behavior)
      $title = pathinfo($file, PATHINFO_FILENAME);
      /**
       * Filter the generated image title
       *
       * @param string $title The generated title
       * @param string $file The file path
       * @param object $user_data WordPress user data object
       * @param int|null $post_id The post ID (if applicable)
       * @param string $post_type The post type
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_image_title', $title, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
   }
    /**
     * @param array $file_data
     * @param array $options
     *
     * @return array|WP_Error
     */
   public function handleContentUpload(array $file_data, array $options = []): array|WP_Error
   public function secureUploadedFile(array $file_data, array $context): array
   {
      try {
         if (!isset($file_data['tmp_name']) || !is_uploaded_file($file_data['tmp_name'])) {
            throw new Exception('No valid file uploaded.');
         $file_type = $this->testFile($file_data, $context);
         $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']);
         $temp_path = "{$this->upload_dir}/{$temp_dir}/{$temp_filename}";
         // Move the uploaded file to our temporary storage
         if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) {
            // Fallback to copy if needed
            if (!copy($file_data['tmp_name'], $temp_path)) {
               throw new Exception('Failed to store uploaded file');
            }
         }
         $user_data = get_userdata($this->user_id);
         $post_id = $options['post_id'] ?? 0;
         $this->term_id = $options['term_id'] ?? 0;
         // Return metadata about the stored file
         return [
            'temp_path'    => $temp_path,
            'original_name' => $file_data['name'],
            'mime_type'    => $file_data['type'],
            'file_type'    => $file_type,
            'file_size'    => $file_data['size'],
            'stored_at'    => current_time('mysql')
         ];
      } catch (Exception $e) {
         Errors::getInstance()->log('UploadManager','Failed to store uploaded file: ' . print_r($e->getMessage(), true));
         return [];
      }
   }
         // Get base upload directory based on content type
         $base_dir = $this->getUploadDirectory();
         $original_dir = 'originals';
   /**
    * Process an uploaded file with full context
    *
    * @param string $temp_path
    * @param array $context Upload context and configuration
    * @return array|WP_Error Result with attachment_id, url, file path
    */
   public function processUpload(string $temp_path, array $context): array|WP_Error
   {
      try {
         $file_type = $this->testFile($temp_path, $context);
         $rel_path = $base_dir;
         $this->ensureUploadDirs($rel_path, $original_dir);
         // Extract context
         $user_id = $context['user_id'] ?? get_current_user_id();
         $post_id = $context['post_id'] ?? 0;
         $filename = $this->generateFilename($file_data['name'], $user_data);
         // Validate file size
         $this->validateFileSize($temp_path, $file_type, $context);
         // Store original file
         $original_file = $this->storeOriginalFile(
            $file_data['tmp_name'],
            $base_dir,
            $original_dir,
            $filename
         );
         // Generate directory structure for final storage
         $directory = $this->generateDirectory($file_type, $context);
         // Process immediately
         return $this->processImage($original_file, $rel_path, $filename, $post_id);
         // Generate filename
         $original_name = $context['original_name'] ?? basename($temp_path);
         $filename = $this->generateFilename($original_name, $context);
         // Process based on file type
         return match($file_type) {
            '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) {
         Errors::getInstance()->log(
            '[UploadManager]:processUpload',
            $e->getMessage(),
            ['temp_path' => $temp_path, 'context' => $context]
         );
         return new WP_Error('upload_failed', $e->getMessage());
      }
   }
    /**
     * @param string $rel_path
     * @param string $original_dir
     *
     * @return void
     * @throws Exception
     */
   protected function ensureUploadDirs($rel_path, $original_dir): void
   /**
    * Clean up empty temporary directories
    */
   public function cleanupEmptyTempDirs(int $user_id): void
   {
      $temp_base = "{$this->upload_dir}/{$user_id}/temp";
      if (!is_dir($temp_base)) {
         return;
      }
      // Get all subdirectories
      $dirs = glob($temp_base . '/*', GLOB_ONLYDIR);
      foreach ($dirs as $dir) {
         // Check if directory is empty
         $files = scandir($dir);
         $files = array_diff($files, ['.', '..']);
         if (empty($files)) {
            @rmdir($dir);
         }
      }
      // Try to remove temp base if empty
      $files = scandir($temp_base);
      $files = array_diff($files, ['.', '..']);
      if (empty($files)) {
         @rmdir($temp_base);
      }
   }
   /**
    * Generate directory structure based on pattern
    * @param string $file_type
    * @param array $config
    * @return string
    * @throws Exception
    */
   public function generateDirectory(string $file_type, array $config): string
   {
      $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 = implode('/', $parts);
      // Allow filtering
      $directory = apply_filters('jvb_upload_directory', $directory, $file_type, $config);
      // Ensure directories exist
      $this->ensureDirectories($directory);
      return $directory;
   }
   /**
    * Ensure directory structure exists
    */
   protected function ensureDirectories(string $rel_path): void
   {
      $dirs = [
         "{$this->upload_dir}/{$rel_path}",
         "{$this->upload_dir}/{$rel_path}/{$original_dir}"
         "{$this->upload_dir}/{$rel_path}"
      ];
      foreach ($dirs as $dir) {
@@ -344,295 +458,464 @@
   /**
    * @param string $tmp_file
    * @param string $base_dir
    * @param string $original_dir
    * @param string $filename
    * @return string
    * 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 storeOriginalFile(string $tmp_file, string $base_dir, string $original_dir, string $filename): string
   protected function validateFileSize(string $file_path, string $file_type, array $config): void
   {
      $ext = pathinfo($tmp_file, PATHINFO_EXTENSION);
      $original_path = "{$this->upload_dir}/{$base_dir}/{$original_dir}/{$filename}.{$ext}";
      if (!move_uploaded_file($tmp_file, $original_path)) {
         throw new Exception('Failed to store original file');
      $size = filesize($file_path);
      $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];
         }
      }
      return $original_path;
      if ($size > $max_size) {
         $max_mb = round($max_size / 1048576, 2);
         $actual_mb = round($size / 1048576, 2);
         throw new Exception("File too large: {$actual_mb}MB (max: {$max_mb}MB)");
      }
   }
   protected function validateMimeType(string $file_path, string $declared_type): bool
   {
      // Get actual MIME type
      $actual_type = mime_content_type($file_path);
      // Check if actual type is allowed
      if (!array_key_exists($actual_type, $this->allowedTypes)) {
         return false;
      }
      // For images, verify it's actually an image
      if (str_starts_with($actual_type, 'image/')) {
         $image_info = @getimagesize($file_path);
         if ($image_info === false) {
            return false;
         }
      }
      return true;
   }
   /**
    * Process image with better error handling
    * Process image file
    * @throws Exception
    */
   protected function processImage(string $original_file, string $rel_path, string $filename, int $post_id): array
   protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $context): array
   {
      // Validate the original file still exists
      if (!file_exists($original_file)) {
         throw new Exception('Original file no longer exists: ' . $original_file);
      $full_dir = "{$this->upload_dir}/{$directory}";
      $original_mime = mime_content_type($original_path);
      // Determine if conversion is needed
      $should_convert = false;
      $target_format = false;
      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])) {
               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);
            }
         }
      }
      // Verify file type before processing
      $mime_type = mime_content_type($original_file);
      if (!in_array($mime_type, $this->config['allowed_types'])) {
         throw new Exception('Invalid file type detected during processing: ' . $mime_type);
      }
      // Convert or copy based on configuration
      if ($should_convert) {
         $final_path = "{$full_dir}/{$filename}.{$target_format}";
      // Ensure the upload directory exists
      $full_upload_dir = $this->upload_dir . '/' . $rel_path;
      if (!wp_mkdir_p($full_upload_dir)) {
         throw new Exception("Failed to create upload directory: {$full_upload_dir}");
      }
         try {
      // Convert to WebP if enabled
      if ($this->config['convert_to_webp'] && $mime_type !== 'image/webp') {
         $final_path = "{$full_upload_dir}/{$filename}.webp";
            $this->convertImage(
               $original_path,
               $final_path,
               $target_format,
               array_key_exists('quality', $context) && is_int($context['quality']) && $context['quality'] <= 100 ?
                  $context['quality'] :
                  $this->convertQuality,
            );
         if ($this->config['use_imagick']) {
            $this->convertWithImagick($original_file, $final_path);
         } else {
            $this->convertWithGd($original_file, $final_path);
         } catch (Exception $e) {
            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}";
            copy($original_path, $final_path);
         }
      } else {
         // Just copy the original with its extension
         $original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
         $final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
         if (!copy($original_file, $final_path)) {
            throw new Exception("Failed to copy file from {$original_file} to {$final_path}");
         }
         // Keep original format
         $ext = pathinfo($original_path, PATHINFO_EXTENSION);
         $final_path = "{$full_dir}/{$filename}.{$ext}";
         copy($original_path, $final_path);
      }
      // Verify the final file was created
      if (!file_exists($final_path)) {
         throw new Exception("Final processed file was not created: {$final_path}");
      // Create attachment
      $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      // Set alt text if provided
      $alt_text = apply_filters('jvb_upload_alt_text', '', $context);
      if ($alt_text !== '') {
         update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
      }
      // Generate title text
      $title = $this->generateImageTitle(
         $final_path,
         get_userdata($this->user_id),
         $post_id
      );
      // Create attachment with title
      $attachment_id = $this->createAttachment($final_path, $title, $post_id);
      // Generate thumbnails
      if ($this->config['create_thumbnails']) {
      $hasOption = array_key_exists('create_thumbnails', $context);
      if (($hasOption && $context['create_thumbnails']) || (!$hasOption && $this->createThumbnails)) {
         $this->generateThumbnails($attachment_id);
      }
      // Update post attachments with new file info
      $this->updatePostAttachments($attachment_id, $final_path);
      return [
         'success' => true,
         'success'   => true,
         'attachment_id' => $attachment_id,
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path
         'url'       => wp_get_attachment_url($attachment_id),
         'file'      => $final_path,
         'type'      => 'image',
         'format'    => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION),
         'context'   => $context
      ];
   }
    /**
     * @param string $source
     * @param string $destination
     *
     * @return void
     * @throws Exception
     */
   protected function convertWithImagick(string $source, string $destination, string $toType = 'webp'): void
   /**
    * Process video file
    * @throws Exception
    */
   protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $context): array
   {
      $allowed = ['webp', 'jpeg', 'jpg', 'png'];
      if (!in_array($toType, $allowed)) {
         return;
      $full_dir = "{$this->upload_dir}/{$directory}";
      $ext = pathinfo($original_path, PATHINFO_EXTENSION);
      $final_path = "{$full_dir}/{$filename}.{$ext}";
      // Copy video to final location
      copy($original_path, $final_path);
      // Create attachment
      try {
         $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      } catch (Exception $e) {
         Errors::getInstance()->log('Uploader', 'Attachment creation failed: '.print_r($e->getMessage(), true));
         return ['success' => false];
      }
      // Extract thumbnail if enabled
      $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 [
         'success'   => true,
         'attachment_id' => $attachment_id,
         'url'       => wp_get_attachment_url($attachment_id),
         'file'      => $final_path,
         'type'      => 'video',
         'context'   => $context
      ];
   }
   /**
    * Process document file
    */
   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);
      $final_path = "{$full_dir}/{$filename}.{$ext}";
      // Copy document to final location
      copy($original_path, $final_path);
      // Create attachment
      try {
         $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      } catch (Exception $e) {
         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',
         'context'   => $context
      ];
   }
   /**
    * Generate SEO-friendly filename
    */
   public function generateFilename(string $original_name, array $context): string
   {
      $name_parts = pathinfo($original_name);
      $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 $this->sanitizeFileName(apply_filters(
         'jvb_upload_filename',
         "{$base_name}-{$timestamp}",
         $context
      ));
   }
   protected function sanitizeFileName(string $filename): string
   {
      // Remove path info
      $filename = basename($filename);
      // Remove special characters
      $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
      // Remove multiple underscores
      $filename = preg_replace('/_+/', '_', $filename);
      // Ensure it's not too long (255 char limit in most filesystems)
      if (strlen($filename) > 200) {
         $ext = pathinfo($filename, PATHINFO_EXTENSION);
         $name = pathinfo($filename, PATHINFO_FILENAME);
         $name = substr($name, 0, 200 - strlen($ext) - 1);
         $filename = $name . '.' . $ext;
      }
      return $filename;
   }
   /**
    * Create WordPress attachment
    */
   protected function createAttachment(string $file_path, string $title, array $context, int $post_id = 0): int
   {
      $file_url = str_replace($this->upload_dir, $this->upload_url, $file_path);
      $mime_type = mime_content_type($file_path);
      $title = apply_filters('jvb_upload_title', $title, $file_path, $context);
      $attachment_id = wp_insert_attachment([
         'guid' => $file_url,
         'post_mime_type' => $mime_type,
         'post_title' => $title,
         'post_status' => 'inherit'
      ], $file_path, $post_id);
      if (is_wp_error($attachment_id)) {
         throw new Exception('Failed to create attachment');
      }
      require_once(ABSPATH . 'wp-admin/includes/image.php');
      $metadata = wp_generate_attachment_metadata($attachment_id, $file_path);
      wp_update_attachment_metadata($attachment_id, $metadata);
      return $attachment_id;
   }
   /**
    * Convert an existing attachment to a different format
    *
    * @param int $attachment_id WordPress attachment ID
    * @param string $format Target format: 'webp', 'jpeg', 'png', 'gif'
    * @param int $quality Conversion quality (1-100)
    * @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 = false): array|WP_Error
   {
      try {
         // Validate format
         $format = strtolower($format);
         if (!isset($this->supportedFormats[$format])) {
            throw new Exception("Unsupported format: {$format}");
         }
         // Get original file
         $original_path = get_attached_file($attachment_id);
         if (!$original_path || !file_exists($original_path)) {
            throw new Exception('Original file not found');
         }
         // Check if it's an image
         $mime_type = get_post_mime_type($attachment_id);
         if (!str_starts_with($mime_type, 'image/')) {
            throw new Exception('Attachment is not an image');
         }
         // Don't convert if already in target format
         $current_mime = $this->supportedFormats[$format];
         if ($mime_type === $current_mime) {
            return [
               'success' => true,
               'message' => 'Already in target format',
               'attachment_id' => $attachment_id,
               'url' => wp_get_attachment_url($attachment_id)
            ];
         }
         // Generate new filename
         $path_info = pathinfo($original_path);
         $new_filename = $path_info['filename'] . '.' . $format;
         $new_path = $path_info['dirname'] . '/' . $new_filename;
         // Perform conversion
         $this->convertImage($original_path, $new_path, $format, $quality);
         if ($replace) {
            // Replace the original file
            $this->replaceAttachmentFile($attachment_id, $new_path, $current_mime);
            $result_attachment_id = $attachment_id;
         } else {
            // Create new attachment
            $post_id = wp_get_post_parent_id($attachment_id);
            $result_attachment_id = $this->createAttachment(
               $new_path,
               get_the_title($attachment_id),
               ['user_id' => get_post_field('post_author', $attachment_id)],
               $post_id
            );
         }
         // Regenerate thumbnails
         $this->generateThumbnails($result_attachment_id);
         return [
            'success' => true,
            'attachment_id' => $result_attachment_id,
            'url' => wp_get_attachment_url($result_attachment_id),
            'format' => $format,
            'file' => $new_path
         ];
      } catch (Exception $e) {
         return new WP_Error('conversion_failed', $e->getMessage());
      }
   }
   /**
    * Generic image conversion method
    */
   /**
    * @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 ($this->has_imagick) {
         $this->convertWithImagick($source, $destination, $format, $quality);
      } else {
         $this->convertWithGd($source, $destination, $format, $quality);
      }
   }
   /**
    * 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
   {
      try {
         $image = new Imagick($source);
         $image->setImageFormat('webp');
         $image->setImageCompressionQuality($this->config['webp_quality']);
         // Set format
         $image->setImageFormat($format);
         // Set quality
         $image->setImageCompressionQuality($quality);
         // Format-specific optimizations
         if ($format === 'webp') {
            $image->setOption('webp:method', '6'); // Better compression
         } elseif ($format === 'jpeg' || $format === 'jpg') {
            $image->setImageCompression(Imagick::COMPRESSION_JPEG);
         } elseif ($format === 'png') {
            $image->setImageCompression(Imagick::COMPRESSION_ZIP);
         }
         $image->writeImage($destination);
         $image->clear();
      } catch (Exception $e) {
         throw new Exception('WebP conversion with Imagick failed: ' . $e->getMessage());
         throw new Exception("Imagick conversion to {$format} failed: " . $e->getMessage());
      }
   }
    /**
     * Fixed convertWithGd method with better error handling
     */
   protected function convertWithGd(string $source, string $destination, string $toType = 'webp'): void
   /**
    * 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
   {
      $mime_type = mime_content_type($source);
      // Ensure destination directory exists
      $dest_dir = dirname($destination);
      if (!wp_mkdir_p($dest_dir)) {
         throw new Exception("Failed to create destination directory: {$dest_dir}");
      // Create image resource from source
      $image = match($mime_type) {
         'image/jpeg' => imagecreatefromjpeg($source),
         'image/png' => imagecreatefrompng($source),
         'image/gif' => imagecreatefromgif($source),
         'image/webp' => imagecreatefromwebp($source),
         default => throw new Exception('Unsupported source image type for GD conversion')
      };
      if (!$image) {
         throw new Exception('Failed to create image resource');
      }
      switch ($mime_type) {
         case 'image/webp':
            $image = imagecreatefromwebp($source);
            break;
         case 'image/jpeg':
            $image = imagecreatefromjpeg($source);
            break;
         case 'image/png':
            $image = imagecreatefrompng($source);
            if ($image !== false) {
               imagepalettetotruecolor($image);
               imagealphablending($image, true);
               imagesavealpha($image, true);
            }
            break;
         case 'image/gif':
            $image = imagecreatefromgif($source);
            if ($image !== false) {
               imagepalettetotruecolor($image);
            }
            break;
         default:
            throw new Exception('Unsupported image type for GD conversion: ' . $mime_type);
      }
      // Convert to target format
      $result = match($format) {
         'webp' => imagewebp($image, $destination, $quality),
         'jpeg', 'jpg' => imagejpeg($image, $destination, $quality),
         'png' => imagepng($image, $destination, $this->qualityToPngCompression($quality)),
         'gif' => imagegif($image, $destination),
         default => throw new Exception("Unsupported target format for GD: {$format}")
      };
      if ($image === false) {
         throw new Exception('Failed to create image resource from source file: ' . $source);
      }
      // Convert to WebP
      $result = imagewebp($image, $destination, $this->config['webp_quality']);
      // Clean up memory
      imagedestroy($image);
      if (!$result) {
         throw new Exception('WebP conversion with GD failed - imagewebp returned false');
      }
      // Verify the file was actually created
      if (!file_exists($destination)) {
         throw new Exception('WebP file was not created despite imagewebp returning true');
         throw new Exception("GD conversion to {$format} failed");
      }
   }
   /**
    * @return void
    * Convert quality (1-100) to PNG compression level (0-9)
    */
   public function cleanupOriginalFiles(): void
   protected function qualityToPngCompression(int $quality): int
   {
      $cutoff = time() - $this->config['original_retention'];
      // Get upload directory and find original directories
      $pattern = "{$this->upload_dir}/**/originals";
      $original_dirs = glob($pattern, GLOB_ONLYDIR);
      foreach ($original_dirs as $original_dir) {
         $files = glob("{$original_dir}/*");
         foreach ($files as $file) {
            if (filemtime($file) < $cutoff) {
               unlink($file);
            }
         }
      }
      // PNG compression is inverted: 0 = no compression, 9 = max compression
      // Quality: 100 = best quality, 1 = worst quality
      // So: quality 100 -> compression 0, quality 1 -> compression 9
      return (int) round((100 - $quality) / 11.11);
   }
   /**
    * @param string|null $temp_dir
    * @return void
    */
   protected function cleanupTempFiles(string|null $temp_dir = null): void
   {
      if (is_null($temp_dir)) {
         $temp_dir = $this->upload_dir . '/tmp';
      }
      if (is_dir($temp_dir)) {
         $files = glob($temp_dir . '/*');
         foreach ($files as $file) {
            if (is_file($file)) {
               @unlink($file);
            }
         }
         @rmdir($temp_dir);
      }
   }
   /**
    * @param int $retention_days
    * @return void
    */
   public function cleanupUserFiles(int $retention_days = 30): void
   {
      $cutoff = time() - ($retention_days * DAY_IN_SECONDS);
      $user_dir = $this->getUploadDirectory();
      if (!is_dir($user_dir)) {
         return;
      }
      $this->cleanupDirectory($user_dir, $cutoff);
   }
    /**
    * @param string $dir
    * @param int $cutoff
    * @return void
    */
   protected function cleanupDirectory(string $dir, int $cutoff): void
   {
      $files = glob($dir . '/*');
      foreach ($files as $file) {
         if (is_dir($file)) {
            $this->cleanupDirectory($file, $cutoff);
         } elseif (filemtime($file) < $cutoff) {
            @unlink($file);
         }
      }
   }
   /**
    * @param string $file
    * @param string $title
    * @param int $post_id
    * @return int|WP_Error
    * @throws Exception
    */
   protected function createAttachment(string $file, string $title, int $post_id): int|WP_Error
   {
      $file_url = str_replace($this->upload_dir, $this->upload_url, $file);
      $attachment = [
         'post_mime_type' => mime_content_type($file),
         'post_title' => $title,
         'post_name' => sanitize_title($title),
         'post_content' => '',
         'post_status' => 'inherit',
         'guid' => $file_url
      ];
      $attach_id = wp_insert_attachment($attachment, $file, $post_id);
      if (is_wp_error($attach_id)) {
         throw new Exception($attach_id->get_error_message());
      }
      // Generate and set alt text
      $alt_text = $this->generateAltText($file, get_userdata($this->user_id), $post_id);
      update_post_meta($attach_id, '_wp_attachment_image_alt', $alt_text);
      return $attach_id;
   }
   /**
    * Generate thumbnails using WordPress's built-in image size system
    * This will create all registered image sizes (thumbnail, medium, large, and any custom sizes)
    * Sites can register custom image sizes using add_image_size()
    *
    * @param int $attachment_id
    * @return void
    * Generate image thumbnails
    */
   protected function generateThumbnails(int $attachment_id): void
   {
@@ -642,119 +925,12 @@
   }
   /**
    * @param $result
    * @return void
    * Extract video thumbnail (placeholder - requires ffmpeg)
    */
   protected function trackUploadStats($result)
   protected function extractVideoThumbnail(int $attachment_id, string $video_path, int $time): void
   {
      $stats_key = "upload_stats_{$this->user_id}";
      $stats = wp_cache_get($stats_key) ?: [
         'total_uploads' => 0,
         'successful_uploads' => 0,
         'failed_uploads' => 0,
         'total_size' => 0,
         'last_upload' => null
      ];
      if ($result['success']) {
         $stats['successful_uploads']++;
         $stats['total_size'] += filesize($result['file']);
      } else {
         $stats['failed_uploads']++;
      }
      $stats['total_uploads']++;
      $stats['last_upload'] = current_time('mysql');
      wp_cache_set($stats_key, $stats, '', DAY_IN_SECONDS);
   }
   /**
    * @param int $attachment_id
    * @param string $new_file_path
    * @return void
    */
   protected function updatePostAttachments(int $attachment_id, string $new_file_path): void
   {
      // Update attachment post
      $file_url = str_replace($this->upload_dir, $this->upload_url, $new_file_path);
      $filename = basename($new_file_path);
      wp_update_post([
         'ID' => $attachment_id,
         'guid' => $file_url,
         'post_mime_type' => mime_content_type($new_file_path),
         'post_title' => $filename
      ]);
      // Update attachment metadata
      update_post_meta($attachment_id, '_wp_attached_file', str_replace($this->upload_dir . '/', '', $new_file_path));
      // Update attachment metadata including sizes
      $metadata = wp_get_attachment_metadata($attachment_id);
      if ($metadata) {
         $metadata['file'] = str_replace($this->upload_dir . '/', '', $new_file_path);
         // Update thumbnail paths if they exist
         if (!empty($metadata['sizes'])) {
            foreach ($metadata['sizes'] as $size => $info) {
               $old_file = $info['file'];
               $new_file = preg_replace(
                  '/\.(jpe?g|png|gif)$/i',
                  '.webp',
                  $old_file
               );
               $metadata['sizes'][$size]['file'] = $new_file;
               $metadata['sizes'][$size]['mime-type'] = 'image/webp';
            }
         }
         wp_update_attachment_metadata($attachment_id, $metadata);
      }
      // If this is a profile/featured image, update those references
      $post_id = wp_get_post_parent_id($attachment_id);
      if ($post_id) {
         $featured_image_id = get_post_thumbnail_id($post_id);
         if ($featured_image_id === $attachment_id) {
            // Re-set the featured image to trigger any necessary updates
            set_post_thumbnail($post_id, $attachment_id);
         }
      }
      // Clear any caches
      clean_attachment_cache($attachment_id);
      clean_post_cache($post_id);
   }
   /**
    * Clean up empty temporary directories
    *
    * @param int $user_id
    * @return void
    */
   public function cleanupEmptyTempDirs(int $user_id): void
   {
      $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$user_id}";
      if (is_dir($temp_dir)) {
         // Check if directory is empty
         $files = scandir($temp_dir);
         $is_empty = (count($files) <= 2); // Only . and .. entries
         if ($is_empty) {
            // Try to remove the empty directory
            @rmdir($temp_dir);
            // Also check if parent temp directory is empty
            $parent_temp_dir = "{$this->upload_dir}/jvb_temp_uploads";
            $parent_files = scandir($parent_temp_dir);
            $parent_is_empty = (count($parent_files) <= 2); // Only . and .. entries
            if ($parent_is_empty) {
               @rmdir($parent_temp_dir);
            }
         }
      }
      // This would require ffmpeg or similar
      // Placeholder for future implementation
      apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time);
   }
}