This commit is contained in:
echo committed 2025-06-24 21:46:54 +02:00
1 parent c8bcfe6bd6
commit 9c277e6db8
18 files changed
+2506 -671

No files matched your search

+226 -33
View File
@@ -8,25 +8,16 @@ class EcomZoneClient
private $apiToken;
private $apiUrl;
private $maxRetries = 3;
private $timeout = 30;
private $timeout = 60; // Increased timeout
public function __construct()
{
$this->apiToken = Configuration::get("ECOMZONE_API_TOKEN");
$this->apiUrl = Configuration::get("ECOMZONE_API_URL");
if (empty($this->apiToken)) {
throw new EcomZoneException(
"API token not configured",
EcomZoneException::API_ERROR
);
}
if (empty($this->apiUrl)) {
throw new EcomZoneException(
"API URL not configured",
EcomZoneException::API_ERROR
);
$this->apiUrl = 'https://dropship.ecomzone.eu/api'; // Default API URL
Configuration::updateValue("ECOMZONE_API_URL", $this->apiUrl);
}
EcomZoneLogger::log("API Client initialized", "INFO", [
@@ -34,44 +25,60 @@ class EcomZoneClient
]);
}
public function validateApiCredentials()
{
if (empty($this->apiToken)) {
throw new EcomZoneException(
"API token not configured",
EcomZoneException::API_ERROR
);
}
return true;
}
public function getCatalog($page = 1, $perPage = 100)
{
$url = $this->apiUrl . "/catalog";
$this->validateApiCredentials();
$params = [
"page" => $page,
"per_page" => $perPage,
];
return $this->makeRequest("GET", $url, $params);
return $this->makeRequest("catalog", "GET", $params);
}
public function getProduct($sku)
{
$url = $this->apiUrl . "/product/" . urlencode($sku);
return $this->makeRequest("GET", $url);
$this->validateApiCredentials();
return $this->makeRequest("product/" . urlencode($sku), "GET");
}
private function makeRequest($method, $url, $params = [], $data = null)
private function makeRequest($endpoint, $method = "GET", $params = [], $data = null)
{
// Validate API credentials first
$this->validateApiCredentials();
$retryCount = 0;
$lastError = null;
$url = rtrim($this->apiUrl, '/') . '/' . ltrim($endpoint, '/');
// Add query parameters to URL if method is GET
if ($method === "GET" && !empty($params) && is_array($params)) {
$url .= (strpos($url, "?") === false ? "?" : "&") . http_build_query($params);
}
do {
try {
EcomZoneLogger::log("Making API request", "INFO", [
"method" => $method,
"url" => $url,
"params" => $params,
"retry" => $retryCount,
"params" => $params
]);
// Add query parameters
if (!empty($params)) {
$url .=
(strpos($url, "?") === false ? "?" : "&") .
http_build_query($params);
}
$curl = curl_init();
$options = [
CURLOPT_URL => $url,
@@ -86,36 +93,108 @@ class EcomZoneClient
"Authorization: Bearer " . $this->apiToken,
"Accept: application/json",
"Content-Type: application/json",
"User-Agent: PrestaShop-EcomZone/1.0",
"Connection: close" // Avoid keep-alive issues
],
// SSL Options - disable verification for development
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
// Verbose debugging
CURLOPT_VERBOSE => true,
];
// Create a temporary file for curl verbose output
$verbose = fopen('php://temp', 'w+');
curl_setopt($curl, CURLOPT_STDERR, $verbose);
// Add POST data if provided
if ($data !== null) {
$options[CURLOPT_POSTFIELDS] = json_encode($data);
$jsonData = json_encode($data);
curl_setopt($curl, CURLOPT_POSTFIELDS, $jsonData);
EcomZoneLogger::log("Request payload", "DEBUG", [
'data' => $jsonData
]);
}
// For POST requests with params, add them to the body
else if ($method !== "GET" && !empty($params)) {
$jsonData = json_encode($params);
curl_setopt($curl, CURLOPT_POSTFIELDS, $jsonData);
EcomZoneLogger::log("Request params as payload", "DEBUG", [
'data' => $jsonData
]);
}
curl_setopt_array($curl, $options);
// Execute the request
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($curl, CURLINFO_CONTENT_TYPE);
$error = curl_error($curl);
$errorNo = curl_errno($curl);
// Get verbose information
rewind($verbose);
$verboseLog = stream_get_contents($verbose);
fclose($verbose);
// Log detailed response information
EcomZoneLogger::log("API Response Details", "DEBUG", [
'http_code' => $httpCode,
'content_type' => $contentType,
'response_length' => strlen($response),
'curl_error_code' => $errorNo,
'curl_error' => $error,
'verbose_log' => $verboseLog,
'raw_response' => strlen($response) > 1000 ?
substr($response, 0, 1000) . '... [truncated]' :
$response
]);
curl_close($curl);
if ($error) {
throw new Exception("cURL Error: " . $error);
if ($errorNo > 0) {
throw new Exception("cURL Error ({$errorNo}): " . $error);
}
// Handle HTTP errors
if ($httpCode >= 400) {
throw new Exception(
"HTTP Error: " . $httpCode . " Response: " . $response
);
$errorMessage = "HTTP Error: " . $httpCode;
// Try to extract error details from response
if (!empty($response)) {
try {
$errorData = json_decode($response, true);
if (json_last_error() === JSON_ERROR_NONE && !empty($errorData['error'])) {
$errorMessage .= " - " . $errorData['error'];
} else {
$errorMessage .= " - Response: " . substr($response, 0, 200);
}
} catch (Exception $e) {
$errorMessage .= " - Raw response: " . substr($response, 0, 200);
}
}
throw new Exception($errorMessage);
}
// Handle empty responses
if (empty($response)) {
throw new Exception("Empty response received from API");
}
// Decode JSON response
$decodedResponse = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("Invalid JSON response");
throw new Exception(
"Invalid JSON response: " . json_last_error_msg() .
"\nRaw response: " . substr($response, 0, 500)
);
}
EcomZoneLogger::log("API request successful", "INFO", [
"http_code" => $httpCode,
"content_type" => $contentType
]);
return $decodedResponse;
@@ -126,10 +205,18 @@ class EcomZoneClient
EcomZoneLogger::log("API request failed", "ERROR", [
"error" => $e->getMessage(),
"retry" => $retryCount,
"url" => $url,
"trace" => $e->getTraceAsString()
]);
if ($retryCount < $this->maxRetries) {
sleep(pow(2, $retryCount));
$sleepTime = pow(2, $retryCount);
EcomZoneLogger::log("Retrying request", "INFO", [
"retry_count" => $retryCount,
"sleep_time" => $sleepTime,
"url" => $url
]);
sleep($sleepTime);
continue;
}
@@ -142,11 +229,117 @@ class EcomZoneClient
}
} while ($retryCount < $this->maxRetries);
// This should never be reached, but just in case
throw new EcomZoneException(
"Max retries reached: " . $lastError->getMessage(),
EcomZoneException::API_ERROR,
$lastError
);
}
/**
* Download an image from the EcomZone API with proper authentication
*
* @param string $imageUrl The full URL of the image to download
* @return array Array containing image data and metadata
* @throws EcomZoneException If the download fails
*/
public function downloadImage(string $imageUrl): array
{
try {
EcomZoneLogger::log("Downloading image", "DEBUG", [
'url' => $imageUrl
]);
// Initialize cURL
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $imageUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => 0,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
CURLOPT_HTTPHEADER => [
'Accept: image/webp,image/apng,image/*,*/*;q=0.8',
'Accept-Encoding: gzip, deflate, br',
'Authorization: Bearer ' . $this->getApiToken()
]
]);
$imageData = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
$error = curl_error($ch);
curl_close($ch);
// Check if the response is HTML instead of an image (authentication error)
$isHtml = stripos($contentType, 'text/html') !== false ||
(strlen($imageData) > 15 && stripos($imageData, '<!DOCTYPE html>') !== false);
if ($isHtml) {
throw new EcomZoneException(
"Authentication error: Received HTML instead of image data. Check API token.",
EcomZoneException::API_ERROR
);
}
if ($httpCode !== 200 || empty($imageData)) {
throw new EcomZoneException(
"Failed to download image (HTTP $httpCode): $error",
EcomZoneException::API_ERROR
);
}
EcomZoneLogger::log("Image download complete", "DEBUG", [
'url' => $imageUrl,
'content_type' => $contentType,
'size' => strlen($imageData)
]);
return [
'data' => $imageData,
'content_type' => $contentType,
'http_code' => $httpCode
];
} catch (Exception $e) {
EcomZoneLogger::log("Image download failed", "ERROR", [
'url' => $imageUrl,
'error' => $e->getMessage()
]);
throw new EcomZoneException(
"Image download failed: " . $e->getMessage(),
EcomZoneException::API_ERROR,
$e
);
}
}
/**
* Get the API token, either from the class property or from the configuration
*
* @return string The API token
*/
protected function getApiToken()
{
if (empty($this->apiToken)) {
$this->apiToken = Configuration::get("ECOMZONE_API_TOKEN");
}
if (empty($this->apiToken)) {
throw new EcomZoneException(
"API token not configured",
EcomZoneException::API_ERROR
);
}
return $this->apiToken;
}
}
+155 -48
View File
@@ -5,66 +5,173 @@ if (!defined("_PS_VERSION_")) {
class EcomZoneLogger
{
private static $logFile = 'ecomzone.log';
private static $maxLogSize = 10485760; // 10MB
public static function log($message, $level = "INFO", $context = [])
const LOG_FILE = 'ecomzone.log';
// Log levels with corresponding severity
const LEVELS = [
'DEBUG' => 1,
'INFO' => 2,
'WARNING' => 3,
'ERROR' => 4,
'CRITICAL' => 5
];
// Current log level - can be changed through configuration
private static function getLogLevel()
{
$configLevel = Configuration::get('ECOMZONE_LOG_LEVEL', 'INFO');
return isset(self::LEVELS[$configLevel]) ? $configLevel : 'INFO';
}
public static function log($message, $level = 'INFO', $context = [])
{
try {
$logDir = _PS_MODULE_DIR_ . 'ecomzone/log/';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
// Check if we should log this level
$logLevel = self::getLogLevel();
if (self::LEVELS[$level] < self::LEVELS[$logLevel]) {
return true; // Skip logging for less severe levels
}
$timestamp = date('Y-m-d H:i:s');
$contextStr = !empty($context) ? json_encode($context) : '';
$logMessage = sprintf(
"[%s] %s: %s %s\n",
$timestamp,
$level,
$message,
$contextStr
);
file_put_contents(
$logDir . self::$logFile,
$logMessage,
FILE_APPEND
);
} catch (Exception $e) {
// Fallback to PrestaShop's logger
PrestaShopLogger::addLog(
"EcomZone: $message",
($level === 'ERROR' ? 3 : 1)
);
}
}
private static function initLogFile()
{
if (!file_exists(self::$logFile)) {
$logDir = dirname(self::$logFile);
// Ensure the log directory exists
$logDir = _PS_MODULE_DIR_ . 'ecomzone/log';
if (!is_dir($logDir)) {
mkdir($logDir, 0755, true);
if (!@mkdir($logDir, 0755, true)) {
throw new Exception("Failed to create log directory: " . $logDir);
}
@chmod($logDir, 0755); // Ensure the directory is writable
}
touch(self::$logFile);
chmod(self::$logFile, 0666);
// Format the log entry
$logEntry = [
'timestamp' => date('Y-m-d H:i:s'),
'level' => strtoupper($level),
'message' => $message,
'context' => $context
];
// For errors and critical issues, also include a stack trace
if (in_array($level, ['ERROR', 'CRITICAL']) && !isset($context['trace'])) {
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
$logEntry['trace'] = array_slice($trace, 1); // Skip the log function itself
}
$logLine = json_encode($logEntry) . PHP_EOL;
// Rotate log file if it's too large (> 10MB)
$logFile = $logDir . '/' . self::LOG_FILE;
if (file_exists($logFile) && filesize($logFile) > 10 * 1024 * 1024) {
self::rotateLogFile($logFile);
}
// Write to log file with exclusive lock
if (!@file_put_contents($logFile, $logLine, FILE_APPEND | LOCK_EX)) {
throw new Exception("Failed to write to log file: " . $logFile);
}
// Also write to PrestaShop log for error/critical levels
if (in_array($level, ['ERROR', 'CRITICAL'])) {
$psLogLevel = ($level === 'ERROR') ? 3 : 4;
$contextStr = json_encode($context);
PrestaShopLogger::addLog(
"EcomZone: {$message} - Context: {$contextStr}",
$psLogLevel,
null,
'EcomZone'
);
}
return true;
} catch (Exception $e) {
// Last resort logging to PHP error log
error_log('EcomZone logging error: ' . $e->getMessage() .
'. Original message: ' . $message);
// Try to log to PrestaShop's log
try {
PrestaShopLogger::addLog(
'EcomZone logging error: ' . $e->getMessage() . '. Original message: ' . $message,
3, // Error level
null,
'EcomZone'
);
} catch (Exception $innerEx) {
error_log('Failed to log to PrestaShop: ' . $innerEx->getMessage());
}
return false;
}
}
private static function rotateLogIfNeeded()
private static function rotateLogFile($logFile)
{
if (!file_exists(self::$logFile)) {
return;
}
try {
$backup = $logFile . '.' . date('Y-m-d-H-i-s') . '.bak';
if (!@rename($logFile, $backup)) {
throw new Exception("Failed to rotate log file");
}
if (filesize(self::$logFile) > self::$maxLogSize) {
$backup = self::$logFile . "." . date("Y-m-d-H-i-s") . ".bak";
rename(self::$logFile, $backup);
touch(self::$logFile);
chmod(self::$logFile, 0666);
// Keep only last 5 backup files
$backups = glob($logFile . '.*.bak');
if (count($backups) > 5) {
usort($backups, function($a, $b) {
return filemtime($b) - filemtime($a);
});
$toDelete = array_slice($backups, 5);
foreach ($toDelete as $file) {
@unlink($file);
}
}
return true;
} catch (Exception $e) {
error_log('EcomZone log rotation error: ' . $e->getMessage());
return false;
}
}
public static function getLogPath()
{
// When running in standalone mode, use the local directory
if (!defined('_PS_MODULE_DIR_') || !file_exists(_PS_MODULE_DIR_ . 'ecomzone')) {
return __DIR__ . '/../log/' . self::LOG_FILE;
}
return _PS_MODULE_DIR_ . 'ecomzone/log/' . self::LOG_FILE;
}
public static function getLogContents($maxLines = 100)
{
$logFile = self::getLogPath();
if (!file_exists($logFile)) {
return [];
}
$logs = [];
$lines = array_reverse(file($logFile));
$count = 0;
foreach ($lines as $line) {
if ($count >= $maxLines) break;
$entry = json_decode($line, true);
if ($entry) {
$logs[] = $entry;
$count++;
}
}
return $logs;
}
public static function clearLogs()
{
$logFile = self::getLogPath();
if (file_exists($logFile)) {
@unlink($logFile);
self::log("Log file cleared", "INFO");
}
return true;
}
}
+292 -168
View File
@@ -3,6 +3,8 @@ if (!defined("_PS_VERSION_")) {
exit();
}
require_once dirname(__FILE__) . '/interfaces/IProductSync.php';
class EcomZoneProductSync implements IProductSync
{
private EcomZoneClient $client;
@@ -22,63 +24,112 @@ class EcomZoneProductSync implements IProductSync
public function importProducts(int $perPage = 100): array
{
$page = 1;
$totalImported = 0;
$totalAvailable = 0;
$errors = [];
try {
EcomZoneLogger::log("Starting product import", "INFO", [
// Get or initialize sync state
$syncState = $this->getSyncState();
// If this is a new sync (page 1 and no imports), reset everything
if (empty($syncState) || ($syncState['current_page'] ?? 1) === 1 && ($syncState['total_imported'] ?? 0) === 0) {
$this->resetSyncState();
$syncState = [];
}
$page = $syncState['current_page'] ?? 1;
$totalImported = $syncState['total_imported'] ?? 0;
$totalAvailable = $syncState['total_available'] ?? 0;
$errors = $syncState['errors'] ?? [];
$maxProductsPerBatch = 20; // Process 20 products at a time to avoid timeout
EcomZoneLogger::log("Starting/Resuming product import", "INFO", [
"page" => $page,
"per_page" => $perPage,
"total_imported" => $totalImported,
"is_new_sync" => empty($syncState)
]);
do {
$catalog = $this->client->getCatalog($page, $perPage);
// Get catalog for current page
$catalog = $this->client->getCatalog($page, $perPage);
if (!isset($catalog["data"]) || !is_array($catalog["data"])) {
throw new EcomZoneException(
"Invalid catalog response",
EcomZoneException::API_ERROR
);
}
foreach ($catalog["data"] as $product) {
try {
if ($this->importSingleProduct($product)) {
$totalImported++;
}
} catch (Exception $e) {
$errors[] = [
"sku" => $product["sku"] ?? "unknown",
"error" => $e->getMessage(),
];
EcomZoneLogger::log("Product import failed", "ERROR", [
"sku" => $product["sku"] ?? "unknown",
"error" => $e->getMessage(),
]);
}
}
if (!isset($catalog["data"]) || !is_array($catalog["data"])) {
throw new EcomZoneException(
"Invalid catalog response",
EcomZoneException::API_ERROR
);
}
// Update total available on first page or if it changes
if ($page === 1 || ($catalog["total"] ?? 0) !== $totalAvailable) {
$totalAvailable = $catalog["total"] ?? 0;
$page++;
}
EcomZoneLogger::log("Page processed", "INFO", [
"page" => $page - 1,
"imported" => $totalImported,
"total" => $totalAvailable,
]);
} while (
isset($catalog["next_page_url"]) &&
$catalog["next_page_url"] !== null
);
$productsProcessed = 0;
$batchErrors = [];
// Process only a batch of products
foreach ($catalog["data"] as $product) {
if ($productsProcessed >= $maxProductsPerBatch) {
break;
}
$this->clearCache();
try {
if ($this->importSingleProduct($product)) {
$totalImported++;
}
} catch (Exception $e) {
$batchErrors[] = [
"sku" => $product["sku"] ?? "unknown",
"error" => $e->getMessage(),
];
EcomZoneLogger::log("Product import failed", "ERROR", [
"sku" => $product["sku"] ?? "unknown",
"error" => $e->getMessage(),
]);
}
$productsProcessed++;
}
// Update errors array with new batch errors
$errors = array_merge($errors, $batchErrors);
// Calculate progress
$progress = [
'current_page' => $page,
'total_imported' => $totalImported,
'total_available' => $totalAvailable,
'errors' => $errors,
'is_complete' => false,
'products_processed_on_page' => $productsProcessed
];
// Check if we need to continue to next page
if ($productsProcessed < count($catalog["data"])) {
// Still more products to process on this page
$progress['products_processed_on_page'] = $productsProcessed;
} else {
// Move to next page if available
if (isset($catalog["next_page_url"]) && $catalog["next_page_url"] !== null) {
$progress['current_page'] = $page + 1;
$progress['products_processed_on_page'] = 0;
} else {
$progress['is_complete'] = true;
}
}
// Save progress
$this->saveSyncState($progress);
// Clear cache if complete
if ($progress['is_complete']) {
$this->clearCache();
}
return [
"success" => true,
"imported" => $totalImported,
"total" => $totalAvailable,
"errors" => $errors,
"is_complete" => $progress['is_complete'],
"current_page" => $progress['current_page']
];
} catch (Exception $e) {
EcomZoneLogger::log("Product import process failed", "ERROR", [
@@ -192,8 +243,11 @@ class EcomZoneProductSync implements IProductSync
$product->name[$this->defaultLangId] = $data['product_name'];
$product->description[$this->defaultLangId] = $data['long_description'] ?? $data['description'];
$product->description_short[$this->defaultLangId] = $data['description'];
$product->price = (float)$data['product_price'];
$product->wholesale_price = (float)($data['recommended_retail_price'] ?? 0);
// Update prices
$product->wholesale_price = (float)$data['product_price']; // Original price as wholesale price
$product->price = (float)($data['recommended_retail_price'] ?? $data['product_price']); // Recommended retail price for shop display
$product->reference = $data['sku'];
$product->ean13 = $data['ean'] ?? '';
@@ -354,15 +408,32 @@ class EcomZoneProductSync implements IProductSync
private function updateProductStock(Product $product, array $data): void
{
try {
$quantity = (int) ($data['stock_quantity'] ?? 0);
StockAvailable::setQuantity($product->id, 0, $quantity);
$quantity = (int)($data['stock'] ?? 0);
// Make sure product is saved first
if (!$product->id) {
$product->save();
}
// Update stock quantity
StockAvailable::setQuantity(
$product->id,
0, // Combination ID (0 for products without combinations)
$quantity
);
// Update out of stock behavior
StockAvailable::setProductOutOfStock(
$product->id,
$quantity > 0 ? 2 : 0, // 2 = Allow orders, 0 = Deny orders
2, // Always allow orders
$this->defaultShopId
);
EcomZoneLogger::log("Stock updated", "DEBUG", [
"product" => $product->reference,
"quantity" => $quantity
]);
} catch (Exception $e) {
EcomZoneLogger::log("Failed to update stock", "WARNING", [
"product" => $product->reference,
@@ -375,49 +446,126 @@ class EcomZoneProductSync implements IProductSync
{
try {
$imageUrls = [];
EcomZoneLogger::log("Raw product data for images", "DEBUG", [
"product" => $product->reference,
"data" => json_encode($data)
]);
// Process main image
if (!empty($data["image"])) {
$imageUrls[] = $data["image"];
}
// Process additional images
if (!empty($data["images"])) {
$additionalImages = json_decode($data["images"], true);
if (is_array($additionalImages)) {
$imageUrls = array_merge($imageUrls, $additionalImages);
// Handle different possible data structures
if (isset($data['images']) && is_string($data['images'])) {
$images = json_decode($data['images'], true);
if (json_last_error() === JSON_ERROR_NONE && is_array($images)) {
$imageUrls = array_merge($imageUrls, $images);
}
} elseif (isset($data['images']) && is_array($data['images'])) {
$imageUrls = array_merge($imageUrls, $data['images']);
}
// Add main image if available
if (!empty($data['image'])) {
array_unshift($imageUrls, $data['image']); // Add as first image
}
// Filter out empty or invalid URLs
$imageUrls = array_filter($imageUrls, function($url) {
return !empty($url) && filter_var($url, FILTER_VALIDATE_URL);
});
if (empty($imageUrls)) {
EcomZoneLogger::log("No valid images found for product", "WARNING", [
"product" => $product->reference,
"data" => json_encode($data)
]);
return;
}
// Delete existing images if any
$product->deleteImages();
$successCount = 0;
foreach ($imageUrls as $index => $imageUrl) {
$this->importProductImage($product, $imageUrl, $index === 0);
try {
EcomZoneLogger::log("Processing image", "DEBUG", [
"product" => $product->reference,
"image_url" => $imageUrl,
"index" => $index
]);
$this->importProductImage($product, $imageUrl, $index === 0);
$successCount++;
} catch (Exception $e) {
EcomZoneLogger::log("Failed to process image", "WARNING", [
"product" => $product->reference,
"image_url" => $imageUrl,
"error" => $e->getMessage()
]);
continue;
}
}
if ($successCount === 0 && !empty($imageUrls)) {
throw new Exception(sprintf(
"Failed to process any images for the product. Attempted %d images.",
count($imageUrls)
));
}
EcomZoneLogger::log("Processed product images", "INFO", [
"product" => $product->reference,
"total_images" => count($imageUrls),
"successful_imports" => $successCount
]);
} catch (Exception $e) {
EcomZoneLogger::log("Failed to process product images", "WARNING", [
EcomZoneLogger::log("Failed to process product images", "ERROR", [
"sku" => $product->reference,
"error" => $e->getMessage(),
"data" => json_encode($data)
]);
throw $e;
}
}
private function importProductImage(Product $product, string $imageUrl, bool $cover = false): void
{
try {
// Create temporary file
$tempFile = tempnam(_PS_TMP_IMG_DIR_, 'import_');
// Create temporary directory if it doesn't exist
$tempDir = _PS_MODULE_DIR_ . 'ecomzone/tmp/';
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
chmod($tempDir, 0755);
}
// Clean URL - handle double escaped slashes
$cleanUrl = stripslashes(stripslashes(trim($imageUrl)));
// Download image
if (!copy($imageUrl, $tempFile)) {
throw new Exception("Failed to download image: " . $imageUrl);
EcomZoneLogger::log("Processing image URL", "DEBUG", [
"product" => $product->reference,
"original_url" => $imageUrl,
"cleaned_url" => $cleanUrl
]);
// Create temp file
$tempFile = tempnam($tempDir, 'import_');
if ($tempFile) {
chmod($tempFile, 0644);
}
// Use the client class to download the image
$imageResult = $this->client->downloadImage($cleanUrl);
$imageData = $imageResult['data'];
$contentType = $imageResult['content_type'];
// Save image data to temp file
file_put_contents($tempFile, $imageData);
// Verify the downloaded file is a valid image
$imageInfo = @getimagesize($tempFile);
if (!$imageInfo) {
throw new Exception("Downloaded file is not a valid image. Content type: $contentType");
}
// Create image object
$image = new Image();
$image->id_product = $product->id;
@@ -433,29 +581,60 @@ class EcomZoneProductSync implements IProductSync
$imageDir = dirname($imagePath);
if (!file_exists($imageDir)) {
mkdir($imageDir, 0755, true);
if (!mkdir($imageDir, 0755, true)) {
throw new Exception("Failed to create image directory: " . $imageDir);
}
chmod($imageDir, 0755);
}
// Generate image
if (!ImageManager::resize(
$tempFile,
$imagePath . '.jpg'
$imagePath . '.jpg',
null,
null,
'jpg'
)) {
throw new Exception("Failed to process image");
}
// Ensure the generated image has correct permissions
chmod($imagePath . '.jpg', 0644);
// Generate thumbnails
$this->generateThumbnails($image);
// Clean up
unlink($tempFile);
if (file_exists($tempFile)) {
unlink($tempFile);
}
EcomZoneLogger::log("Image imported successfully", "DEBUG", [
"product" => $product->reference,
"image_url" => $cleanUrl,
"mime_type" => $imageInfo['mime']
]);
} catch (Exception $e) {
EcomZoneLogger::log("Failed to import product image", "ERROR", [
"product" => $product->reference,
"image_url" => $imageUrl,
"error" => $e->getMessage()
"clean_url" => $cleanUrl ?? '',
"error" => $e->getMessage(),
"content_type" => $contentType ?? null
]);
// Clean up on error
if (isset($tempFile) && file_exists($tempFile)) {
unlink($tempFile);
}
// Delete image record if it was created
if (isset($image) && $image->id) {
$image->delete();
}
throw $e;
}
}
@@ -463,117 +642,41 @@ class EcomZoneProductSync implements IProductSync
{
try {
$imageTypes = ImageType::getImagesTypes('products');
$sourceFile = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '.jpg';
if (!file_exists($sourceFile)) {
throw new Exception("Source image file not found");
}
foreach ($imageTypes as $imageType) {
$dir = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath();
$destination = _PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '-' . stripslashes($imageType['name']) . '.jpg';
$dir = dirname($destination);
if (!file_exists($dir)) {
mkdir($dir, 0755, true);
if (!mkdir($dir, 0755, true)) {
throw new Exception("Failed to create thumbnail directory: " . $dir);
}
chmod($dir, 0755);
}
ImageManager::resize(
_PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '.jpg',
_PS_PROD_IMG_DIR_ . $image->getExistingImgPath() . '-' . stripslashes($imageType['name']) . '.jpg',
if (!ImageManager::resize(
$sourceFile,
$destination,
(int)$imageType['width'],
(int)$imageType['height']
);
)) {
throw new Exception("Failed to generate thumbnail: " . $imageType['name']);
}
// Set proper permissions for the thumbnail
chmod($destination, 0644);
}
} catch (Exception $e) {
EcomZoneLogger::log("Failed to generate thumbnails", "WARNING", [
"image_id" => $image->id,
"error" => $e->getMessage()
]);
}
}
private function generateImageType(
string $srcPath,
string $destPath,
?int $width,
?int $height
): void {
try {
$this->createDirectoryIfNotExists(dirname($destPath));
if ($width === null || $height === null) {
copy($srcPath, $destPath);
return;
}
$imageInfo = getimagesize($srcPath);
if (!$imageInfo) {
throw new Exception("Invalid image file");
}
$srcImage = $this->createImageFromType($srcPath, $imageInfo[2]);
$destImage = imagecreatetruecolor($width, $height);
// Preserve transparency for PNG images
if ($imageInfo[2] === IMAGETYPE_PNG) {
imagealphablending($destImage, false);
imagesavealpha($destImage, true);
$transparent = imagecolorallocatealpha(
$destImage,
255,
255,
255,
127
);
imagefilledrectangle(
$destImage,
0,
0,
$width,
$height,
$transparent
);
}
imagecopyresampled(
$destImage,
$srcImage,
0,
0,
0,
0,
$width,
$height,
$imageInfo[0],
$imageInfo[1]
);
imagejpeg($destImage, $destPath, 95);
imagedestroy($srcImage);
imagedestroy($destImage);
} catch (Exception $e) {
throw new EcomZoneException(
"Failed to generate image type: " . $e->getMessage(),
EcomZoneException::IMAGE_PROCESSING_ERROR,
$e
);
}
}
private function createImageFromType(string $filename, int $type)
{
switch ($type) {
case IMAGETYPE_JPEG:
return imagecreatefromjpeg($filename);
case IMAGETYPE_PNG:
return imagecreatefrompng($filename);
case IMAGETYPE_GIF:
return imagecreatefromgif($filename);
default:
throw new Exception("Unsupported image type: " . $type);
}
}
private function createDirectoryIfNotExists(string $directory): void
{
if (!is_dir($directory)) {
if (!mkdir($directory, 0755, true)) {
throw new Exception(
"Failed to create directory: " . $directory
);
}
throw $e;
}
}
@@ -716,6 +819,27 @@ class EcomZoneProductSync implements IProductSync
Media::clearCache();
PrestaShopAutoload::getInstance()->generateIndex();
}
private function getSyncState(): array
{
$state = Configuration::get('ECOMZONE_SYNC_STATE');
return $state ? json_decode($state, true) : [];
}
private function saveSyncState(array $state): void
{
Configuration::updateValue('ECOMZONE_SYNC_STATE', json_encode($state));
}
public function resetSyncState(): void
{
// Delete both sync state and progress
Configuration::deleteByName('ECOMZONE_SYNC_STATE');
Configuration::deleteByName('ECOMZONE_SYNC_PROGRESS');
Configuration::deleteByName('ECOMZONE_LAST_SYNC');
EcomZoneLogger::log("Sync state reset", "INFO");
}
}
$token = Configuration::get('ECOMZONE_CRON_TOKEN');
@@ -5,6 +5,17 @@ if (!defined('_PS_VERSION_')) {
interface IProductSync
{
/**
* Import products from external source
* @param int $perPage Number of products to import per page
* @return array Import results containing success status, counts, and any errors
*/
public function importProducts(int $perPage = 100): array;
/**
* Import a single product
* @param array $productData Product data to import
* @return bool Success status
*/
public function importSingleProduct(array $productData): bool;
}