<?php
|
namespace JVBase\managers;
|
|
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;
|
}
|
|
/**
|
* 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',
|
'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 array $supportedFormats = [
|
'webp' => 'image/webp',
|
'jpeg' => 'image/jpeg',
|
'jpg' => 'image/jpeg',
|
'png' => 'image/png',
|
'gif' => 'image/gif'
|
];
|
|
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
|
];
|
|
/******************************************************************
|
* 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()
|
{
|
$upload = wp_upload_dir();
|
$this->upload_dir = $upload['basedir'];
|
$this->upload_url = $upload['baseurl'];
|
|
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;
|
}
|
|
/**
|
* Initial, basic processing of upload data for later processing through OperationQueue.php
|
* @param array $file_data
|
* @param array $context
|
* @return array
|
*/
|
public function secureUploadedFile(array $file_data, array $context): array
|
{
|
try {
|
$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');
|
}
|
}
|
|
// 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 [];
|
}
|
}
|
|
/**
|
* 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);
|
|
// Extract context
|
$user_id = $context['user_id'] ?? get_current_user_id();
|
$post_id = $context['post_id'] ?? 0;
|
|
// Validate file size
|
$this->validateFileSize($temp_path, $file_type, $context);
|
|
// Generate directory structure for final storage
|
$directory = $this->generateDirectory($file_type, $context);
|
|
// 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());
|
}
|
}
|
|
|
/**
|
* 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}"
|
];
|
|
foreach ($dirs as $dir) {
|
if (!wp_mkdir_p($dir)) {
|
throw new Exception("Failed to create directory: {$dir}");
|
}
|
}
|
}
|
|
|
/**
|
* 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 = $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);
|
$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 file
|
* @throws Exception
|
*/
|
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);
|
|
// 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);
|
}
|
}
|
}
|
|
// 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,
|
array_key_exists('quality', $context) && is_int($context['quality']) && $context['quality'] <= 100 ?
|
$context['quality'] :
|
$this->convertQuality,
|
);
|
|
} 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 {
|
// Keep original format
|
$ext = pathinfo($original_path, PATHINFO_EXTENSION);
|
$final_path = "{$full_dir}/{$filename}.{$ext}";
|
copy($original_path, $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 thumbnails
|
$hasOption = array_key_exists('create_thumbnails', $context);
|
if (($hasOption && $context['create_thumbnails']) || (!$hasOption && $this->createThumbnails)) {
|
$this->generateThumbnails($attachment_id);
|
}
|
|
return [
|
'success' => true,
|
'attachment_id' => $attachment_id,
|
'url' => wp_get_attachment_url($attachment_id),
|
'file' => $final_path,
|
'type' => 'image',
|
'format' => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION),
|
'context' => $context
|
];
|
}
|
|
/**
|
* Process video file
|
* @throws Exception
|
*/
|
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);
|
$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);
|
|
// 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("Imagick conversion to {$format} failed: " . $e->getMessage());
|
}
|
}
|
|
/**
|
* 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);
|
|
// 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');
|
}
|
|
// 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}")
|
};
|
|
imagedestroy($image);
|
|
if (!$result) {
|
throw new Exception("GD conversion to {$format} failed");
|
}
|
}
|
|
/**
|
* Convert quality (1-100) to PNG compression level (0-9)
|
*/
|
protected function qualityToPngCompression(int $quality): int
|
{
|
// 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);
|
}
|
|
/**
|
* Generate image thumbnails
|
*/
|
protected function generateThumbnails(int $attachment_id): void
|
{
|
require_once(ABSPATH . 'wp-admin/includes/image.php');
|
$metadata = wp_generate_attachment_metadata($attachment_id, get_attached_file($attachment_id));
|
wp_update_attachment_metadata($attachment_id, $metadata);
|
}
|
|
/**
|
* Extract video thumbnail (placeholder - requires ffmpeg)
|
*/
|
protected function extractVideoThumbnail(int $attachment_id, string $video_path, int $time): void
|
{
|
// This would require ffmpeg or similar
|
// Placeholder for future implementation
|
apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time);
|
}
|
}
|