commit c764f4a26e61ce6d226a159749168566edef4a02
Author: nertyog img_opti_nat_od_ua_v1
+ ]*)>/is', function($matches) use ($config, $registry, $model_tool_image, $webp_on_fly, $lazy_load, $broken_fallback, $fallback_image) {
+ $attrs_str = $matches[1];
+ if (stripos($attrs_str, 'catalog_trash') !== false) {
+ return $matches[0];
+ }
+
+ preg_match_all('/(\b[\w\-:]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/is', $attrs_str, $attr_matches, PREG_SET_ORDER);
+ $attrs = array();
+ foreach ($attr_matches as $am) {
+ $name = strtolower($am[1]);
+ $val = ($am[2] !== '') ? $am[2] : (($am[3] !== '') ? $am[3] : $am[4]);
+ $attrs[$name] = $val;
+ }
+
+ $src = isset($attrs['src']) ? $attrs['src'] : '';
+ $data_src = isset($attrs['data-src']) ? $attrs['data-src'] : '';
+ $has_datasrc = ($data_src !== '');
+
+ $target_url = $has_datasrc ? $data_src : $src;
+ $rel_path = '';
+ if (preg_match('/\\/image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $target_url, $m_rel)) {
+ $rel_path = urldecode($m_rel[1]);
+ } elseif (preg_match('/^image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $target_url, $m_rel)) {
+ $rel_path = urldecode($m_rel[1]);
+ }
+
+ $clean_rel_path = preg_replace('/\\?.*/', '', $rel_path);
+ $browser_supports_webp = isset($_SERVER['HTTP_ACCEPT']) && (strpos($_SERVER['HTTP_ACCEPT'], 'image/webp') !== false);
+
+ if ($webp_on_fly && $browser_supports_webp && $clean_rel_path) {
+ $ext = strtolower(substr(strrchr($clean_rel_path, '.'), 1));
+ if (in_array($ext, array('jpg', 'jpeg', 'png'))) {
+ $phys_source = DIR_IMAGE . $clean_rel_path;
+ $phys_webp = DIR_IMAGE . preg_replace('/\\.(jpg|jpeg|png)$/i', '.webp', $clean_rel_path);
+ if (file_exists($phys_source)) {
+ if (!file_exists($phys_webp)) {
+ $img_data = @file_get_contents($phys_source);
+ if ($img_data) {
+ $im = @imagecreatefromstring($img_data);
+ if ($im) {
+ $webp_quality = (int)$config->get('module_img_opti_webp_quality');
+ if ($webp_quality <= 0 || $webp_quality > 100) {
+ $webp_quality = 80;
+ }
+ @imagewebp($im, $phys_webp, $webp_quality);
+ @imagedestroy($im);
+ }
+ }
+ }
+ if (file_exists($phys_webp)) {
+ if (isset($attrs['src'])) {
+ $attrs['src'] = preg_replace('/\\.(jpg|jpeg|png)(\\?|$)/i', '.webp$2', $attrs['src']);
+ }
+ if (isset($attrs['data-src'])) {
+ $attrs['data-src'] = preg_replace('/\\.(jpg|jpeg|png)(\\?|$)/i', '.webp$2', $attrs['data-src']);
+ }
+ }
+ }
+ }
+ }
+
+ if ($webp_on_fly && $browser_supports_webp) {
+ foreach (array('srcset', 'data-srcset') as $srcset_attr) {
+ if (isset($attrs[$srcset_attr])) {
+ $parts = explode(',', $attrs[$srcset_attr]);
+ foreach ($parts as &$part) {
+ $part = trim($part);
+ $subparts = preg_split('/\\s+/', $part);
+ if (!empty($subparts[0])) {
+ $s_url = $subparts[0];
+ $s_rel = '';
+ if (preg_match('/\\/image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $s_url, $sm_rel)) {
+ $s_rel = urldecode($sm_rel[1]);
+ } elseif (preg_match('/^image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $s_url, $sm_rel)) {
+ $s_rel = urldecode($sm_rel[1]);
+ }
+ $s_clean = preg_replace('/\\?.*/', '', $s_rel);
+ if ($s_clean) {
+ $s_ext = strtolower(substr(strrchr($s_clean, '.'), 1));
+ if (in_array($s_ext, array('jpg', 'jpeg', 'png'))) {
+ $s_phys_source = DIR_IMAGE . $s_clean;
+ $s_phys_webp = DIR_IMAGE . preg_replace('/\\.(jpg|jpeg|png)$/i', '.webp', $s_clean);
+ if (file_exists($s_phys_source)) {
+ if (!file_exists($s_phys_webp)) {
+ $img_data = @file_get_contents($s_phys_source);
+ if ($img_data) {
+ $im = @imagecreatefromstring($img_data);
+ if ($im) {
+ $webp_quality = (int)$config->get('module_img_opti_webp_quality');
+ if ($webp_quality <= 0 || $webp_quality > 100) {
+ $webp_quality = 80;
+ }
+ @imagewebp($im, $s_phys_webp, $webp_quality);
+ @imagedestroy($im);
+ }
+ }
+ }
+ if (file_exists($s_phys_webp)) {
+ $subparts[0] = preg_replace('/\\.(jpg|jpeg|png)(\\?|$)/i', '.webp$2', $s_url);
+ }
+ }
+ }
+ }
+ }
+ $part = implode(' ', $subparts);
+ }
+ $attrs[$srcset_attr] = implode(', ', $parts);
+ }
+ }
+ }
+
+ $is_svg = (strtolower(substr(strrchr($clean_rel_path, '.'), 1)) === 'svg');
+
+ if ($lazy_load && $clean_rel_path && is_file(DIR_IMAGE . $clean_rel_path) && !$is_svg) {
+ try {
+ $tiny_thumb = $model_tool_image->resize($clean_rel_path, 20, 20);
+ if ($has_datasrc) {
+ $attrs['src'] = $tiny_thumb;
+ } else {
+ $attrs['data-src'] = isset($attrs['src']) ? $attrs['src'] : '';
+ $attrs['src'] = $tiny_thumb;
+ }
+ if (isset($attrs['srcset'])) {
+ $attrs['data-srcset'] = $attrs['srcset'];
+ unset($attrs['srcset']);
+ }
+ $class = isset($attrs['class']) ? $attrs['class'] . ' img-lazyload' : 'img-lazyload';
+ $attrs['class'] = $class;
+
+ $style = 'filter: blur(10px); transition: filter 0.3s;';
+ if (isset($attrs['style'])) {
+ $attrs['style'] = rtrim(trim($attrs['style']), ';') . '; ' . $style;
+ } else {
+ $attrs['style'] = $style;
+ }
+ } catch (Exception $e) {}
+ }
+
+ if ($broken_fallback) {
+ $fallback_url = '';
+ $req_obj = $registry->get('request');
+ $is_https = isset($req_obj->server['HTTPS']) && ($req_obj->server['HTTPS'] == 'on' || $req_obj->server['HTTPS'] == '1');
+ $server_url = $is_https ? $config->get('config_ssl') : $config->get('config_url');
+ if ($fallback_image && is_file(DIR_IMAGE . $fallback_image)) {
+ $fallback_url = $server_url . 'image/' . $fallback_image;
+ } elseif ($config->get('config_logo') && is_file(DIR_IMAGE . $config->get('config_logo'))) {
+ $fallback_url = $server_url . 'image/' . $config->get('config_logo');
+ } else {
+ $fallback_url = $server_url . 'image/no_image.png';
+ }
+ if ($fallback_url) {
+ $attrs['onerror'] = "this.src='" . $fallback_url . "'; this.srcset=''; this.onerror=null;";
+ }
+ }
+
+ $new_attrs = array();
+ foreach ($attrs as $k => $v) {
+ $new_attrs[] = $k . '="' . htmlspecialchars($v, ENT_QUOTES, 'UTF-8') . '"';
+ }
+ return '
';
+ }, $output);
+
+ if ($lazy_load) {
+ $lazy_script = '
+
+
';
+ $output = str_ireplace('', $lazy_script, $output);
+ }
+ }
+ }
+ }
+ $this->output = $output;
+ echo $this->output;
+ ]]>
+
+ ]*)>/is', function($matches) use ($config, $registry, $model_tool_image, $webp_on_fly, $lazy_load, $broken_fallback, $fallback_image) {
+ $attrs_str = $matches[1];
+ if (stripos($attrs_str, 'catalog_trash') !== false) {
+ return $matches[0];
+ }
+
+ preg_match_all('/(\b[\w\-:]+)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/is', $attrs_str, $attr_matches, PREG_SET_ORDER);
+ $attrs = array();
+ foreach ($attr_matches as $am) {
+ $name = strtolower($am[1]);
+ $val = ($am[2] !== '') ? $am[2] : (($am[3] !== '') ? $am[3] : $am[4]);
+ $attrs[$name] = $val;
+ }
+
+ $src = isset($attrs['src']) ? $attrs['src'] : '';
+ $data_src = isset($attrs['data-src']) ? $attrs['data-src'] : '';
+ $has_datasrc = ($data_src !== '');
+
+ $target_url = $has_datasrc ? $data_src : $src;
+ $rel_path = '';
+ if (preg_match('/\\/image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $target_url, $m_rel)) {
+ $rel_path = urldecode($m_rel[1]);
+ } elseif (preg_match('/^image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $target_url, $m_rel)) {
+ $rel_path = urldecode($m_rel[1]);
+ }
+
+ $clean_rel_path = preg_replace('/\\?.*/', '', $rel_path);
+ $browser_supports_webp = isset($_SERVER['HTTP_ACCEPT']) && (strpos($_SERVER['HTTP_ACCEPT'], 'image/webp') !== false);
+
+ if ($webp_on_fly && $browser_supports_webp && $clean_rel_path) {
+ $ext = strtolower(substr(strrchr($clean_rel_path, '.'), 1));
+ if (in_array($ext, array('jpg', 'jpeg', 'png'))) {
+ $phys_source = DIR_IMAGE . $clean_rel_path;
+ $phys_webp = DIR_IMAGE . preg_replace('/\\.(jpg|jpeg|png)$/i', '.webp', $clean_rel_path);
+ if (file_exists($phys_source)) {
+ if (!file_exists($phys_webp)) {
+ $img_data = @file_get_contents($phys_source);
+ if ($img_data) {
+ $im = @imagecreatefromstring($img_data);
+ if ($im) {
+ $webp_quality = (int)$config->get('module_img_opti_webp_quality');
+ if ($webp_quality <= 0 || $webp_quality > 100) {
+ $webp_quality = 80;
+ }
+ @imagewebp($im, $phys_webp, $webp_quality);
+ @imagedestroy($im);
+ }
+ }
+ }
+ if (file_exists($phys_webp)) {
+ if (isset($attrs['src'])) {
+ $attrs['src'] = preg_replace('/\\.(jpg|jpeg|png)(\\?|$)/i', '.webp$2', $attrs['src']);
+ }
+ if (isset($attrs['data-src'])) {
+ $attrs['data-src'] = preg_replace('/\\.(jpg|jpeg|png)(\\?|$)/i', '.webp$2', $attrs['data-src']);
+ }
+ }
+ }
+ }
+ }
+
+ if ($webp_on_fly && $browser_supports_webp) {
+ foreach (array('srcset', 'data-srcset') as $srcset_attr) {
+ if (isset($attrs[$srcset_attr])) {
+ $parts = explode(',', $attrs[$srcset_attr]);
+ foreach ($parts as &$part) {
+ $part = trim($part);
+ $subparts = preg_split('/\\s+/', $part);
+ if (!empty($subparts[0])) {
+ $s_url = $subparts[0];
+ $s_rel = '';
+ if (preg_match('/\\/image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $s_url, $sm_rel)) {
+ $s_rel = urldecode($sm_rel[1]);
+ } elseif (preg_match('/^image\\/(.*\\.(?:jpg|jpeg|png|webp|svg))/is', $s_url, $sm_rel)) {
+ $s_rel = urldecode($sm_rel[1]);
+ }
+ $s_clean = preg_replace('/\\?.*/', '', $s_rel);
+ if ($s_clean) {
+ $s_ext = strtolower(substr(strrchr($s_clean, '.'), 1));
+ if (in_array($s_ext, array('jpg', 'jpeg', 'png'))) {
+ $s_phys_source = DIR_IMAGE . $s_clean;
+ $s_phys_webp = DIR_IMAGE . preg_replace('/\\.(jpg|jpeg|png)$/i', '.webp', $s_clean);
+ if (file_exists($s_phys_source)) {
+ if (!file_exists($s_phys_webp)) {
+ $img_data = @file_get_contents($s_phys_source);
+ if ($img_data) {
+ $im = @imagecreatefromstring($img_data);
+ if ($im) {
+ $webp_quality = (int)$config->get('module_img_opti_webp_quality');
+ if ($webp_quality <= 0 || $webp_quality > 100) {
+ $webp_quality = 80;
+ }
+ @imagewebp($im, $s_phys_webp, $webp_quality);
+ @imagedestroy($im);
+ }
+ }
+ }
+ if (file_exists($s_phys_webp)) {
+ $subparts[0] = preg_replace('/\\.(jpg|jpeg|png)(\\?|$)/i', '.webp$2', $s_url);
+ }
+ }
+ }
+ }
+ }
+ $part = implode(' ', $subparts);
+ }
+ $attrs[$srcset_attr] = implode(', ', $parts);
+ }
+ }
+ }
+
+ $is_svg = (strtolower(substr(strrchr($clean_rel_path, '.'), 1)) === 'svg');
+
+ if ($lazy_load && $clean_rel_path && is_file(DIR_IMAGE . $clean_rel_path) && !$is_svg) {
+ try {
+ $tiny_thumb = $model_tool_image->resize($clean_rel_path, 20, 20);
+ if ($has_datasrc) {
+ $attrs['src'] = $tiny_thumb;
+ } else {
+ $attrs['data-src'] = isset($attrs['src']) ? $attrs['src'] : '';
+ $attrs['src'] = $tiny_thumb;
+ }
+ if (isset($attrs['srcset'])) {
+ $attrs['data-srcset'] = $attrs['srcset'];
+ unset($attrs['srcset']);
+ }
+ $class = isset($attrs['class']) ? $attrs['class'] . ' img-lazyload' : 'img-lazyload';
+ $attrs['class'] = $class;
+
+ $style = 'filter: blur(10px); transition: filter 0.3s;';
+ if (isset($attrs['style'])) {
+ $attrs['style'] = rtrim(trim($attrs['style']), ';') . '; ' . $style;
+ } else {
+ $attrs['style'] = $style;
+ }
+ } catch (Exception $e) {}
+ }
+
+ if ($broken_fallback) {
+ $fallback_url = '';
+ $req_obj = $registry->get('request');
+ $is_https = isset($req_obj->server['HTTPS']) && ($req_obj->server['HTTPS'] == 'on' || $req_obj->server['HTTPS'] == '1');
+ $server_url = $is_https ? $config->get('config_ssl') : $config->get('config_url');
+ if ($fallback_image && is_file(DIR_IMAGE . $fallback_image)) {
+ $fallback_url = $server_url . 'image/' . $fallback_image;
+ } elseif ($config->get('config_logo') && is_file(DIR_IMAGE . $config->get('config_logo'))) {
+ $fallback_url = $server_url . 'image/' . $config->get('config_logo');
+ } else {
+ $fallback_url = $server_url . 'image/no_image.png';
+ }
+ if ($fallback_url) {
+ $attrs['onerror'] = "this.src='" . $fallback_url . "'; this.srcset=''; this.onerror=null;";
+ }
+ }
+
+ $new_attrs = array();
+ foreach ($attrs as $k => $v) {
+ $new_attrs[] = $k . '="' . htmlspecialchars($v, ENT_QUOTES, 'UTF-8') . '"';
+ }
+ return '
';
+ }, $output);
+
+ if ($lazy_load) {
+ $lazy_script = '
+
+';
+ $output = str_ireplace('', $lazy_script, $output);
+ }
+ }
+ }
+ }
+ echo $output;
+ ]]>
", + $statusClass, + $statusText, + htmlspecialchars('image/catalog/' . $displayPath, ENT_QUOTES | ENT_IGNORE, 'UTF-8'), + $this->formatBytes($oldSize), + $this->formatBytes($newSize), + $isUnchanged ? '' : $compareBtn + ); + } + } else { + $displayPath = $this->safe_utf8($cleanRel); + $json['log'][] = sprintf( + "
",
+ $this->language->get('text_error_process'),
+ htmlspecialchars('image/catalog/' . $displayPath, ENT_QUOTES | ENT_IGNORE, 'UTF-8')
+ );
+ }
+ $processed++;
+ gc_collect_cycles();
+
+ $memLimit = $this->getMemoryLimitInBytesHelper();
+ if (memory_get_usage(true) > 0.85 * $memLimit) {
+ break;
+ }
+ }
+
+ if (!empty($cache_updates)) {
+ $cache = $this->getOptimizedCache();
+ foreach ($cache_updates as $k => $v) {
+ $cache[$k] = $v;
+ }
+ $this->saveOptimizedCache($cache);
+ }
+
+ $json['done'] = $processed;
+ $json['optimized'] = $batchOptimized;
+ $json['batch_old'] = $batchOldSize;
+ $json['batch_new'] = $batchNewSize;
+
+ if (!file_exists($queueFile)) {
+ $json['finished'] = true;
+ }
+
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function applyChanges() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $copyFolder = !empty($this->request->post['copy_folder']) ? preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$this->request->post['copy_folder']) : 'catalog_new';
+ if (empty($copyFolder) || $copyFolder === 'catalog') {
+ $json['error'] = $this->language->get('error_invalid_folder');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $source_dir = realpath(DIR_IMAGE . $copyFolder);
+ $target_dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+
+ if (!$source_dir || strpos($source_dir, $realImageDir) !== 0 || !is_dir($source_dir)) {
+ $json['error'] = $this->language->get('text_error_source');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $backup_name = 'backup_' . date('Ymd_His');
+ $backup_dir = DIR_IMAGE . $backup_name;
+
+ $do_backup = isset($this->request->post['backup']) && (int)$this->request->post['backup'] === 1;
+
+ if ($do_backup) {
+ $this->registerModuleFolder($backup_name);
+ if (!is_dir($backup_dir)) @mkdir($backup_dir, 0755, true);
+ $backup_dir = realpath($backup_dir);
+ }
+
+ $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source_dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
+ $errors = array();
+ $cache_updates = array();
+
+ foreach ($files as $file) {
+ $currentPath = str_replace('\\', '/', $file->getPathname());
+ $relative_path = preg_replace('/^' . preg_quote(str_replace('\\', '/', $source_dir), '/') . '/i', '', $currentPath);
+ $relative_path = ltrim($relative_path, '/');
+
+ $dest_file = rtrim(str_replace('\\', '/', $target_dir), '/') . '/' . $relative_path;
+ $dest_folder = $this->mb_dirname($dest_file);
+
+ if ($file->isDir()) {
+ if (!is_dir($dest_folder)) {
+ if (!@mkdir($dest_folder, 0755, true)) {
+ $errors[] = 'Failed to create directory: ' . $relative_path;
+ }
+ }
+ } else {
+ if (!is_dir($dest_folder)) {
+ @mkdir($dest_folder, 0755, true);
+ }
+
+ $replaced = true;
+ if (file_exists($dest_file)) {
+ if ($do_backup) {
+ $back_file = rtrim(str_replace('\\', '/', $backup_dir), '/') . '/' . $relative_path;
+ if (!is_dir($this->mb_dirname($back_file))) @mkdir($this->mb_dirname($back_file), 0755, true);
+ if (file_exists($back_file)) {
+ @unlink($back_file);
+ }
+ if (!@rename($dest_file, $back_file)) {
+ if (!@copy($dest_file, $back_file)) {
+ $replaced = false;
+ $errors[] = 'Backup failed: ' . $relative_path;
+ } else {
+ @unlink($dest_file);
+ }
+ }
+ } else {
+ if (!@unlink($dest_file)) {
+ $replaced = false;
+ $errors[] = 'Failed to delete original: ' . $relative_path;
+ }
+ }
+ }
+
+ if ($replaced) {
+ $moved = false;
+ if (@rename($file->getPathname(), $dest_file)) {
+ $moved = true;
+ } else {
+ if (@copy($file->getPathname(), $dest_file)) {
+ @unlink($file->getPathname());
+ $moved = true;
+ }
+ }
+ if (!$moved) {
+ $errors[] = 'Failed to apply changes: ' . $relative_path;
+ } else {
+ clearstatcache(true, $dest_file);
+ $cacheKey = $this->getRelativeImagePath($dest_file);
+ $cache_updates[$cacheKey] = array(
+ 'size' => @filesize($dest_file),
+ 'mtime' => @filemtime($dest_file)
+ );
+ }
+ }
+ }
+ }
+
+ if (!empty($cache_updates)) {
+ $cache = $this->getOptimizedCache();
+ foreach ($cache_updates as $k => $v) {
+ $cache[$k] = $v;
+ }
+ $this->saveOptimizedCache($cache);
+ }
+
+ $this->recursiveDelete($source_dir);
+
+ $msg = $this->language->get('text_apply_success');
+ $msg .= $do_backup ? $this->language->get('text_apply_backup_ok') . htmlspecialchars($backup_name, ENT_QUOTES, 'UTF-8') : $this->language->get('text_apply_no_backup');
+
+ if (!empty($errors)) {
+ $json['success'] = $msg . '
' . $this->language->get('text_apply_errors_warning') . '
' . implode('
', array_slice($errors, 0, 10)) . (count($errors) > 10 ? '
...' : '') . '';
+ } else {
+ $json['success'] = $msg;
+ }
+
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function resetCache() {
+ $this->loadLanguageSafe();
+ $json = array();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+ $cache_file = DIR_CACHE . 'img_opti_optimized.json';
+ if (file_exists($cache_file)) {
+ @unlink($cache_file);
+ }
+ $json['success'] = $this->translate('text_cache_cleared', 'Optimization cache cleared. Next scan will check all files.');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function clearTrash() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $query = $this->db->query("SELECT `value` FROM `" . DB_PREFIX . "setting` WHERE `code` = 'module_img_opti' AND `key` = 'module_img_opti_folders'");
+ $folders = ($query->num_rows && $query->row['value']) ? json_decode($query->row['value'], true) : array();
+
+ $folders[] = 'catalog_trash';
+ $folders = array_unique($folders);
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($folders as $folder) {
+ $folder = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$folder);
+ if (empty($folder) || $folder === 'catalog') continue;
+
+ $path = realpath(DIR_IMAGE . $folder);
+ if ($path && strpos($path, $realImageDir) === 0 && is_dir($path)) {
+ $this->recursiveDelete($path);
+ }
+ }
+
+ $compare_path = realpath(DIR_IMAGE . 'cache/compare');
+ if ($compare_path && strpos($compare_path, $realImageDir) === 0 && is_dir($compare_path)) {
+ $this->recursiveDelete($compare_path);
+ }
+ $cache_file = DIR_CACHE . 'img_opti_optimized.json';
+ if (file_exists($cache_file)) {
+ @unlink($cache_file);
+ }
+ $this->db->query("DELETE FROM `" . DB_PREFIX . "setting` WHERE `code` = 'module_img_opti' AND `key` = 'module_img_opti_folders'");
+
+ $json['success'] = $this->language->get('text_quarantine_empty');
+
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function clear_backups() {
+ $this->loadLanguageSafe();
+ $json = array();
+
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $backup_dir = realpath(DIR_IMAGE . 'catalog_trash/backup');
+ $realImageDir = realpath(DIR_IMAGE);
+
+ if ($backup_dir && strpos($backup_dir, $realImageDir) === 0 && is_dir($backup_dir)) {
+ $this->recursiveDelete($backup_dir);
+ $json['success'] = $this->language->get('text_backups_cleared');
+ } else {
+ $json['error'] = $this->language->get('error_no_backups');
+ }
+
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function download_folder() {
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ return;
+ }
+
+ $folder = isset($this->request->get['folder']) ? preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$this->request->get['folder']) : '';
+ if (empty($folder) || $folder === 'catalog') {
+ return;
+ }
+
+ $dir_path = realpath(DIR_IMAGE . $folder);
+ $realImageDir = realpath(DIR_IMAGE);
+
+ if (!$dir_path || strpos($dir_path, $realImageDir) !== 0 || !is_dir($dir_path)) {
+ return;
+ }
+
+ $zip_file = DIR_CACHE . $folder . '_' . date('Ymd_His') . '.zip';
+
+ $zip = new ZipArchive();
+ if ($zip->open($zip_file, ZipArchive::CREATE | ZipArchive::OVERWRITE) === TRUE) {
+ $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir_path, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
+ foreach ($files as $file) {
+ if ($file->isFile()) {
+ $filePath = $file->getPathname();
+ $relativePath = substr($filePath, strlen($dir_path) + 1);
+ $zip->addFile($filePath, $relativePath);
+ }
+ }
+ $zip->close();
+ }
+
+ if (file_exists($zip_file)) {
+ header('Content-Type: application/zip');
+ header('Content-Disposition: attachment; filename="' . $this->mb_basename($zip_file) . '"');
+ header('Content-Length: ' . filesize($zip_file));
+ readfile($zip_file);
+ @unlink($zip_file);
+ exit;
+ }
+ }
+
+ public function compare_preview() {
+ $this->loadLanguageSafe();
+ $json = array();
+
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $id = isset($this->request->post['id']) ? str_replace(array('../', '..\\', "\0"), '', (string)$this->request->post['id']) : '';
+ $tool = isset($this->request->post['tool']) ? preg_replace('/[^a-zA-Z0-9_]/', '', (string)$this->request->post['tool']) : '';
+
+ $this->load->model('module/img_opti');
+ $json = $this->model_module_img_opti->generateCompareUrls($tool, $id);
+
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ private function registerModuleFolder($folder_name) {
+ $folder_name = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', $folder_name);
+ if (empty($folder_name)) return;
+
+ $query = $this->db->query("SELECT `value` FROM `" . DB_PREFIX . "setting` WHERE `code` = 'module_img_opti' AND `key` = 'module_img_opti_folders'");
+ $folders = ($query->num_rows && $query->row['value']) ? json_decode($query->row['value'], true) : array();
+
+ if (!in_array($folder_name, $folders)) {
+ $folders[] = $folder_name;
+ $this->db->query("DELETE FROM `" . DB_PREFIX . "setting` WHERE `code` = 'module_img_opti' AND `key` = 'module_img_opti_folders'");
+ $this->db->query("INSERT INTO `" . DB_PREFIX . "setting` SET `store_id` = '0', `code` = 'module_img_opti', `key` = 'module_img_opti_folders', `value` = '" . $this->db->escape(json_encode($folders)) . "'");
+ }
+ }
+
+ private function recursiveDelete($dir) {
+ $realDir = realpath($dir);
+ if (!$realDir || strpos($realDir, realpath(DIR_IMAGE)) !== 0 || !is_dir($realDir)) return false;
+
+ $files = array_diff(scandir($realDir), array('.','..'));
+ foreach ($files as $file) {
+ $path = $realDir . DIRECTORY_SEPARATOR . $file;
+ (is_dir($path)) ? $this->recursiveDelete($path) : @unlink($path);
+ }
+ return @rmdir($realDir);
+ }
+
+ private function safe_utf8($str) {
+ $str = (string)$str;
+ if (mb_check_encoding($str, 'UTF-8')) {
+ return $str;
+ }
+ $converted = @mb_convert_encoding($str, 'UTF-8', 'Windows-1251');
+ return $converted ? $converted : $str;
+ }
+
+ private function scanDirectoryHelper($selected_items) {
+ $allFiles = array();
+ $scanRootFiles = false;
+ $dirImageNorm = rtrim(str_replace('\\', '/', DIR_IMAGE), '/');
+ $cache = $this->getOptimizedCache();
+
+ if (empty($selected_items) || in_array('all', (array)$selected_items)) {
+ $dirsToScan = array($dirImageNorm . '/catalog');
+ } else {
+ $dirsToScan = array();
+ if (in_array('root_files', (array)$selected_items)) {
+ $scanRootFiles = true;
+ }
+ foreach ((array)$selected_items as $item) {
+ if ($item === 'root_files' || $item === 'all') continue;
+
+ $clean_item = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$item);
+ $clean_item = str_replace(array('../', '..\\'), '', $clean_item);
+
+ $fullPath = realpath($dirImageNorm . '/' . ltrim($clean_item, '/'));
+ if ($fullPath && strpos(str_replace('\\', '/', $fullPath), $dirImageNorm) === 0 && is_dir($fullPath)) {
+ $dirsToScan[] = str_replace('\\', '/', $fullPath);
+ }
+ }
+ }
+
+ if (!empty($dirsToScan)) {
+ foreach ($dirsToScan as $dir) {
+ if (!is_dir($dir)) continue;
+ try {
+ $directory = new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS);
+ $iterator = new RecursiveIteratorIterator($directory);
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $ext = mb_strtolower($file->getExtension(), 'UTF-8');
+ if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp', 'gif'))) {
+ $filePathNorm = str_replace('\\', '/', $file->getPathname());
+ $relPath = $this->getRelativeImagePath($filePathNorm);
+ $skip = false;
+ if (isset($cache[$relPath])) {
+ clearstatcache(true, $filePathNorm);
+ $size = @filesize($filePathNorm);
+ $mtime = @filemtime($filePathNorm);
+ if ($size === $cache[$relPath]['size'] && $mtime === $cache[$relPath]['mtime']) {
+ $skip = true;
+ }
+ }
+ if (!$skip) {
+ $allFiles[] = $filePathNorm;
+ }
+ }
+ }
+ }
+ } catch (Exception $e) {
+ continue;
+ }
+ }
+ }
+
+ if ($scanRootFiles) {
+ $base_dir = $dirImageNorm . '/catalog/';
+ if (is_dir($base_dir)) {
+ $items = scandir($base_dir);
+ foreach ($items as $item) {
+ if ($item == '.' || $item == '..') continue;
+ $fullPath = $base_dir . $item;
+ if (is_file($fullPath)) {
+ $ext = mb_strtolower($this->mb_pathinfo($fullPath, PATHINFO_EXTENSION), 'UTF-8');
+ if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp', 'gif'))) {
+ $filePathNorm = str_replace('\\', '/', $fullPath);
+ $relPath = $this->getRelativeImagePath($filePathNorm);
+ $skip = false;
+ if (isset($cache[$relPath])) {
+ clearstatcache(true, $filePathNorm);
+ $size = @filesize($filePathNorm);
+ $mtime = @filemtime($filePathNorm);
+ if ($size === $cache[$relPath]['size'] && $mtime === $cache[$relPath]['mtime']) {
+ $skip = true;
+ }
+ }
+ if (!$skip) {
+ $allFiles[] = $filePathNorm;
+ }
+ }
+ }
+ }
+ }
+ }
+ return array_unique($allFiles);
+ }
+
+ private function optimizeImage($src, $dest, $maxWidth, $maxHeight, $jpgQuality, $pngCompression, $webpQuality, $engine = 'gd') {
+ if ($engine == 'imagick' && extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($src);
+ $img->transformImageColorspace(Imagick::COLORSPACE_SRGB);
+
+ $width = $img->getImageWidth();
+ $height = $img->getImageHeight();
+ $ext = strtolower($this->mb_pathinfo($src, PATHINFO_EXTENSION));
+
+ if (($maxWidth > 0 && $width > $maxWidth) || ($maxHeight > 0 && $height > $maxHeight)) {
+ $targetW = $maxWidth;
+ $targetH = $maxHeight;
+ if ($targetW == 0) {
+ $targetW = (int)round($width * ($targetH / $height));
+ }
+ if ($targetH == 0) {
+ $targetH = (int)round($height * ($targetW / $width));
+ }
+ $img->resizeImage($targetW, $targetH, Imagick::FILTER_LANCZOS, 1, true);
+ }
+
+ $img->stripImage();
+
+ if ($ext === 'png') {
+ $img->setImageFormat('png');
+ $img->setCompressionQuality($pngCompression * 10);
+ } elseif ($ext === 'webp') {
+ $img->setImageFormat('webp');
+ $img->setCompressionQuality($webpQuality);
+ } else {
+ $img->setImageFormat('jpeg');
+ $img->setImageCompression(Imagick::COMPRESSION_JPEG);
+ $img->setCompressionQuality($jpgQuality);
+ }
+
+ return $img->writeImage($dest);
+ } catch (Exception $e) {
+ $engine = 'gd';
+ }
+ }
+
+ if ($engine == 'gd') {
+ $img = @imagecreatefromstring(file_get_contents($src));
+ if (!$img) return false;
+
+ $width = imagesx($img);
+ $height = imagesy($img);
+ $ext = strtolower($this->mb_pathinfo($src, PATHINFO_EXTENSION));
+
+ if ($ext === 'png' || $ext === 'webp') {
+ imagealphablending($img, false);
+ imagesavealpha($img, true);
+ }
+
+ if (($maxWidth > 0 && $width > $maxWidth) || ($maxHeight > 0 && $height > $maxHeight)) {
+ $ratioX = ($maxWidth > 0) ? ($maxWidth / $width) : 1;
+ $ratioY = ($maxHeight > 0) ? ($maxHeight / $height) : 1;
+ $ratio = min($ratioX, $ratioY);
+
+ $newW = (int)($width * $ratio);
+ $newH = (int)($height * $ratio);
+
+ $newImg = imagecreatetruecolor($newW, $newH);
+
+ if ($ext === 'png' || $ext === 'webp') {
+ imagealphablending($newImg, false);
+ imagesavealpha($newImg, true);
+ $transparent = imagecolorallocatealpha($newImg, 255, 255, 255, 127);
+ imagefill($newImg, 0, 0, $transparent);
+ } else {
+ $white = imagecolorallocate($newImg, 255, 255, 255);
+ imagefill($newImg, 0, 0, $white);
+ }
+
+ imagecopyresampled($newImg, $img, 0, 0, 0, 0, $newW, $newH, $width, $height);
+ imagedestroy($img);
+ $img = $newImg;
+ }
+
+ $res = false;
+ if ($ext === 'png') {
+ $res = imagepng($img, $dest, $pngCompression);
+ } elseif ($ext === 'webp' && function_exists('imagewebp')) {
+ $res = imagewebp($img, $dest, $webpQuality);
+ } else {
+ $res = imagejpeg($img, $dest, $jpgQuality);
+ }
+
+ imagedestroy($img);
+ return $res;
+ }
+ return false;
+ }
+
+ private function formatBytes($bytes) {
+ if ($bytes >= 1048576) return round($bytes / 1048576, 2) . ' MB';
+ if ($bytes >= 1024) return round($bytes / 1024, 0) . ' KB';
+ return $bytes . ' B';
+ }
+
+ public function stop() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $queueFile = DIR_CACHE . 'img_opti_queue.json';
+ if (file_exists($queueFile)) @unlink($queueFile);
+
+ $json = array('success' => true);
+ $this->response->setOutput(json_encode($json));
+ }
+
+ private function checkLicense($key) {
+ $host = explode(':', str_replace(array('http://', 'https://', 'www.'), '', strtolower($this->request->server['HTTP_HOST'])))[0];
+ if ($host === 'localhost' || $host === '127.0.0.1') return true;
+ if (empty($key)) return false;
+ $parts = explode('.', $host);
+ $v_k = 'k'; $v_to = 'to'; $v_bit = '_lom'; $v_end = 'aet_'; $v_img = 'image_'; $v_opt = 'optimizer_'; $v_mid = 'tot'; $v_core = '_redis'; $v_sfx = 'ka';
+ $salt = ($v_k . $v_to . $v_bit) . ($v_end . $v_img . $v_opt . $v_mid) . ($v_core . $v_sfx);
+ while(count($parts) >= 2) {
+ $domain = implode('.', $parts);
+ if ($key === strtoupper(implode('-', str_split(substr(md5($domain . $salt), 0, 16), 4)))) return true;
+ array_shift($parts);
+ }
+ return false;
+ }
+
+ public function run_tool() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ if (!$this->checkLicense($this->getToken())) {
+ $json['error'] = $this->language->get('error_license') . $this->language->get('error_domain') . $_SERVER['HTTP_HOST'] . ')';
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ session_write_close();
+
+ $tool = isset($this->request->post['tool']) ? preg_replace('/[^a-zA-Z0-9_]/', '', (string)$this->request->post['tool']) : '';
+ $action = isset($this->request->post['action']) ? preg_replace('/[^a-zA-Z0-9_]/', '', (string)$this->request->post['action']) : 'scan';
+
+ $this->load->model('module/img_opti');
+
+ $method = $action . '_' . $tool;
+ if ($tool === 'broken') {
+ $method = $action . '_broken_db';
+ }
+
+ $allowed_methods = array(
+ 'scan_broken_db', 'fix_broken_db',
+ 'scan_broken_files', 'fix_broken_files',
+ 'scan_duplicates', 'fix_duplicates',
+ 'scan_png_jpg', 'fix_png_jpg',
+ 'scan_translit', 'fix_translit',
+ 'scan_empty_folders', 'fix_empty_folders',
+ 'scan_smart_cache', 'fix_smart_cache',
+ 'scan_cache_restore', 'fix_cache_restore',
+ 'scan_exif', 'fix_exif',
+ 'scan_placeholders', 'fix_placeholders',
+ 'scan_watermark',
+ 'scan_small_photos', 'fix_small_photos',
+ 'scan_html_broken',
+ 'scan_folder_tree', 'fix_folder_tree',
+ 'scan_sanitizer', 'fix_sanitizer',
+ 'scan_cmyk', 'fix_cmyk',
+ 'scan_heavy_files', 'fix_heavy_files'
+ );
+
+ if (in_array($method, $allowed_methods)) {
+ $json = $this->model_module_img_opti->{$method}($this->request->post);
+ } else {
+ $json['error'] = 'Tool method not found: ' . $method;
+ }
+
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function upload_font() {
+ $this->loadLanguageSafe();
+ $json = array();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+ if (!empty($this->request->files['file']['name']) && is_file($this->request->files['file']['tmp_name'])) {
+ $filename = $this->mb_basename(html_entity_decode($this->request->files['file']['name'], ENT_QUOTES, 'UTF-8'));
+ $filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename);
+ $extension = strtolower($this->mb_pathinfo($filename, PATHINFO_EXTENSION));
+ if ($extension === 'ttf' && $this->request->files['file']['size'] < 5242880) {
+ $dir = DIR_SYSTEM . 'library/font/';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0755, true);
+ }
+ if (move_uploaded_file($this->request->files['file']['tmp_name'], $dir . $filename)) {
+ $json['success'] = $this->language->get('text_success_font_upload');
+ $json['font'] = array(
+ 'name' => $filename,
+ 'path' => $dir . $filename
+ );
+ } else {
+ $json['error'] = $this->language->get('error_font_upload');
+ }
+ } else {
+ $json['error'] = $this->language->get('error_font_upload');
+ }
+ } else {
+ $json['error'] = $this->language->get('error_font_upload');
+ }
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function scan_broken() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ if (!$this->checkLicense($this->getToken())) {
+ $json['error'] = $this->language->get('error_license') . $this->language->get('error_domain') . $_SERVER['HTTP_HOST'] . ')';
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $this->load->model('module/img_opti');
+
+ $data = array(
+ 'start' => isset($this->request->post['start']) ? (int)$this->request->post['start'] : 0,
+ 'limit' => isset($this->request->post['limit']) ? (int)$this->request->post['limit'] : 200
+ );
+
+ $json = $this->model_module_img_opti->scan_broken_db($data);
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function fix_broken() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ if (!$this->checkLicense($this->getToken())) {
+ $json['error'] = $this->language->get('error_license') . $this->language->get('error_domain') . $_SERVER['HTTP_HOST'] . ')';
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $this->load->model('module/img_opti');
+
+ $data = array(
+ 'ids' => isset($this->request->post['ids']) && is_array($this->request->post['ids']) ? array_map(function($v) { return preg_replace('/[^a-zA-Z0-9_-]/', '', (string)$v); }, $this->request->post['ids']) : array(),
+ 'fix_all' => isset($this->request->post['fix_all']) ? (int)$this->request->post['fix_all'] : 0
+ );
+
+ $this->model_module_img_opti->fix_broken_db($data);
+ $json['success'] = true;
+
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function build_index() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $sysToken = isset($this->request->post['module_token']) ? trim((string)$this->request->post['module_token']) : '';
+ if (!$this->checkLicense($sysToken)) {
+ $json['error'] = $this->language->get('error_token');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $whitelist = array();
+ $tables = $this->db->query("SHOW TABLES");
+
+ foreach ($tables->rows as $table_row) {
+ $table = array_values($table_row)[0];
+ $columns = $this->db->query("SHOW COLUMNS FROM `" . $this->db->escape((string)$table) . "`");
+ $text_cols = array();
+
+ foreach ($columns->rows as $col) {
+ $type = strtolower($col['Type']);
+ if (strpos($type, 'char') !== false || strpos($type, 'text') !== false) {
+ $text_cols[] = $col['Field'];
+ }
+ }
+
+ if (!empty($text_cols)) {
+ foreach ($text_cols as $col) {
+ $query = $this->db->query("SELECT `" . $this->db->escape((string)$col) . "` FROM `" . $this->db->escape((string)$table) . "` WHERE `" . $this->db->escape((string)$col) . "` LIKE '%catalog%'");
+
+ foreach ($query->rows as $row) {
+ $val = $row[$col];
+
+ $val = stripslashes((string)$val);
+ $val = html_entity_decode($val, ENT_QUOTES, 'UTF-8');
+ $val = urldecode($val);
+
+ if (preg_match_all('/(?:image\/)?(catalog\/[^\'"<>\\*|?\n\r\t]+?\.(?:jpg|jpeg|png|webp|gif|svg))/iu', $val, $matches)) {
+ foreach ($matches[1] as $match) {
+ $cleanPath = mb_strtolower(trim(str_replace('\\', '/', $match)), 'UTF-8');
+ $cleanPath = preg_replace('/^catalog\//i', '', $cleanPath);
+ $cleanPath = ltrim($cleanPath, '/');
+
+ if (!isset($whitelist[$cleanPath])) {
+ $whitelist[$cleanPath] = array();
+ }
+
+ $source = 'DB: ' . $table;
+ if (!in_array($source, $whitelist[$cleanPath])) {
+ $whitelist[$cleanPath][] = $source;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ $theme_dir = realpath(DIR_CATALOG . 'view/theme/');
+ if ($theme_dir && is_dir($theme_dir)) {
+ $directory = new RecursiveDirectoryIterator($theme_dir, RecursiveDirectoryIterator::SKIP_DOTS);
+ $iterator = new RecursiveIteratorIterator($directory);
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $ext = strtolower($file->getExtension());
+ if (in_array($ext, array('twig', 'tpl', 'css', 'php', 'js'))) {
+ $content = file_get_contents($file->getRealPath());
+
+ $content = urldecode($content);
+
+ if (preg_match_all('/(?:image\/)?(catalog\/[^\'"<>\\*|?\n\r\t]+?\.(?:jpg|jpeg|png|webp|gif|svg))/iu', $content, $matches)) {
+ foreach ($matches[1] as $match) {
+ $cleanPath = mb_strtolower(trim(str_replace('\\', '/', $match)), 'UTF-8');
+ $cleanPath = preg_replace('/^catalog\//i', '', $cleanPath);
+ $cleanPath = ltrim($cleanPath, '/');
+
+ if (!isset($whitelist[$cleanPath])) {
+ $whitelist[$cleanPath] = array();
+ }
+
+ $source = 'Theme: ' . $this->mb_basename($file->getFilename());
+ if (!in_array($source, $whitelist[$cleanPath])) {
+ $whitelist[$cleanPath][] = $source;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_whitelist.json', json_encode($whitelist, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR));
+
+ $json['success'] = true;
+ $this->response->setOutput(json_encode($json));
+ }
+
+ public function scan_trash() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array('log' => array(), 'total' => 0, 'total_bytes' => 0, 'scanned_folders' => '');
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ if (!$this->checkLicense($this->getToken())) {
+ $json['error'] = $this->language->get('error_license') . $this->language->get('error_domain') . $_SERVER['HTTP_HOST'] . ')';
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ $extendedLog = isset($this->request->post['extended_log']) ? true : false;
+ $processBroken = isset($this->request->post['process_broken']) ? true : false;
+
+ $whitelist_file = DIR_CACHE . 'img_opti_whitelist.json';
+ $whitelist = file_exists($whitelist_file) ? json_decode(file_get_contents($whitelist_file), true) : array();
+
+ $selected_items = isset($this->request->post['clean_items']) ? (array)$this->request->post['clean_items'] : array();
+
+ if (empty($selected_items) || in_array('all', $selected_items)) {
+ $json['scanned_folders'] = $this->language->get('text_all_folders');
+ } else {
+ $folders_str = array();
+ foreach ($selected_items as $si) {
+ if ($si === 'root_files') {
+ $folders_str[] = $this->language->get('text_root_folder');
+ } else {
+ $clean_si = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$si);
+ $folders_str[] = 'image/' . str_replace(array('../', '..\\'), '', $clean_si);
+ }
+ }
+ $json['scanned_folders'] = implode(', ', $folders_str);
+ }
+
+ $physicalFiles = $this->scanDirectoryHelper($selected_items);
+ $trashFiles = array();
+ $dirImageNorm = rtrim(str_replace('\\', '/', DIR_IMAGE), '/') . '/';
+
+ foreach ($physicalFiles as $path) {
+ $realPath = realpath($path);
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), $dirImageNorm) !== 0) continue;
+
+ $pathNorm = str_replace('\\', '/', $realPath);
+ $relPath = preg_replace('/^' . preg_quote($dirImageNorm, '/') . '/i', '', $pathNorm);
+
+ $cleanRelOrig = preg_replace('/^catalog\//i', '', ltrim(trim($relPath), '/'));
+ $cleanRelOrig = ltrim($cleanRelOrig, '/');
+
+ $cleanRelLower = mb_strtolower($cleanRelOrig, 'UTF-8');
+
+ if (strpos($cleanRelLower, 'catalog_trash/') === 0) continue;
+
+ $displayPath = $this->safe_utf8($cleanRelOrig);
+
+ if (!$processBroken && (strpos($displayPath, '?') !== false || strpos($cleanRelOrig, '?') !== false)) {
+ continue;
+ }
+
+ if (!isset($whitelist[$cleanRelLower])) {
+ $trashFiles[] = array(
+ 'path_full' => $realPath,
+ 'path_clean' => $cleanRelOrig,
+ 'path_display' => $displayPath
+ );
+
+ $json['total_bytes'] += filesize($realPath);
+ $json['log'][] = "
"; + } else { + if ($extendedLog) { + $sources = implode(', ', $whitelist[$cleanRelLower]); + $json['log'][] = "
"; + } + } + } + + file_put_contents(DIR_CACHE . 'img_opti_trash_queue.json', json_encode($trashFiles, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR)); + $json['total'] = count($trashFiles); + $this->response->setOutput(json_encode($json)); + } + + public function process_trash() { + $this->loadLanguageSafe(); + if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) { + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission')))); + return; + } + + $json = array('log' => array(), 'finished' => false); + + if (!$this->user->hasPermission('modify', 'module/img_opti')) { + $json['error'] = $this->language->get('error_permission'); + $this->response->setOutput(json_encode($json)); + return; + } + + if (!$this->checkLicense($this->getToken())) { + $json['error'] = $this->language->get('error_license') . $this->language->get('error_domain') . $_SERVER['HTTP_HOST'] . ')'; + $this->response->setOutput(json_encode($json)); + return; + } + + $queueFile = DIR_CACHE . 'img_opti_trash_queue.json'; + if (!file_exists($queueFile)) { + $json['finished'] = true; + $this->response->setOutput(json_encode($json)); + return; + } + + $queue = json_decode(file_get_contents($queueFile), true); + if (empty($queue)) { + @unlink($queueFile); + $json['finished'] = true; + $this->response->setOutput(json_encode($json)); + return; + } + + $action = isset($this->request->post['action']) ? preg_replace('/[^a-z]/', '', (string)$this->request->post['action']) : 'quarantine'; + $batchSize = 200; + $processed = 0; + + $dirImageNorm = rtrim(str_replace('\\', '/', DIR_IMAGE), '/') . '/'; + + while ($processed < $batchSize && !empty($queue)) { + $item = array_shift($queue); + $filePath = $item['path_full']; + $displayPath = $item['path_display']; + + $realFilePath = realpath($filePath); + + if ($realFilePath && strpos(str_replace('\\', '/', $realFilePath), $dirImageNorm) === 0 && is_file($realFilePath)) { + if ($action === 'delete') { + if (@unlink($realFilePath)) { + $json['log'][] = "
"; + } else { + $json['log'][] = "
"; + } + } else { + $cleanRel = str_replace(array('../', '..\\', "\0"), '', (string)$item['path_clean']); + + $destPath = DIR_IMAGE . 'catalog_trash/' . ltrim($cleanRel, '/'); + $destDir = $this->mb_dirname($destPath); + + if (!is_dir($destDir)) { + @mkdir($destDir, 0755, true); + } + + if (file_exists($destPath)) { + @unlink($destPath); + } + $rename_ok = false; + if (@rename($realFilePath, $destPath)) { + $rename_ok = true; + } else { + if (@copy($realFilePath, $destPath)) { + @unlink($realFilePath); + $rename_ok = true; + } + } + if ($rename_ok) { + $json['log'][] = "
"; + } else { + $json['log'][] = "
"; + } + } + } + $processed++; + } + + file_put_contents($queueFile, json_encode($queue, JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR)); + if (empty($queue)) { + @unlink($queueFile); + $json['finished'] = true; + } + $this->response->setOutput(json_encode($json)); + } + + public function restore_quarantine() { + $this->loadLanguageSafe(); + if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) { + $this->response->addHeader('Content-Type: application/json'); + $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission')))); + return; + } + + $json = array('log' => array(), 'success' => true); + + if (!$this->user->hasPermission('modify', 'module/img_opti')) { + $json['error'] = $this->language->get('error_permission'); + $this->response->setOutput(json_encode($json)); + return; + } + + if (!$this->checkLicense($this->getToken())) { + $json['error'] = $this->language->get('error_license') . $this->language->get('error_domain') . $_SERVER['HTTP_HOST'] . ')'; + $this->response->setOutput(json_encode($json)); + return; + } + + $trash_dir = realpath(DIR_IMAGE . 'catalog_trash'); + $realImageDir = realpath(DIR_IMAGE); + + if ($trash_dir && strpos($trash_dir, $realImageDir) === 0 && is_dir($trash_dir)) { + $directory = new RecursiveDirectoryIterator($trash_dir, RecursiveDirectoryIterator::SKIP_DOTS); + $iterator = new RecursiveIteratorIterator($directory); + $trash_dir_norm = str_replace('\\', '/', $trash_dir) . '/'; + + foreach ($iterator as $file) { + if ($file->isFile()) { + $src = str_replace('\\', '/', $file->getPathname()); + $rel = preg_replace('/^' . preg_quote($trash_dir_norm, '/') . '/i', '', $src); + $cleanRel = str_replace(array('../', '..\\', "\0"), '', ltrim($rel, '/')); + $tools = array('duplicates', 'broken_files', 'small_photos', 'sanitizer', 'translit', 'png_jpg', 'smart_cache', 'exif'); + $matched_tool = false; + foreach ($tools as $t) { + if (strpos($cleanRel, $t . '/') === 0) { + $exactRel = substr($cleanRel, strlen($t . '/')); + $dest = rtrim(str_replace('\\', '/', DIR_IMAGE), '/') . '/' . $exactRel; + $matched_tool = true; + break; + } + } + if (!$matched_tool) { + $dest = rtrim(str_replace('\\', '/', DIR_IMAGE), '/') . '/catalog/' . $cleanRel; + } + $displayPath = $this->safe_utf8($cleanRel); + $destDir = $this->mb_dirname($dest); + if (!is_dir($destDir)) { + @mkdir($destDir, 0755, true); + } + if (file_exists($dest)) { + @unlink($dest); + } + $rename_ok = false; + if (@rename($src, $dest)) { + $rename_ok = true; + } else { + if (@copy($src, $dest)) { + @unlink($src); + $rename_ok = true; + } + } + if ($rename_ok) { + $json['log'][] = "
"; + } else { + $json['log'][] = "
"; + } + } + } + $this->recursiveDelete($trash_dir); + } else { + $json['log'][] = "
";
+ }
+
+ $this->response->setOutput(json_encode($json));
+ }
+
+ private function getImageParameters($relativePath, $globalSettings) {
+ $rules = isset($globalSettings['module_img_opti_folder_rules']) ? (array)$globalSettings['module_img_opti_folder_rules'] : array();
+
+ $jpgQ = isset($globalSettings['module_img_opti_jpg_quality']) ? (int)$globalSettings['module_img_opti_jpg_quality'] : 72;
+ $pngQ = isset($globalSettings['module_img_opti_png_quality']) ? (int)$globalSettings['module_img_opti_png_quality'] : 7;
+ $webpQ = isset($globalSettings['module_img_opti_webp_quality']) ? (int)$globalSettings['module_img_opti_webp_quality'] : 80;
+ $maxW = isset($globalSettings['module_img_opti_max_width']) ? (int)$globalSettings['module_img_opti_max_width'] : 1600;
+ $maxH = isset($globalSettings['module_img_opti_max_height']) ? (int)$globalSettings['module_img_opti_max_height'] : 1600;
+
+ $relativePath = str_replace('\\', '/', $relativePath);
+ $relativePath = ltrim($relativePath, '/');
+
+ foreach ($rules as $rule) {
+ if (empty($rule['folder'])) continue;
+ $folder = str_replace('\\', '/', $rule['folder']);
+ $folder = trim($folder, '/');
+
+ if ($folder !== '' && strpos($relativePath, $folder) !== false) {
+ if (isset($rule['jpg_quality']) && $rule['jpg_quality'] !== '') {
+ $jpgQ = (int)$rule['jpg_quality'];
+ }
+ if (isset($rule['webp_quality']) && $rule['webp_quality'] !== '') {
+ $webpQ = (int)$rule['webp_quality'];
+ }
+ if (isset($rule['max_width']) && $rule['max_width'] !== '') {
+ $maxW = (int)$rule['max_width'];
+ }
+ if (isset($rule['max_height']) && $rule['max_height'] !== '') {
+ $maxH = (int)$rule['max_height'];
+ }
+ break;
+ }
+ }
+
+ return array(
+ 'jpg_quality' => $jpgQ,
+ 'png_quality' => $pngQ,
+ 'webp_quality' => $webpQ,
+ 'max_width' => $maxW,
+ 'max_height' => $maxH
+ );
+ }
+
+ private function getMemoryLimitInBytesHelper() {
+ $limit = ini_get('memory_limit');
+ if (!$limit || (int)$limit === -1) return 512 * 1024 * 1024;
+ $unit = strtolower(substr($limit, -1));
+ $bytes = (int)$limit;
+ switch ($unit) {
+ case 'g': $bytes *= 1024;
+ case 'm': $bytes *= 1024;
+ case 'k': $bytes *= 1024;
+ }
+ return $bytes;
+ }
+
+ public function preview_watermark() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || !isset($this->session->data['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+
+ $json = array();
+
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $json['error'] = $this->language->get('error_permission');
+ $this->response->setOutput(json_encode($json));
+ return;
+ }
+
+ session_write_close();
+
+ $test_image = DIR_IMAGE . 'no_image.png';
+ if (!is_file($test_image)) {
+ $test_image = DIR_IMAGE . 'catalog/no_image.png';
+ }
+ if (!is_file($test_image)) {
+ $test_image = DIR_CACHE . 'temp_wm_test.png';
+ $im = imagecreatetruecolor(500, 500);
+ $white = imagecolorallocate($im, 240, 240, 240);
+ imagefill($im, 0, 0, $white);
+ imagepng($im, $test_image);
+ imagedestroy($im);
+ }
+
+ $temp_preview = DIR_IMAGE . 'cache/temp_wm_preview.png';
+ $compare_dir = DIR_IMAGE . 'cache/';
+ if (!is_dir($compare_dir)) {
+ @mkdir($compare_dir, 0755, true);
+ }
+
+ @copy($test_image, $temp_preview);
+
+ $this->config->set('module_img_opti_wm_type', $this->request->post['module_img_opti_wm_type']);
+ $this->config->set('module_img_opti_wm_image', $this->request->post['module_img_opti_wm_image']);
+ $this->config->set('module_img_opti_wm_text_val', $this->request->post['module_img_opti_wm_text_val']);
+ $this->config->set('module_img_opti_wm_position', $this->request->post['module_img_opti_wm_position']);
+ $this->config->set('module_img_opti_wm_opacity', $this->request->post['module_img_opti_wm_opacity']);
+ $this->config->set('module_img_opti_wm_angle', isset($this->request->post['module_img_opti_wm_angle']) ? $this->request->post['module_img_opti_wm_angle'] : 0);
+ $this->config->set('module_img_opti_wm_corner_radius', isset($this->request->post['module_img_opti_wm_corner_radius']) ? $this->request->post['module_img_opti_wm_corner_radius'] : 0);
+ $this->config->set('module_img_opti_wm_size_type', isset($this->request->post['module_img_opti_wm_size_type']) ? $this->request->post['module_img_opti_wm_size_type'] : 'original');
+ $this->config->set('module_img_opti_wm_size_percent', isset($this->request->post['module_img_opti_wm_size_percent']) ? $this->request->post['module_img_opti_wm_size_percent'] : 20);
+ $this->config->set('module_img_opti_wm_text_color', isset($this->request->post['module_img_opti_wm_text_color']) ? $this->request->post['module_img_opti_wm_text_color'] : '#ffffff');
+ $this->config->set('module_img_opti_wm_text_font', isset($this->request->post['module_img_opti_wm_text_font']) ? $this->request->post['module_img_opti_wm_text_font'] : '');
+
+ $this->config->set('module_img_opti_wm_target_product', 1);
+ $this->config->set('module_img_opti_wm_target_category', 0);
+ $this->config->set('module_img_opti_wm_target_brand', 0);
+ $this->config->set('module_img_opti_wm_target_banner', 0);
+ $this->config->set('module_img_opti_wm_target_blog', 0);
+
+ $this->load->model('module/img_opti');
+ $res = $this->model_module_img_opti->applyDynamicWatermark($temp_preview, '', true);
+
+ if ($res) {
+ $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '');
+ $json['success'] = true;
+ $json['preview_url'] = $catalog_url . 'image/cache/temp_wm_preview.png?t=' . time();
+ } else {
+ $json['error'] = $this->language->get('error_wm_preview') ?: 'Failed to generate preview';
+ }
+
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ private function getFoldersTree($dir, $base = '') {
+ $folders = array();
+ $realDir = realpath($dir);
+ if (!$realDir || !is_dir($realDir)) return $folders;
+ $items = array_diff(scandir($realDir), array('.', '..'));
+ foreach ($items as $item) {
+ $path = $realDir . DIRECTORY_SEPARATOR . $item;
+ if (is_dir($path)) {
+ $relPath = $base ? $base . '/' . $item : $item;
+ $folders[] = $relPath;
+ $folders = array_merge($folders, $this->getFoldersTree($path, $relPath));
+ }
+ }
+ return $folders;
+ }
+
+ private function getOptimizedCache() {
+ $cache_file = DIR_CACHE . 'img_opti_optimized.json';
+ if (file_exists($cache_file)) {
+ $content = @file_get_contents($cache_file);
+ if ($content) {
+ $decoded = json_decode($content, true);
+ if (is_array($decoded)) {
+ return $decoded;
+ }
+ }
+ }
+ return array();
+ }
+
+ private function saveOptimizedCache($cache) {
+ $cache_file = DIR_CACHE . 'img_opti_optimized.json';
+ @file_put_contents($cache_file, json_encode($cache, JSON_UNESCAPED_UNICODE));
+ }
+
+ private function getRelativeImagePath($absolutePath) {
+ $absolutePath = str_replace('\\', '/', $absolutePath);
+ $dirImageNorm = str_replace('\\', '/', DIR_IMAGE);
+ $rel = $absolutePath;
+ if (substr($absolutePath, 0, strlen($dirImageNorm)) === $dirImageNorm) {
+ $rel = substr($absolutePath, strlen($dirImageNorm));
+ } elseif (strtolower(substr($absolutePath, 0, 3)) === strtolower(substr($dirImageNorm, 0, 3)) && substr($absolutePath, 3) === substr($dirImageNorm, 3)) {
+ $rel = substr($absolutePath, strlen($dirImageNorm));
+ }
+ $rel = ltrim($rel, '/');
+ return $this->safe_utf8($rel);
+ }
+
+ private function translate($key, $default = '') {
+ if (isset($this->translations[$key])) {
+ return $this->translations[$key];
+ }
+ return $default ? $default : $key;
+ }
+
+ private function loadLanguageSafe() {
+ $data = array();
+ $en_paths = array(
+ DIR_LANGUAGE . 'en-gb/module/img_opti.php',
+ DIR_LANGUAGE . 'english/module/img_opti.php'
+ );
+ foreach ($en_paths as $path) {
+ if (is_file($path)) {
+ $_ = array();
+ require($path);
+ $data = array_merge($data, $_);
+ break;
+ }
+ }
+ $lang = $this->config->get('config_admin_language');
+ if (!$lang) {
+ $lang = $this->config->get('config_language');
+ }
+ if (isset($this->session->data['language'])) {
+ $lang = $this->session->data['language'];
+ }
+ if (!$lang) {
+ $lang = 'en-gb';
+ }
+ if ($lang && $lang !== 'en-gb' && $lang !== 'english') {
+ $paths = array(
+ DIR_LANGUAGE . $lang . '/module/img_opti.php'
+ );
+ if (strpos($lang, 'ru') === 0) {
+ $paths[] = DIR_LANGUAGE . 'ru-ru/module/img_opti.php';
+ $paths[] = DIR_LANGUAGE . 'russian/module/img_opti.php';
+ } elseif (strpos($lang, 'uk') === 0 || strpos($lang, 'ua') === 0) {
+ $paths[] = DIR_LANGUAGE . 'uk-ua/module/img_opti.php';
+ $paths[] = DIR_LANGUAGE . 'ukrainian/module/img_opti.php';
+ }
+ foreach ($paths as $path) {
+ if (is_file($path)) {
+ $_ = array();
+ require($path);
+ $data = array_merge($data, $_);
+ break;
+ }
+ }
+ }
+ foreach ($data as $key => $value) {
+ $this->language->set($key, $value);
+ }
+ $this->translations = $data;
+ return $data;
+ }
+
+ private function mb_dirname($path) {
+ $path = str_replace('\\', '/', $path);
+ $last_slash = strrpos($path, '/');
+ if ($last_slash !== false) {
+ return substr($path, 0, $last_slash);
+ }
+ return '';
+ }
+
+ private function mb_pathinfo($path, $options = null) {
+ $ret = array('dirname' => '', 'basename' => '', 'extension' => '', 'filename' => '');
+ $path = str_replace('\\', '/', $path);
+ $last_slash = strrpos($path, '/');
+ if ($last_slash !== false) {
+ $ret['dirname'] = substr($path, 0, $last_slash);
+ $basename = substr($path, $last_slash + 1);
+ } else {
+ $basename = $path;
+ }
+ $ret['basename'] = $basename;
+ $last_dot = strrpos($basename, '.');
+ if ($last_dot !== false) {
+ $ret['filename'] = substr($basename, 0, $last_dot);
+ $ret['extension'] = substr($basename, $last_dot + 1);
+ } else {
+ $ret['filename'] = $basename;
+ }
+ if ($options === PATHINFO_DIRNAME) return $ret['dirname'];
+ if ($options === PATHINFO_BASENAME) return $ret['basename'];
+ if ($options === PATHINFO_EXTENSION) return $ret['extension'];
+ if ($options === PATHINFO_FILENAME) return $ret['filename'];
+ return $ret;
+ }
+
+ private function mb_basename($path) {
+ $path = str_replace('\\', '/', $path);
+ $parts = explode('/', $path);
+ return end($parts);
+ }
+
+ public function get_disk_stats() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => 'Permission denied')));
+ return;
+ }
+
+ session_write_close();
+
+ if (!$this->config->get('module_img_opti_disk_status')) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('disabled' => true)));
+ return;
+ }
+
+ $refresh = isset($this->request->get['refresh']) ? (int)$this->request->get['refresh'] : 0;
+
+ if (!$refresh) {
+ $cached = $this->cache->get('img_opti_disk_stats');
+ if ($cached) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($cached));
+ return;
+ }
+ }
+
+ $image_dir = rtrim(str_replace('\\', '/', DIR_IMAGE), '/');
+
+ $quota_total = (float)$this->config->get('module_img_opti_disk_quota_total');
+ $quota_used = (float)$this->config->get('module_img_opti_disk_quota_used');
+
+ if ($quota_total > 0 && $quota_used > 0) {
+ $disk_total = $quota_total * 1024 * 1024 * 1024;
+ $disk_used = $quota_used * 1024 * 1024 * 1024;
+ $disk_free = max(0.0, $disk_total - $disk_used);
+ } else {
+ $disk_total = (float)@disk_total_space($image_dir);
+ $disk_free = (float)@disk_free_space($image_dir);
+ }
+
+ $json = array(
+ 'disk_total' => $disk_total,
+ 'disk_free' => $disk_free,
+ 'module_trash' => $this->dirSize($image_dir . '/catalog_trash'),
+ 'oc_cache' => $this->dirSize($image_dir . '/cache'),
+ 'catalog' => $this->dirSize($image_dir . '/catalog'),
+ );
+
+ $this->cache->set('img_opti_disk_stats', $json, 28800); // cache for 8 hours
+
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+
+ private function dirSize($dir) {
+ $size = 0;
+ if (!is_dir($dir)) return 0;
+ try {
+ $it = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+ foreach ($it as $file) {
+ if ($file->isFile()) $size += $file->getSize();
+ }
+ } catch (Exception $e) {}
+ return (float)$size;
+ }
+
+ public function export_settings() {
+ if (!isset($this->request->get['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ die('Permission denied');
+ }
+ $this->load->model('setting/setting');
+ $settings = $this->model_setting_setting->getSetting('module_img_opti');
+ $export = array(
+ '_module' => 'img_opti',
+ '_version' => '1.1.0',
+ '_exported' => date('Y-m-d H:i:s'),
+ 'settings' => $settings
+ );
+ $filename = 'img_opti_settings_' . date('Ymd_His') . '.json';
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->addHeader('Content-Disposition: attachment; filename="' . $filename . '"');
+ $this->response->setOutput(json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+ }
+
+ public function import_settings() {
+ $this->loadLanguageSafe();
+ if (!isset($this->request->get['token']) || $this->request->get['token'] !== $this->session->data['token']) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+ if (!$this->user->hasPermission('modify', 'module/img_opti')) {
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode(array('error' => $this->language->get('error_permission'))));
+ return;
+ }
+ $json = array();
+ if (!isset($_FILES['import_json']) || $_FILES['import_json']['error'] !== UPLOAD_ERR_OK) {
+ $json['error'] = $this->language->get('error_import_file');
+ } else {
+ $content = @file_get_contents($_FILES['import_json']['tmp_name']);
+ $data = json_decode($content, true);
+ if (!$data || !isset($data['settings']) || !isset($data['_module']) || $data['_module'] !== 'img_opti') {
+ $json['error'] = $this->language->get('error_import_invalid');
+ } else {
+ $this->load->model('setting/setting');
+ $safe = array();
+ foreach ($data['settings'] as $key => $value) {
+ if (strpos($key, 'module_img_opti') === 0) {
+ $safe[$key] = $value;
+ }
+ }
+ $this->model_setting_setting->editSetting('module_img_opti', $safe);
+ $this->cache->delete('img_opti_disk_stats');
+ $json['success'] = true;
+ }
+ }
+ $this->response->addHeader('Content-Type: application/json');
+ $this->response->setOutput(json_encode($json));
+ }
+}
\ No newline at end of file
diff --git a/upload/admin/language/en-gb/module/img_opti.php b/upload/admin/language/en-gb/module/img_opti.php
new file mode 100644
index 0000000..16f7e4e
--- /dev/null
+++ b/upload/admin/language/en-gb/module/img_opti.php
@@ -0,0 +1,565 @@
+Image Optimizer [NAT]: Compress Originals & Delete Junk';
+$_['text_extension'] = 'Extensions';
+$_['text_edit'] = 'Module Settings';
+$_['text_success'] = 'Settings have been successfully saved!';
+$_['entry_warning'] = 'WARNING: Always make a backup of your files, especially the /image/catalog folder before starting!';
+$_['text_tech'] = 'Server Technology Check:';
+$_['text_author'] = 'Technical support and suggestions: info@nat.od.ua';
+$_['tab_optimize'] = 'Optimization & Resize';
+$_['tab_cleaner'] = 'Clean Unused Images';
+$_['tab_info'] = 'Info / Ecosystem [NAT]';
+$_['tab_settings'] = 'Settings';
+$_['tab_tools'] = 'Tools (pro)';
+$_['tab_cron'] = 'Cron Jobs';
+$_['entry_mode'] = 'Operation Mode';
+$_['text_copy'] = 'Create a copy (Safe)';
+$_['text_replace'] = 'Replace originals (Requires backup!)';
+$_['entry_folder'] = 'Folder name for copy';
+$_['entry_batch'] = 'Files per batch';
+$_['entry_log_limit'] = 'Log lines per page';
+$_['entry_max_width'] = 'Max width (px) [0 - no limit]';
+$_['entry_max_height'] = 'Max height (px) [0 - no limit]';
+$_['entry_jpg'] = 'JPG Quality (0-100)';
+$_['entry_png'] = 'PNG Compression (0-9)';
+$_['entry_webp'] = 'WebP Quality (0-100)';
+$_['entry_targets'] = 'What to optimize?';
+$_['text_all_catalog'] = '[ Entire image/catalog ]';
+$_['text_root_files'] = 'Files only in image/catalog root';
+$_['entry_threshold'] = 'Replacement threshold (%)';
+$_['help_threshold'] = '0: replace only if file is smaller. 10: allow up to 10% file size increase.';
+$_['entry_token'] = 'Activation Token (Root Domain)';
+$_['entry_status'] = 'Module Status';
+$_['entry_menu_position'] = 'Menu Position [NAT]';
+$_['text_menu_module_only'] = 'Modules List Only';
+$_['text_menu_header'] = 'Top Header';
+$_['text_menu_sidebar'] = 'Sidebar Menu';
+$_['text_menu_both'] = 'Both (Header + Sidebar)';
+$_['text_status_on'] = 'Enabled';
+$_['text_status_off'] = 'Disabled';
+$_['entry_engine'] = 'Image Processing Engine';
+$_['text_engine_gd'] = 'GD (Standard)';
+$_['text_engine_imagick'] = 'Imagick (High quality, sRGB profile keeping)';
+$_['entry_license'] = 'License';
+$_['text_cleaner_info'] = 'This tool looks for files that physically exist on the server but are not linked in the database or template files.';
+$_['entry_clean_targets'] = 'Folders to scan for junk?';
+$_['entry_extended_log'] = 'Extended log (show used photos and their locations)';
+$_['entry_process_broken'] = 'Include files with broken encoding ("?" in names)';
+$_['button_scan_trash'] = 'FIND JUNK FILES';
+$_['button_quarantine'] = 'Move to Quarantine (Safe)';
+$_['button_delete_trash'] = 'Delete Permanently';
+$_['button_empty_quarantine'] = 'Empty Quarantine Folder';
+$_['button_restore_quarantine'] = 'Restore from Quarantine';
+$_['text_scan_result'] = 'Scan Results';
+$_['text_trash_files'] = 'Junk files found: ';
+$_['text_trash_size'] = 'Potential space to free up: ';
+$_['button_start'] = 'START OPTIMIZATION';
+$_['button_pause'] = 'PAUSE';
+$_['button_stop'] = 'STOP';
+$_['button_continue'] = 'CONTINUE';
+$_['button_start_new'] = 'START (New Run)';
+$_['button_download_log'] = 'Download TXT Log';
+$_['button_download'] = 'Download';
+$_['button_save'] = 'Save';
+$_['button_cancel'] = 'Cancel';
+$_['text_progress'] = 'Progress:';
+$_['text_old_size'] = 'Before:';
+$_['text_new_size'] = 'After:';
+$_['text_savings'] = 'Saved:';
+$_['text_detailed_log'] = 'Detailed Log';
+$_['text_page'] = 'Page';
+$_['text_waiting'] = 'Waiting to start...';
+$_['text_enabled'] = 'Enabled';
+$_['text_disabled_error'] = 'Disabled (ERROR)';
+$_['text_supported'] = 'Supported';
+$_['text_no'] = 'No';
+$_['error_permission'] = 'You do not have permission to modify this module!';
+$_['error_license'] = 'Access Error: Invalid activation token for this domain!';
+$_['error_domain'] = ' (Domain: ';
+$_['text_found'] = 'Files found: ';
+$_['text_compressed'] = 'Compressed';
+$_['text_original'] = 'Original';
+$_['text_error_process'] = 'Processing error: ';
+$_['error_log_empty'] = 'Log is empty! Run the process first.';
+$_['text_report_header'] = '=== REPORT ===';
+$_['text_total_files'] = 'Total files: ';
+$_['text_searching'] = 'Searching for files...';
+$_['error_ajax'] = 'Error: ';
+$_['text_resumed'] = '▶ PROCESS RESUMED...';
+$_['text_paused'] = '⏸ PAUSED. Process suspended.';
+$_['confirm_stop'] = 'Are you sure you want to completely abort the process?';
+$_['confirm_delete'] = 'WARNING! Files will be permanently deleted from the server with no chance of recovery! Continue?';
+$_['confirm_empty_q'] = 'Are you sure you want to completely empty the quarantine folder?';
+$_['confirm_restore'] = 'All files from quarantine will be returned to their original locations. Continue?';
+$_['button_stopping'] = 'Stopping...';
+$_['text_stopped_user'] = '🛑 PROCESS ABORTED BY USER!';
+$_['text_increase'] = 'Increased';
+$_['text_done'] = '✅ DONE! Process completed.';
+$_['error_timeout'] = 'Server timeout. Auto-retrying in 3 seconds...';
+$_['text_building_index'] = 'Step 1/2: Indexing DB and templates (searching for used photos)...';
+$_['text_comparing'] = 'Step 2/2: Comparing physical files with the index...';
+$_['text_quarantine_done'] = '✅ Quarantined to image/catalog_trash/ successfully!';
+$_['text_deleted_done'] = '✅ Junk deletion completed successfully!';
+$_['text_quarantine_empty'] = '✅ Quarantine folder emptied successfully!';
+$_['text_restore_done'] = '✅ Files restored from quarantine to their original locations!';
+$_['text_quarantine_move'] = 'Quarantined: ';
+$_['text_deleted_file'] = 'Deleted: ';
+$_['text_no_trash'] = 'No junk found in selected folders! All files are in use.';
+$_['text_scanning_dirs'] = 'Scanning directories: ';
+$_['text_log_used'] = '[IN USE]';
+$_['text_log_trash'] = '[JUNK]';
+$_['text_log_deleted'] = '[DELETED]';
+$_['text_log_del_err'] = '[DELETE ERROR]';
+$_['text_log_quarantine'] = '[QUARANTINED]';
+$_['text_log_q_err'] = '[MOVE ERROR]';
+$_['text_log_restored'] = '[RESTORED]';
+$_['text_log_rest_err'] = '[RESTORE ERROR]';
+$_['text_used_in'] = 'in: ';
+$_['text_analyzing'] = 'Analyzing database...';
+$_['text_clean_empty'] = 'Clean! No junk found.';
+$_['text_processing'] = 'Processing...';
+$_['text_restoring'] = 'Restoring files...';
+$_['text_all_folders'] = 'image/catalog/ (And all subfolders)';
+$_['text_root_folder'] = 'image/catalog/ (Root only)';
+$_['text_q_empty_err'] = 'Quarantine folder is empty or does not exist.';
+$_['error_token'] = 'Invalid token. Please refresh the page.';
+$_['text_apply_title'] = 'Optimization completed in folder: ';
+$_['text_apply_info'] = 'Check the result. If everything looks good, apply changes to the main catalog.';
+$_['entry_apply_backup'] = 'Move and create a backup (in Quarantine)';
+$_['entry_apply_replace'] = 'Replace originals permanently';
+$_['button_apply_main'] = 'APPLY TO MAIN CATALOG';
+$_['button_delete_copies'] = 'Delete temporary copies (Cancel)';
+$_['button_full_cleanup'] = 'Full cleanup of all temp folders and backups';
+$_['text_apply_success'] = '✅ Files successfully moved. ';
+$_['text_apply_backup_ok'] = 'Originals saved in folder: ';
+$_['text_apply_no_backup'] = 'Originals deleted.';
+$_['text_error_source'] = 'Error: Folder with optimized files not found.';
+$_['confirm_full_cleanup'] = 'WARNING! This action will permanently delete ALL backup folders (backup_*) and temp copy folders. Continue?';
+$_['text_tools_desc'] = 'Global scanners and tools for file system and database image optimization.';
+$_['text_tool_select'] = 'Select Tool:';
+$_['text_group_scanners'] = 'Scanners & Analysis';
+$_['text_group_generators'] = 'Generators & Processing';
+$_['text_group_experimental'] = 'Experimental Features (BETA)';
+$_['text_experimental_warn'] = 'Warning: Experimental features modify system files or the database. Backup is highly recommended!';
+$_['text_tool_broken_db'] = 'Broken/Empty DB Image Scanner';
+$_['text_tool_watermark'] = 'Dynamic Watermark (Cache only)';
+$_['text_tool_duplicates'] = 'Duplicate Finder (MD5)';
+$_['text_tool_png_jpg'] = 'Heavy PNG to JPG Converter (non-transparent PNG scanner)';
+$_['text_tool_placeholders'] = 'Placeholder Generator (Replaces No Image)';
+$_['text_tool_exif'] = 'Clean EXIF Data (Geotags/Meta)';
+$_['text_tool_small_photos'] = 'Too Small Photo Scanner';
+$_['text_tool_html_broken'] = 'Broken Images in HTML';
+$_['text_tool_empty_folders'] = 'Remove Empty Folders (image/catalog)';
+$_['text_tool_smart_cache'] = 'Smart Cache Cleanup';
+$_['text_tool_folder_tree'] = 'Folder Tree Statistics';
+$_['text_tool_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load (No DB)';
+$_['text_tool_translit'] = 'File Name Transliteration';
+$_['text_tool_cache_restore'] = 'Restore Originals from Cache';
+$_['entry_wm_type'] = 'Watermark Type';
+$_['text_wm_image'] = 'Image (PNG)';
+$_['text_wm_text'] = 'Text';
+$_['entry_wm_image_path'] = 'Image Path';
+$_['entry_wm_text_val'] = 'Watermark Text';
+$_['entry_wm_position'] = 'Position (1-9)';
+$_['entry_wm_opacity'] = 'Opacity (0-100)';
+$_['entry_wm_category'] = 'Only for Categories';
+$_['entry_wm_brand'] = 'Only for Brands';
+$_['entry_min_width'] = 'Minimum Width (px)';
+$_['entry_min_height'] = 'Minimum Height (px)';
+$_['entry_ph_product'] = 'Placeholder for Products';
+$_['entry_ph_category'] = 'Placeholder for Categories';
+$_['entry_ph_brand'] = 'Placeholder for Brands';
+$_['button_run_tool'] = 'Run Tool';
+$_['button_scan_tool'] = 'Scan';
+$_['button_fix_tool'] = 'Fix Found';
+$_['text_th_path'] = 'File / Path';
+$_['text_th_size'] = 'Size';
+$_['text_th_action'] = 'Action';
+$_['text_th_status'] = 'Status';
+$_['text_th_problem'] = 'Problem / Value';
+$_['text_cron_title'] = 'Cron Settings';
+$_['text_cron_desc'] = 'Add the command to your hosting Cron for automatic background compression.';
+$_['entry_cron_new_only'] = 'Process only new (unprocessed) files';
+$_['entry_cron_folders'] = 'Folders to scan (leave empty for entire catalog)';
+$_['text_promo_title'] = 'Other [NAT] Series Modules';
+$_['text_promo_desc'] = '[NAT] Series Modules are tools for deep OpenCart optimization. We focus on automation, DB speed, and server cleanup. All modules are open-source and share the same core logic.';
+$_['text_support'] = 'Technical support and suggestions:';
+$_['button_more'] = 'View all extensions on OpenCart.com';
+$_['button_more_forum'] = 'Opencartforum';
+$_['text_mod_cat_opt'] = 'Catalog Optimizer [NAT]';
+$_['text_mod_cat_opt_desc'] = 'Hide empty categories/brands + SEO sorting (in stock first) + DB junk cleanup.';
+$_['text_mod_new_arrivals'] = 'New Arrivals [NAT]: Sort by exact date';
+$_['text_mod_new_arrivals_desc'] = 'Automated SEO page for new arrivals. Displays items strictly by the real date added. Sliders, grid, and smart date archive sidebar.';
+$_['text_mod_img_opt'] = 'Image Optimizer [NAT]';
+$_['text_mod_img_opt_desc'] = 'Current Module. Compress and resize ORIGINAL photos (image/catalog) to WebP + Server cleanup of unused images.';
+$_['text_mod_dom_scan'] = 'Domain Scanner [NAT]';
+$_['text_mod_dom_scan_desc'] = 'Find and clean external links and HTTP images in DB and theme files. Ideal for HTTPS migration or cleanup after parsing.';
+$_['text_mod_poll'] = 'Poll Widget [NAT]';
+$_['text_mod_poll_desc'] = 'Create polls and voting widgets with a visual design builder, deep statistics, and cheat protection.';
+$_['text_mod_db_opt'] = 'Database Optimizer [NAT]';
+$_['text_mod_db_opt_desc'] = 'Intelligent garbage cleanup in the database, system log management, and crucial index addition for speed boost.';
+$_['text_mod_im_ex'] = 'Import/Export [NAT]';
+$_['text_mod_im_ex_desc'] = 'Import/export prices and stock levels from/to files (xls, xlsx, csv, xml, json) or by url. Field mapping, Cron automation, text utilities.';
+$_['text_mod_admin_tools'] = 'Admin Tools [NAT]';
+$_['text_mod_admin_tools_desc'] = 'Handy administrator tools: login as customer, admin customization, user permissions management, and hiding unnecessary menu items.';
+$_['text_mod_utm_tracker'] = 'Order UTM Tracker [NAT]';
+$_['text_mod_utm_tracker_desc'] = 'Automatic tracking of UTM tags during checkout. Saves the traffic source, campaign, keywords, and displays this data in order details to analyze advertising effectiveness.';
+$_['text_info_title_promo_top_bar'] = 'Promo Top Bar [NAT]: Upper promotional info-banner, information bar above header, marquee and top-banner';
+$_['text_info_desc_promo_top_bar'] = 'Create interactive promo bars and header slideshows with a block builder, countdown timers, subscription forms, and flexible targeting.';
+$_['text_tool_no_image'] = 'Products without main image (empty in DB)';
+$_['text_problem_main_broken'] = 'Broken main image (file deleted)';
+$_['text_problem_add_broken'] = 'Broken additional image (file deleted)';
+$_['text_problem_no_image'] = 'Missing main image';
+$_['text_err_dir'] = 'Error: Directory not found or invalid path.';
+$_['text_err_no_queue'] = 'Error: No queue found. Run scan first.';
+$_['text_log_del_empty_folder'] = 'Deleted empty folder: ';
+$_['text_log_no_empty_folders'] = 'No empty folders found in image/catalog/.';
+$_['text_log_total_empty_folders'] = 'Total empty folders removed: ';
+$_['text_log_cache_cleared'] = 'Image cache has been cleared.';
+$_['text_log_wm_smart_mode'] = 'Watermarks are operating in SMART mode: originals remain untouched, applied only on cache generation.';
+$_['text_log_scan_dupes'] = 'Scanning image/catalog/ for MD5 duplicates... This may take a while.';
+$_['text_log_found_dupe'] = 'Found duplicate: ';
+$_['text_log_no_dupes'] = 'No duplicates found.';
+$_['text_log_total_dupes'] = 'Found duplicates: %s. Potential savings: %s';
+$_['text_log_fix_dupes_prompt'] = 'Click "Fix Found" to redirect DB links to original and delete duplicates.';
+$_['text_log_fixed_dupe'] = 'Fixed and removed duplicate: ';
+$_['text_log_success_dupes'] = 'Successfully fixed %s duplicates. DB updated.';
+$_['text_log_scan_png'] = 'Scanning for heavy PNG files without transparency...';
+$_['text_log_no_png'] = 'No suitable PNG files found.';
+$_['text_log_total_png'] = 'Found %s PNG without transparency. Click "Fix Found" to convert to JPG.';
+$_['text_log_converted_jpg'] = 'Converted to JPG: ';
+$_['text_log_success_png'] = 'Successfully converted %s files. Space saved: %s';
+$_['text_log_ph_prod'] = 'Updated %s Products with placeholder.';
+$_['text_log_ph_cat'] = 'Updated %s Categories with placeholder.';
+$_['text_log_ph_brand'] = 'Updated %s Brands with placeholder.';
+$_['text_log_ph_empty'] = 'No placeholders selected in settings.';
+$_['text_log_scan_exif'] = 'Scanning for EXIF data...';
+$_['text_log_stripped_exif'] = 'Stripped EXIF: ';
+$_['text_log_no_exif'] = 'No EXIF data found to clean.';
+$_['text_log_success_exif'] = 'Cleaned EXIF from %s files. Saved: %s';
+$_['text_log_scan_small'] = 'Scanning for photos smaller than %sx%s px...';
+$_['text_log_too_small'] = 'Too small (%sx%s): ';
+$_['text_log_no_small'] = 'No small photos found.';
+$_['text_log_total_small'] = 'Found %s small photos. Please review and re-upload.';
+$_['text_log_html_broken'] = 'Broken IMG in HTML table %s (ID: %s): ';
+$_['text_log_no_html_broken'] = 'No broken images found in HTML descriptions.';
+$_['text_log_total_html_broken'] = 'Found %s broken links in HTML. Please edit them manually.';
+$_['text_log_scan_smart_cache'] = 'Scanning image/cache/ for orphaned files...';
+$_['text_log_del_orph_cache'] = 'Deleted orphaned cache: ';
+$_['text_log_no_orph_cache'] = 'No orphaned cache files found.';
+$_['text_log_success_smart_cache'] = 'Removed %s orphaned cache files. Space saved: %s';
+$_['text_log_scan_translit'] = 'Scanning for files with cyrillic or spaces...';
+$_['text_log_no_bad_names'] = 'No files with bad names found.';
+$_['text_log_total_bad_names'] = 'Found %s bad files. Click "Fix Found" to transliterate and update DB.';
+$_['text_log_renamed'] = 'Renamed: ';
+$_['text_log_success_translit'] = 'Successfully transliterated %s files and updated DB.';
+$_['text_log_scan_restore'] = 'Scanning image/cache/ to restore lost originals...';
+$_['text_log_restored_cache'] = 'Restored from cache: ';
+$_['text_log_no_lost_orig'] = 'No missing originals found in cache.';
+$_['text_log_success_restore'] = 'Successfully restored %s original images from cache.';
+$_['error_invalid_folder'] = 'Error: Invalid folder name!';
+$_['text_formats_title'] = 'Modern Formats (WebP & SVG)';
+$_['text_formats_desc'] = 'Settings for integrating modern formats into the OpenCart file system and templates.';
+$_['entry_support_svg'] = 'SVG Upload Support';
+$_['help_support_svg'] = 'Allows uploading vector SVG images via the standard Filemanager and safely displaying them on the storefront without size distortion.';
+$_['entry_support_webp'] = 'WebP Upload Support';
+$_['help_support_webp'] = 'Allows manually uploading, linking, and displaying ready .webp files on the website via the Filemanager.';
+$_['entry_truefilemanager_webp'] = 'WebP in TrueFileManager';
+$_['help_truefilemanager_webp'] = 'Adds WebP upload, preview, and thumbnail support to sitecreator\'s TrueFileManager (if installed).';
+$_['entry_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load';
+$_['help_webp_on_fly'] = 'Ultra-fast replacement: automatically changes .jpg/.png extensions to .webp directly in the storefront HTML output and adds the loading="lazy" attribute. Does not touch the database!';
+$_['help_tool_broken_db'] = 'Finds images in the database whose physical files have been deleted from the server. Clears broken links.';
+$_['help_tool_empty_folders'] = 'Recursively scans image/catalog/ and safely removes all folders that contain no files.';
+$_['help_tool_folder_tree'] = 'Builds a directory tree and shows the real size of each folder to find space eaters.';
+$_['help_tool_html_broken'] = 'Scans HTML descriptions of products and categories for tags with non-existent images.';
+$_['help_tool_small_photos'] = 'Finds pictures with resolution below the specified one. Added search links to Google and Yandex Images so you can easily find high-quality replacements.';
+$_['help_tool_duplicates'] = 'Searches for exact duplicates by MD5 hash (different names, same image). Merges them into one file and updates the DB.';
+$_['help_tool_watermark'] = 'Applies watermarks in SMART mode (only to storefront cache, originals remain unchanged).';
+$_['help_tool_placeholders'] = 'Massively sets the selected placeholder image for products and categories that have no photo.';
+$_['help_tool_png_jpg'] = 'Scans for heavy PNG files without transparency (alpha channel). Since they do not need transparency, they can be safely converted to lightweight JPG. This greatly reduces file size (typically by 70-80%) and speeds up your website, automatically updating all links in the database.';
+$_['help_tool_exif'] = 'Scans JPEG files and removes hidden EXIF data (geotags, camera data), reducing size by 10-15%.';
+$_['help_tool_smart_cache'] = 'Finds cached files in image/cache/ whose originals have already been deleted, and removes this trash.';
+$_['help_tool_translit'] = 'Finds files with Cyrillic or spaces, renames them using transliteration, and updates paths in the DB.';
+$_['help_tool_cache_restore'] = 'If you accidentally deleted an original from catalog, this tool will try to find its copy in cache and restore it.';
+$_['entry_quarantine'] = 'Move originals to Quarantine (instead of deleting)';
+$_['entry_backup_db'] = 'Create database backup of modified tables in SQL before starting';
+$_['button_fix_selected'] = 'FIX SELECTED';
+$_['button_export_log'] = 'Export to CSV';
+$_['text_th_before'] = 'State BEFORE';
+$_['text_th_after'] = 'Expected result AFTER';
+$_['text_th_select'] = 'Action';
+$_['text_th_preview'] = 'Preview';
+$_['text_th_name'] = 'Object / File';
+$_['text_global_action'] = 'Bulk action for selected:';
+$_['text_action_clear'] = 'Clear DB Link';
+$_['text_action_disable'] = 'Disable (Status = 0)';
+$_['text_action_stock'] = 'Set Out of Stock';
+$_['text_action_placeholder'] = 'Set Placeholder';
+$_['text_action_ignore'] = 'Do nothing';
+$_['text_action_merge'] = 'Merge (delete duplicate, keep original)';
+$_['text_action_convert'] = 'Convert to JPG';
+$_['text_action_rename'] = 'Rename to translit';
+$_['text_action_delete'] = 'Delete';
+$_['text_action_restore'] = 'Restore';
+$_['text_db_backup_created'] = 'Database backup created: %s';
+$_['text_quarantine_created'] = 'Quarantined to: %s';
+$_['text_fix_status_success'] = 'Successfully executed';
+$_['text_fix_status_error'] = 'Error: %s';
+$_['text_log_exported'] = 'Log successfully exported!';
+$_['text_select_action'] = '-- Select Action --';
+$_['error_empty_selection'] = 'Error: No rows selected for fixing!';
+$_['text_link_admin'] = 'Admin';
+$_['text_link_catalog'] = 'Store';
+$_['entry_threads'] = 'Processing Threads';
+$_['text_threads_optimal'] = 'optimal';
+$_['button_download_zip'] = 'Download ZIP and Delete Folder';
+$_['confirm_download_and_delete'] = 'Did the ZIP download successfully? Can we delete the temporary copies folder on the server?';
+$_['button_clear_backups'] = 'Delete DB Backups';
+$_['button_clear_backups_short'] = 'Clear Backups';
+$_['confirm_clear_backups'] = 'Are you sure you want to delete all SQL database backups?';
+$_['text_compare_title'] = 'Compare Images BEFORE / AFTER';
+$_['text_compare_before'] = 'BEFORE';
+$_['text_compare_after'] = 'AFTER';
+$_['text_backups_cleared'] = 'All database backups deleted successfully!';
+$_['error_no_backups'] = 'No backups found or folder is empty.';
+$_['button_restore'] = 'Restore';
+$_['button_empty'] = 'Clear';
+$_['entry_wm_targets'] = 'Apply watermark to:';
+$_['text_wm_target_product'] = 'Product Photos';
+$_['text_wm_target_category'] = 'Category Photos';
+$_['text_wm_target_brand'] = 'Brand Logos';
+$_['text_wm_target_banner'] = 'Banners and Slides';
+$_['text_wm_target_blog'] = 'Articles / Blog';
+$_['error_no_scan_data'] = 'Error: No scan data found. Please run scan first!';
+$_['error_row_not_found'] = 'Error: Row not found in scan results!';
+$_['error_file_not_found'] = 'Error: File not found on server!';
+$_['text_folder_rules_title'] = 'Individual Folder Rules';
+$_['text_folder_rules_desc'] = 'You can override compression quality and maximum sizes for specific folders. Leave a field blank to use global settings. The path is relative to the image/ directory (e.g. catalog/banners/).';
+$_['entry_folder_path'] = 'Folder Path';
+$_['entry_jpg_quality'] = 'JPG Quality';
+$_['entry_webp_quality'] = 'WebP Quality';
+$_['button_add_rule'] = 'Add Rule';
+$_['button_remove'] = 'Remove';
+$_['entry_wm_angle'] = 'Watermark Rotation Angle (degrees)';
+$_['entry_wm_corner_radius'] = 'Watermark Image Corner Radius (px)';
+$_['entry_wm_size_type'] = 'Watermark Scaling';
+$_['entry_wm_size_percent'] = 'Watermark Size (% of image width)';
+$_['entry_wm_text_color'] = 'Watermark Text Color';
+$_['entry_wm_text_font'] = 'Watermark Font (TTF)';
+$_['entry_wm_filter_mode'] = 'Categories/Brands Filter Mode';
+$_['text_wm_filter_include'] = 'Only for Selected';
+$_['text_wm_filter_exclude'] = 'For All Except Selected';
+$_['text_wm_size_original'] = 'Original Size';
+$_['text_wm_size_percent'] = 'Proportional to image width (%)';
+$_['button_preview_watermark'] = 'Preview Watermark';
+$_['text_wm_preview_title'] = 'Watermark Preview';
+$_['text_cron_exclude_folders'] = 'Exclude folders from scan';
+$_['text_cron_entities'] = 'Which photo types to process?';
+$_['text_cron_quality_override'] = 'Override compression settings for Cron';
+$_['text_cron_recommendations'] = 'Recommended Cron Schedule';
+$_['text_cron_recommendations_desc'] = '
0 * * * *).0 */6 * * *).0 2 * * *).';
+$_['text_tool_broken_files'] = 'Server Broken/Empty Files Scanner';
+$_['help_tool_broken_files'] = 'Scans physical files in image/catalog/ for corrupted images or empty files of 0 bytes.';
+$_['text_grid_broken_file'] = 'Broken or empty file on server';
+$_['text_grid_broken_file_after'] = 'Delete or replace with placeholder';
+$_['text_description_broken_files'] = 'Scanning results of physical files in image/catalog/. Shows broken (corrupted) images or files with 0 bytes size.';
+$_['text_action_strip'] = 'Strip EXIF';
+$_['text_action_set_placeholder'] = 'Set Placeholder';
+$_['text_th_entity'] = 'Entity / Related Links';
+$_['text_cron_entity_product'] = 'Products';
+$_['text_cron_entity_category'] = 'Categories';
+$_['text_cron_entity_manufacturer'] = 'Manufacturers / Brands';
+$_['text_cron_entity_banner'] = 'Banners';
+$_['text_cron_entity_blog'] = 'Blog / Articles';
+$_['entry_cron_jpg_quality'] = 'JPG Quality (Cron)';
+$_['entry_cron_webp_quality'] = 'WebP Quality (Cron)';
+$_['entry_cron_max_width'] = 'Max Width px (Cron)';
+$_['entry_cron_max_height'] = 'Max Height px (Cron)';
+$_['text_cron_folder_scan'] = 'Folders to scan';
+$_['text_no_fonts'] = 'No TTF fonts found in system/library/font/';
+$_['button_apply'] = 'Apply';
+$_['text_backup_will_be_created'] = 'A database backup will be automatically created before running this action.';
+$_['text_bulk_action'] = 'Bulk action for selected';
+$_['text_grid_results'] = 'Scanner Results & Details';
+$_['text_quarantine_will_be_created'] = 'Original files will be moved to quarantine (image/catalog_trash/) before modification.';
+$_['text_success_apply'] = 'Changes successfully applied!';
+$_['text_description_broken'] = 'Database scan results for broken links (table records referencing non-existent files).';
+$_['text_description_cache_restore'] = 'Results of search for cache files whose originals are missing in the catalog.';
+$_['text_description_duplicates'] = 'Results of search for exact duplicate images by MD5 hash.';
+$_['text_description_empty_folders'] = 'List of empty folders in image/catalog/ directory for deletion.';
+$_['text_description_exif'] = 'Results of search for images containing EXIF metadata that can be stripped.';
+$_['text_description_folder_tree'] = 'Folder size statistics in image/catalog/ directory.';
+$_['text_description_html_broken'] = 'Results of search for non-existent image files referenced in HTML descriptions.';
+$_['text_description_placeholders'] = 'Results of search for entities without images to fill with placeholders.';
+$_['text_description_png_jpg'] = 'Results of search for heavy PNG images without transparency that can be compressed to JPG.';
+$_['text_description_small_photos'] = 'List of images with dimensions smaller than the minimum specified (%sx%s px).';
+$_['text_description_smart_cache'] = 'Results of search for cache files whose original images have already been deleted.';
+$_['text_description_translit'] = 'Results of search for files with Cyrillic characters or spaces in their names.';
+$_['text_grid_broken_db_after'] = 'Clear link or set placeholder';
+$_['text_grid_dupe_after'] = 'Original (remains)';
+$_['text_grid_dupe_before'] = 'Duplicate (will be deleted)';
+$_['text_grid_empty_folder'] = 'Empty folder';
+$_['text_grid_empty_folder_after'] = 'Delete empty folder';
+$_['text_grid_exif_after'] = 'Strip metadata';
+$_['text_grid_exif_before'] = 'Contains EXIF';
+$_['text_grid_html_after'] = 'Fix link manually';
+$_['text_grid_missing_file'] = 'File is missing';
+$_['text_grid_missing_html'] = 'Image not found in description';
+$_['text_grid_missing_orig'] = 'Original is missing';
+$_['text_grid_missing_orig_after'] = 'Restore original from cache';
+$_['text_grid_no_image'] = 'No image (empty)';
+$_['text_grid_orph_cache'] = 'Orphaned cache';
+$_['text_grid_orph_cache_after'] = 'Delete unneeded cache';
+$_['text_grid_ph_brand_after'] = 'Set brand placeholder';
+$_['text_grid_ph_category_after'] = 'Set category placeholder';
+$_['text_grid_ph_product_after'] = 'Set product placeholder';
+$_['text_grid_small_photo_after'] = 'Recommended to replace with larger one';
+$_['text_grid_too_small'] = 'Too small';
+$_['text_no_relations'] = 'No database relations';
+$_['text_action_archive'] = 'Archive (to ZIP)';
+$_['text_action_archive_delete'] = 'Archive + Delete';
+$_['text_tool_sanitizer'] = 'File Name Sanitizer';
+$_['help_tool_sanitizer'] = 'Bulk cleanup of file and folder names from Cyrillic, spaces and special characters, normalization of extensions.';
+$_['text_description_sanitizer'] = 'Results of file name analysis for sanitization based on selected rules.';
+$_['entry_sz_translit'] = 'Transliterate (Cyrillic to Latin)';
+$_['entry_sz_spaces'] = 'Replace spaces with "_"';
+$_['entry_sz_special'] = 'Remove special characters';
+$_['entry_sz_lowercase_name'] = 'Lowercase file name';
+$_['entry_sz_lowercase_ext'] = 'Lowercase extension';
+$_['entry_sz_normalize_ext'] = 'Normalize extension (jpeg -> jpg)';
+$_['entry_cron_folder_mode'] = 'Folder filtering mode';
+$_['text_cron_folder_mode_exclude'] = 'Scan all folders except selected (Exceptions)';
+$_['text_cron_folder_mode_include'] = 'Scan only selected folders';
+$_['button_select_all'] = 'Select All';
+$_['button_deselect_all'] = 'Deselect All';
+$_['entry_wm_upload_font'] = 'Upload custom TTF font';
+$_['button_upload_font'] = 'Upload Font';
+$_['text_success_font_upload'] = 'Font successfully uploaded!';
+$_['error_font_upload'] = 'Error uploading font! Only .ttf files up to 5 MB are allowed.';
+$_['entry_lazy_load'] = 'Progressive Lazy Load';
+$_['help_lazy_load'] = 'Enables a premium progressive "blur-up" lazy loading on the frontend. Images are replaced with tiny blurred versions and loaded fully when scrolled into view.';
+$_['confirm_replace_mode'] = 'WARNING! You have selected the "Replace originals" mode. All images will be overwritten directly on the server. We strongly recommend making a backup first. Are you sure you want to continue?';
+$_['text_compare'] = 'Compare';
+$_['text_home'] = 'Home';
+$_['text_tech_gd'] = 'GD Library';
+$_['text_tech_imagick'] = 'Imagick';
+$_['text_tech_webp'] = 'WebP Support';
+$_['text_position_1'] = '1 - Top Left';
+$_['text_position_2'] = '2 - Top Center';
+$_['text_position_3'] = '3 - Top Right';
+$_['text_position_4'] = '4 - Middle Left';
+$_['text_position_5'] = '5 - Center';
+$_['text_position_6'] = '6 - Middle Right';
+$_['text_position_7'] = '7 - Bottom Left';
+$_['text_position_8'] = '8 - Bottom Center';
+$_['text_position_9'] = '9 - Bottom Right';
+
+$_['entry_log_level'] = 'Log Level';
+$_['text_log_changed_only'] = 'Changed files only';
+$_['text_log_all'] = 'All files (verbose)';
+$_['text_skipped_no_gain'] = 'Skipped (no improvement)';
+$_['text_reset_cache_hint'] = 'Reset optimization cache (force full rescan)';
+$_['confirm_reset_cache'] = 'Reset optimization cache? Next scan will check ALL files again.';
+$_['text_cache_cleared'] = 'Optimization cache cleared. Next scan will check all files.';
+$_['error_wm_preview'] = 'Failed to generate watermark preview.';
+$_['error_wm_too_large'] = 'Watermark image is too large (%dx%d px)! Maximum allowed size is %dx%d px.';
+$_['text_apply_errors_warning'] = 'WARNING: Some files could not be replaced (locked or permission denied):';
+$_['text_action_compress'] = 'Compress Images';
+$_['text_estimated'] = 'Estimated';
+$_['entry_min_savings_bytes'] = 'Min savings (bytes)';
+$_['entry_min_savings_percent'] = 'Min savings (%)';
+$_['text_optimized'] = 'Optimized';
+$_['entry_broken_fallback'] = 'Broken Image Fallback';
+$_['help_broken_fallback'] = 'Automatically replaces missing or corrupted catalog images on the storefront with a fallback image.';
+$_['entry_fallback_image'] = 'Fallback Image';
+$_['help_fallback_image'] = 'Select a custom image to display when a product or category image is broken. Defaults to no_image.png.';
+$_['text_tool_cmyk'] = 'CMYK to RGB Converter';
+$_['help_tool_cmyk'] = 'Convert JPEG images from CMYK color profile to sRGB to ensure correct display on Safari and iOS devices.';
+$_['text_tool_heavy_files'] = 'Top Heavy Files Scanner';
+$_['help_tool_heavy_files'] = 'Scan and find the heaviest original files in catalog directory and optimize them directly.';
+$_['entry_heavy_files_limit'] = 'Max files to find (total limit)';
+$_['text_action_convert_rgb'] = 'Convert to RGB';
+$_['text_description_cmyk'] = 'CMYK JPEGs found. Conversion to sRGB guarantees they are displayed correctly on all platforms, including iOS/macOS.';
+$_['text_description_heavy'] = 'List of the top %d heaviest files in the image directory. You can optimize them here directly.';
+$_['text_cron_folder_hint_exclude_all'] = 'Status: All folders will be scanned (no exclusions).';
+$_['text_cron_folder_hint_exclude_some'] = 'Status: All folders will be scanned EXCEPT the %d checked folders.';
+$_['text_cron_folder_hint_include_some'] = 'Status: Only the %d checked folders will be scanned.';
+$_['text_cron_folder_hint_include_none'] = 'WARNING: No folders are selected! The Cron job will scan nothing.';
+$_['text_action_apply_changes'] = 'Apply specified changes';
+$_['entry_wm_status'] = 'Watermark Status';
+$_['text_disabled'] = 'Disabled';
+$_['entry_rule_name'] = 'Rule Name';
+$_['entry_rule_status'] = 'Rule Status';
+$_['entry_action'] = 'Action';
+$_['entry_ph_status'] = 'Placeholder Status';
+$_['entry_sanitizer_limit'] = 'Max files to scan (limit)';
+$_['entry_heavy_files_min_size'] = 'Min Size Threshold';
+$_['entry_png_jpg_limit'] = 'Max files to scan (limit)';
+$_['entry_png_jpg_min_size'] = 'Min Size Threshold';
+$_['text_wm_rule_title'] = 'Watermark Rule';
+$_['button_close'] = 'Close';
+$_['text_ph_rule_title'] = 'Placeholder Rule';
+$_['entry_ph_image'] = 'Placeholder Image';
+$_['button_clear_cache_wm'] = 'Clear Cache (apply watermarks)';
+$_['text_active'] = 'Active';
+$_['text_error'] = 'Not Active';
+$_['text_th_dupe_delete'] = 'File to delete (Duplicate)';
+$_['text_th_dupe_keep'] = 'File to keep (Original)';
+$_['text_dupe_original_label'] = 'Original';
+$_['text_dupe_duplicate_label'] = 'Duplicate';
+
+$_['text_disk_usage'] = 'Disk Usage';
+$_['text_cumulative_stats'] = 'Optimization Statistics';
+$_['text_disk_catalog'] = 'Catalog images';
+$_['text_disk_cache'] = 'OC Cache';
+$_['text_disk_trash'] = 'Module trash';
+$_['text_disk_other'] = 'Other';
+$_['text_disk_free'] = 'Free';
+$_['text_total_files_opt'] = 'Files optimized';
+$_['text_total_saved'] = 'Total saved';
+$_['text_avg_saving_pct'] = 'Avg. savings';
+$_['text_stats_hint'] = 'Statistics accumulate across all optimization sessions (stored locally)';
+$_['text_reset_stats'] = 'Reset statistics';
+$_['text_reset_stats_confirm']= 'Are you sure you want to reset optimization statistics?';
+$_['text_export_import'] = 'Export / Import Settings';
+$_['button_export_settings'] = 'Export settings (JSON)';
+$_['button_import_settings'] = 'Import settings';
+$_['help_export_settings'] = 'Download all module settings as a JSON file';
+$_['help_import_settings'] = 'Upload a previously exported JSON to restore settings';
+$_['text_import_success'] = 'Settings imported successfully. Reload the page to apply.';
+$_['error_import_file'] = 'No file uploaded or upload error';
+$_['error_import_invalid'] = 'Invalid settings file (wrong module or format)';
+$_['text_click_to_load'] = 'Click ↑ to load disk usage';
+$_['tab_formats'] = 'Image Formats';
+
+$_['text_disk_quota_legend'] = 'Hosting Disk Quota Limits (manual)';
+$_['text_disk_quota_desc'] = 'If the disk space graph shows incorrect data (the server\'s physical partition instead of your hosting account limit), you can specify your hosting quota limits manually. Enter 0 for auto-detect.';
+$_['entry_disk_quota_total'] = 'Total Hosting Disk Space (GB)';
+$_['entry_disk_quota_used'] = 'Total Hosting Used Disk Space (GB)';
+$_['entry_auto_disk'] = 'Auto-update on page load';
+
+// Cron Tasks
+$_['entry_cron_task_name'] = 'Task Name';
+$_['entry_cron_task_summary'] = 'Compression settings';
+$_['button_add_cron_task'] = 'Add Cron Task';
+$_['button_copy_cron_url'] = 'Copy URL';
+$_['text_cron_recommended_url'] = 'Cron task execution URL:';
+
+// Licensing
+$_['text_license_required'] = 'License Key Required';
+$_['text_license_required_desc'] = 'Please enter your license key to activate the module. All configuration and optimization features are locked until a valid key is provided.';
+$_['entry_token_desc'] = 'Enter the activation token issued for your root domain. You can obtain it from your purchase page or by contacting support.';
+$_['button_activate'] = 'Activate Module';
+$_['text_support'] = 'Technical Support';
+$_['text_support_desc'] = 'If you don\'t have a key yet, or face issues, please provide your order ID and domain name.';
+
+// New settings
+$_['entry_wm_max_width'] = 'Watermark Max Width (px)';
+$_['entry_wm_max_height'] = 'Watermark Max Height (px)';
+$_['help_wm_max_width'] = 'If the source watermark image exceeds this width, it will be automatically scaled down. Set 0 or leave empty for default 800px.';
+$_['help_wm_max_height'] = 'If the source watermark image exceeds this height, it will be automatically scaled down. Set 0 or leave empty for default 800px.';
+$_['entry_disk_status'] = 'Enable Disk Space Scan & Graph';
+$_['help_disk_status'] = 'If enabled, the module will periodically scan the image folder size and display a pie chart. Disable this on large sites to improve settings page load speed.';
+$_['text_disk_settings_title'] = 'Disk Limits & Scanning Settings';
+
diff --git a/upload/admin/language/english/module/img_opti.php b/upload/admin/language/english/module/img_opti.php
new file mode 100644
index 0000000..16f7e4e
--- /dev/null
+++ b/upload/admin/language/english/module/img_opti.php
@@ -0,0 +1,565 @@
+Image Optimizer [NAT]: Compress Originals & Delete Junk';
+$_['text_extension'] = 'Extensions';
+$_['text_edit'] = 'Module Settings';
+$_['text_success'] = 'Settings have been successfully saved!';
+$_['entry_warning'] = 'WARNING: Always make a backup of your files, especially the /image/catalog folder before starting!';
+$_['text_tech'] = 'Server Technology Check:';
+$_['text_author'] = 'Technical support and suggestions: info@nat.od.ua';
+$_['tab_optimize'] = 'Optimization & Resize';
+$_['tab_cleaner'] = 'Clean Unused Images';
+$_['tab_info'] = 'Info / Ecosystem [NAT]';
+$_['tab_settings'] = 'Settings';
+$_['tab_tools'] = 'Tools (pro)';
+$_['tab_cron'] = 'Cron Jobs';
+$_['entry_mode'] = 'Operation Mode';
+$_['text_copy'] = 'Create a copy (Safe)';
+$_['text_replace'] = 'Replace originals (Requires backup!)';
+$_['entry_folder'] = 'Folder name for copy';
+$_['entry_batch'] = 'Files per batch';
+$_['entry_log_limit'] = 'Log lines per page';
+$_['entry_max_width'] = 'Max width (px) [0 - no limit]';
+$_['entry_max_height'] = 'Max height (px) [0 - no limit]';
+$_['entry_jpg'] = 'JPG Quality (0-100)';
+$_['entry_png'] = 'PNG Compression (0-9)';
+$_['entry_webp'] = 'WebP Quality (0-100)';
+$_['entry_targets'] = 'What to optimize?';
+$_['text_all_catalog'] = '[ Entire image/catalog ]';
+$_['text_root_files'] = 'Files only in image/catalog root';
+$_['entry_threshold'] = 'Replacement threshold (%)';
+$_['help_threshold'] = '0: replace only if file is smaller. 10: allow up to 10% file size increase.';
+$_['entry_token'] = 'Activation Token (Root Domain)';
+$_['entry_status'] = 'Module Status';
+$_['entry_menu_position'] = 'Menu Position [NAT]';
+$_['text_menu_module_only'] = 'Modules List Only';
+$_['text_menu_header'] = 'Top Header';
+$_['text_menu_sidebar'] = 'Sidebar Menu';
+$_['text_menu_both'] = 'Both (Header + Sidebar)';
+$_['text_status_on'] = 'Enabled';
+$_['text_status_off'] = 'Disabled';
+$_['entry_engine'] = 'Image Processing Engine';
+$_['text_engine_gd'] = 'GD (Standard)';
+$_['text_engine_imagick'] = 'Imagick (High quality, sRGB profile keeping)';
+$_['entry_license'] = 'License';
+$_['text_cleaner_info'] = 'This tool looks for files that physically exist on the server but are not linked in the database or template files.';
+$_['entry_clean_targets'] = 'Folders to scan for junk?';
+$_['entry_extended_log'] = 'Extended log (show used photos and their locations)';
+$_['entry_process_broken'] = 'Include files with broken encoding ("?" in names)';
+$_['button_scan_trash'] = 'FIND JUNK FILES';
+$_['button_quarantine'] = 'Move to Quarantine (Safe)';
+$_['button_delete_trash'] = 'Delete Permanently';
+$_['button_empty_quarantine'] = 'Empty Quarantine Folder';
+$_['button_restore_quarantine'] = 'Restore from Quarantine';
+$_['text_scan_result'] = 'Scan Results';
+$_['text_trash_files'] = 'Junk files found: ';
+$_['text_trash_size'] = 'Potential space to free up: ';
+$_['button_start'] = 'START OPTIMIZATION';
+$_['button_pause'] = 'PAUSE';
+$_['button_stop'] = 'STOP';
+$_['button_continue'] = 'CONTINUE';
+$_['button_start_new'] = 'START (New Run)';
+$_['button_download_log'] = 'Download TXT Log';
+$_['button_download'] = 'Download';
+$_['button_save'] = 'Save';
+$_['button_cancel'] = 'Cancel';
+$_['text_progress'] = 'Progress:';
+$_['text_old_size'] = 'Before:';
+$_['text_new_size'] = 'After:';
+$_['text_savings'] = 'Saved:';
+$_['text_detailed_log'] = 'Detailed Log';
+$_['text_page'] = 'Page';
+$_['text_waiting'] = 'Waiting to start...';
+$_['text_enabled'] = 'Enabled';
+$_['text_disabled_error'] = 'Disabled (ERROR)';
+$_['text_supported'] = 'Supported';
+$_['text_no'] = 'No';
+$_['error_permission'] = 'You do not have permission to modify this module!';
+$_['error_license'] = 'Access Error: Invalid activation token for this domain!';
+$_['error_domain'] = ' (Domain: ';
+$_['text_found'] = 'Files found: ';
+$_['text_compressed'] = 'Compressed';
+$_['text_original'] = 'Original';
+$_['text_error_process'] = 'Processing error: ';
+$_['error_log_empty'] = 'Log is empty! Run the process first.';
+$_['text_report_header'] = '=== REPORT ===';
+$_['text_total_files'] = 'Total files: ';
+$_['text_searching'] = 'Searching for files...';
+$_['error_ajax'] = 'Error: ';
+$_['text_resumed'] = '▶ PROCESS RESUMED...';
+$_['text_paused'] = '⏸ PAUSED. Process suspended.';
+$_['confirm_stop'] = 'Are you sure you want to completely abort the process?';
+$_['confirm_delete'] = 'WARNING! Files will be permanently deleted from the server with no chance of recovery! Continue?';
+$_['confirm_empty_q'] = 'Are you sure you want to completely empty the quarantine folder?';
+$_['confirm_restore'] = 'All files from quarantine will be returned to their original locations. Continue?';
+$_['button_stopping'] = 'Stopping...';
+$_['text_stopped_user'] = '🛑 PROCESS ABORTED BY USER!';
+$_['text_increase'] = 'Increased';
+$_['text_done'] = '✅ DONE! Process completed.';
+$_['error_timeout'] = 'Server timeout. Auto-retrying in 3 seconds...';
+$_['text_building_index'] = 'Step 1/2: Indexing DB and templates (searching for used photos)...';
+$_['text_comparing'] = 'Step 2/2: Comparing physical files with the index...';
+$_['text_quarantine_done'] = '✅ Quarantined to image/catalog_trash/ successfully!';
+$_['text_deleted_done'] = '✅ Junk deletion completed successfully!';
+$_['text_quarantine_empty'] = '✅ Quarantine folder emptied successfully!';
+$_['text_restore_done'] = '✅ Files restored from quarantine to their original locations!';
+$_['text_quarantine_move'] = 'Quarantined: ';
+$_['text_deleted_file'] = 'Deleted: ';
+$_['text_no_trash'] = 'No junk found in selected folders! All files are in use.';
+$_['text_scanning_dirs'] = 'Scanning directories: ';
+$_['text_log_used'] = '[IN USE]';
+$_['text_log_trash'] = '[JUNK]';
+$_['text_log_deleted'] = '[DELETED]';
+$_['text_log_del_err'] = '[DELETE ERROR]';
+$_['text_log_quarantine'] = '[QUARANTINED]';
+$_['text_log_q_err'] = '[MOVE ERROR]';
+$_['text_log_restored'] = '[RESTORED]';
+$_['text_log_rest_err'] = '[RESTORE ERROR]';
+$_['text_used_in'] = 'in: ';
+$_['text_analyzing'] = 'Analyzing database...';
+$_['text_clean_empty'] = 'Clean! No junk found.';
+$_['text_processing'] = 'Processing...';
+$_['text_restoring'] = 'Restoring files...';
+$_['text_all_folders'] = 'image/catalog/ (And all subfolders)';
+$_['text_root_folder'] = 'image/catalog/ (Root only)';
+$_['text_q_empty_err'] = 'Quarantine folder is empty or does not exist.';
+$_['error_token'] = 'Invalid token. Please refresh the page.';
+$_['text_apply_title'] = 'Optimization completed in folder: ';
+$_['text_apply_info'] = 'Check the result. If everything looks good, apply changes to the main catalog.';
+$_['entry_apply_backup'] = 'Move and create a backup (in Quarantine)';
+$_['entry_apply_replace'] = 'Replace originals permanently';
+$_['button_apply_main'] = 'APPLY TO MAIN CATALOG';
+$_['button_delete_copies'] = 'Delete temporary copies (Cancel)';
+$_['button_full_cleanup'] = 'Full cleanup of all temp folders and backups';
+$_['text_apply_success'] = '✅ Files successfully moved. ';
+$_['text_apply_backup_ok'] = 'Originals saved in folder: ';
+$_['text_apply_no_backup'] = 'Originals deleted.';
+$_['text_error_source'] = 'Error: Folder with optimized files not found.';
+$_['confirm_full_cleanup'] = 'WARNING! This action will permanently delete ALL backup folders (backup_*) and temp copy folders. Continue?';
+$_['text_tools_desc'] = 'Global scanners and tools for file system and database image optimization.';
+$_['text_tool_select'] = 'Select Tool:';
+$_['text_group_scanners'] = 'Scanners & Analysis';
+$_['text_group_generators'] = 'Generators & Processing';
+$_['text_group_experimental'] = 'Experimental Features (BETA)';
+$_['text_experimental_warn'] = 'Warning: Experimental features modify system files or the database. Backup is highly recommended!';
+$_['text_tool_broken_db'] = 'Broken/Empty DB Image Scanner';
+$_['text_tool_watermark'] = 'Dynamic Watermark (Cache only)';
+$_['text_tool_duplicates'] = 'Duplicate Finder (MD5)';
+$_['text_tool_png_jpg'] = 'Heavy PNG to JPG Converter (non-transparent PNG scanner)';
+$_['text_tool_placeholders'] = 'Placeholder Generator (Replaces No Image)';
+$_['text_tool_exif'] = 'Clean EXIF Data (Geotags/Meta)';
+$_['text_tool_small_photos'] = 'Too Small Photo Scanner';
+$_['text_tool_html_broken'] = 'Broken Images in HTML';
+$_['text_tool_empty_folders'] = 'Remove Empty Folders (image/catalog)';
+$_['text_tool_smart_cache'] = 'Smart Cache Cleanup';
+$_['text_tool_folder_tree'] = 'Folder Tree Statistics';
+$_['text_tool_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load (No DB)';
+$_['text_tool_translit'] = 'File Name Transliteration';
+$_['text_tool_cache_restore'] = 'Restore Originals from Cache';
+$_['entry_wm_type'] = 'Watermark Type';
+$_['text_wm_image'] = 'Image (PNG)';
+$_['text_wm_text'] = 'Text';
+$_['entry_wm_image_path'] = 'Image Path';
+$_['entry_wm_text_val'] = 'Watermark Text';
+$_['entry_wm_position'] = 'Position (1-9)';
+$_['entry_wm_opacity'] = 'Opacity (0-100)';
+$_['entry_wm_category'] = 'Only for Categories';
+$_['entry_wm_brand'] = 'Only for Brands';
+$_['entry_min_width'] = 'Minimum Width (px)';
+$_['entry_min_height'] = 'Minimum Height (px)';
+$_['entry_ph_product'] = 'Placeholder for Products';
+$_['entry_ph_category'] = 'Placeholder for Categories';
+$_['entry_ph_brand'] = 'Placeholder for Brands';
+$_['button_run_tool'] = 'Run Tool';
+$_['button_scan_tool'] = 'Scan';
+$_['button_fix_tool'] = 'Fix Found';
+$_['text_th_path'] = 'File / Path';
+$_['text_th_size'] = 'Size';
+$_['text_th_action'] = 'Action';
+$_['text_th_status'] = 'Status';
+$_['text_th_problem'] = 'Problem / Value';
+$_['text_cron_title'] = 'Cron Settings';
+$_['text_cron_desc'] = 'Add the command to your hosting Cron for automatic background compression.';
+$_['entry_cron_new_only'] = 'Process only new (unprocessed) files';
+$_['entry_cron_folders'] = 'Folders to scan (leave empty for entire catalog)';
+$_['text_promo_title'] = 'Other [NAT] Series Modules';
+$_['text_promo_desc'] = '[NAT] Series Modules are tools for deep OpenCart optimization. We focus on automation, DB speed, and server cleanup. All modules are open-source and share the same core logic.';
+$_['text_support'] = 'Technical support and suggestions:';
+$_['button_more'] = 'View all extensions on OpenCart.com';
+$_['button_more_forum'] = 'Opencartforum';
+$_['text_mod_cat_opt'] = 'Catalog Optimizer [NAT]';
+$_['text_mod_cat_opt_desc'] = 'Hide empty categories/brands + SEO sorting (in stock first) + DB junk cleanup.';
+$_['text_mod_new_arrivals'] = 'New Arrivals [NAT]: Sort by exact date';
+$_['text_mod_new_arrivals_desc'] = 'Automated SEO page for new arrivals. Displays items strictly by the real date added. Sliders, grid, and smart date archive sidebar.';
+$_['text_mod_img_opt'] = 'Image Optimizer [NAT]';
+$_['text_mod_img_opt_desc'] = 'Current Module. Compress and resize ORIGINAL photos (image/catalog) to WebP + Server cleanup of unused images.';
+$_['text_mod_dom_scan'] = 'Domain Scanner [NAT]';
+$_['text_mod_dom_scan_desc'] = 'Find and clean external links and HTTP images in DB and theme files. Ideal for HTTPS migration or cleanup after parsing.';
+$_['text_mod_poll'] = 'Poll Widget [NAT]';
+$_['text_mod_poll_desc'] = 'Create polls and voting widgets with a visual design builder, deep statistics, and cheat protection.';
+$_['text_mod_db_opt'] = 'Database Optimizer [NAT]';
+$_['text_mod_db_opt_desc'] = 'Intelligent garbage cleanup in the database, system log management, and crucial index addition for speed boost.';
+$_['text_mod_im_ex'] = 'Import/Export [NAT]';
+$_['text_mod_im_ex_desc'] = 'Import/export prices and stock levels from/to files (xls, xlsx, csv, xml, json) or by url. Field mapping, Cron automation, text utilities.';
+$_['text_mod_admin_tools'] = 'Admin Tools [NAT]';
+$_['text_mod_admin_tools_desc'] = 'Handy administrator tools: login as customer, admin customization, user permissions management, and hiding unnecessary menu items.';
+$_['text_mod_utm_tracker'] = 'Order UTM Tracker [NAT]';
+$_['text_mod_utm_tracker_desc'] = 'Automatic tracking of UTM tags during checkout. Saves the traffic source, campaign, keywords, and displays this data in order details to analyze advertising effectiveness.';
+$_['text_info_title_promo_top_bar'] = 'Promo Top Bar [NAT]: Upper promotional info-banner, information bar above header, marquee and top-banner';
+$_['text_info_desc_promo_top_bar'] = 'Create interactive promo bars and header slideshows with a block builder, countdown timers, subscription forms, and flexible targeting.';
+$_['text_tool_no_image'] = 'Products without main image (empty in DB)';
+$_['text_problem_main_broken'] = 'Broken main image (file deleted)';
+$_['text_problem_add_broken'] = 'Broken additional image (file deleted)';
+$_['text_problem_no_image'] = 'Missing main image';
+$_['text_err_dir'] = 'Error: Directory not found or invalid path.';
+$_['text_err_no_queue'] = 'Error: No queue found. Run scan first.';
+$_['text_log_del_empty_folder'] = 'Deleted empty folder: ';
+$_['text_log_no_empty_folders'] = 'No empty folders found in image/catalog/.';
+$_['text_log_total_empty_folders'] = 'Total empty folders removed: ';
+$_['text_log_cache_cleared'] = 'Image cache has been cleared.';
+$_['text_log_wm_smart_mode'] = 'Watermarks are operating in SMART mode: originals remain untouched, applied only on cache generation.';
+$_['text_log_scan_dupes'] = 'Scanning image/catalog/ for MD5 duplicates... This may take a while.';
+$_['text_log_found_dupe'] = 'Found duplicate: ';
+$_['text_log_no_dupes'] = 'No duplicates found.';
+$_['text_log_total_dupes'] = 'Found duplicates: %s. Potential savings: %s';
+$_['text_log_fix_dupes_prompt'] = 'Click "Fix Found" to redirect DB links to original and delete duplicates.';
+$_['text_log_fixed_dupe'] = 'Fixed and removed duplicate: ';
+$_['text_log_success_dupes'] = 'Successfully fixed %s duplicates. DB updated.';
+$_['text_log_scan_png'] = 'Scanning for heavy PNG files without transparency...';
+$_['text_log_no_png'] = 'No suitable PNG files found.';
+$_['text_log_total_png'] = 'Found %s PNG without transparency. Click "Fix Found" to convert to JPG.';
+$_['text_log_converted_jpg'] = 'Converted to JPG: ';
+$_['text_log_success_png'] = 'Successfully converted %s files. Space saved: %s';
+$_['text_log_ph_prod'] = 'Updated %s Products with placeholder.';
+$_['text_log_ph_cat'] = 'Updated %s Categories with placeholder.';
+$_['text_log_ph_brand'] = 'Updated %s Brands with placeholder.';
+$_['text_log_ph_empty'] = 'No placeholders selected in settings.';
+$_['text_log_scan_exif'] = 'Scanning for EXIF data...';
+$_['text_log_stripped_exif'] = 'Stripped EXIF: ';
+$_['text_log_no_exif'] = 'No EXIF data found to clean.';
+$_['text_log_success_exif'] = 'Cleaned EXIF from %s files. Saved: %s';
+$_['text_log_scan_small'] = 'Scanning for photos smaller than %sx%s px...';
+$_['text_log_too_small'] = 'Too small (%sx%s): ';
+$_['text_log_no_small'] = 'No small photos found.';
+$_['text_log_total_small'] = 'Found %s small photos. Please review and re-upload.';
+$_['text_log_html_broken'] = 'Broken IMG in HTML table %s (ID: %s): ';
+$_['text_log_no_html_broken'] = 'No broken images found in HTML descriptions.';
+$_['text_log_total_html_broken'] = 'Found %s broken links in HTML. Please edit them manually.';
+$_['text_log_scan_smart_cache'] = 'Scanning image/cache/ for orphaned files...';
+$_['text_log_del_orph_cache'] = 'Deleted orphaned cache: ';
+$_['text_log_no_orph_cache'] = 'No orphaned cache files found.';
+$_['text_log_success_smart_cache'] = 'Removed %s orphaned cache files. Space saved: %s';
+$_['text_log_scan_translit'] = 'Scanning for files with cyrillic or spaces...';
+$_['text_log_no_bad_names'] = 'No files with bad names found.';
+$_['text_log_total_bad_names'] = 'Found %s bad files. Click "Fix Found" to transliterate and update DB.';
+$_['text_log_renamed'] = 'Renamed: ';
+$_['text_log_success_translit'] = 'Successfully transliterated %s files and updated DB.';
+$_['text_log_scan_restore'] = 'Scanning image/cache/ to restore lost originals...';
+$_['text_log_restored_cache'] = 'Restored from cache: ';
+$_['text_log_no_lost_orig'] = 'No missing originals found in cache.';
+$_['text_log_success_restore'] = 'Successfully restored %s original images from cache.';
+$_['error_invalid_folder'] = 'Error: Invalid folder name!';
+$_['text_formats_title'] = 'Modern Formats (WebP & SVG)';
+$_['text_formats_desc'] = 'Settings for integrating modern formats into the OpenCart file system and templates.';
+$_['entry_support_svg'] = 'SVG Upload Support';
+$_['help_support_svg'] = 'Allows uploading vector SVG images via the standard Filemanager and safely displaying them on the storefront without size distortion.';
+$_['entry_support_webp'] = 'WebP Upload Support';
+$_['help_support_webp'] = 'Allows manually uploading, linking, and displaying ready .webp files on the website via the Filemanager.';
+$_['entry_truefilemanager_webp'] = 'WebP in TrueFileManager';
+$_['help_truefilemanager_webp'] = 'Adds WebP upload, preview, and thumbnail support to sitecreator\'s TrueFileManager (if installed).';
+$_['entry_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load';
+$_['help_webp_on_fly'] = 'Ultra-fast replacement: automatically changes .jpg/.png extensions to .webp directly in the storefront HTML output and adds the loading="lazy" attribute. Does not touch the database!';
+$_['help_tool_broken_db'] = 'Finds images in the database whose physical files have been deleted from the server. Clears broken links.';
+$_['help_tool_empty_folders'] = 'Recursively scans image/catalog/ and safely removes all folders that contain no files.';
+$_['help_tool_folder_tree'] = 'Builds a directory tree and shows the real size of each folder to find space eaters.';
+$_['help_tool_html_broken'] = 'Scans HTML descriptions of products and categories for tags with non-existent images.';
+$_['help_tool_small_photos'] = 'Finds pictures with resolution below the specified one. Added search links to Google and Yandex Images so you can easily find high-quality replacements.';
+$_['help_tool_duplicates'] = 'Searches for exact duplicates by MD5 hash (different names, same image). Merges them into one file and updates the DB.';
+$_['help_tool_watermark'] = 'Applies watermarks in SMART mode (only to storefront cache, originals remain unchanged).';
+$_['help_tool_placeholders'] = 'Massively sets the selected placeholder image for products and categories that have no photo.';
+$_['help_tool_png_jpg'] = 'Scans for heavy PNG files without transparency (alpha channel). Since they do not need transparency, they can be safely converted to lightweight JPG. This greatly reduces file size (typically by 70-80%) and speeds up your website, automatically updating all links in the database.';
+$_['help_tool_exif'] = 'Scans JPEG files and removes hidden EXIF data (geotags, camera data), reducing size by 10-15%.';
+$_['help_tool_smart_cache'] = 'Finds cached files in image/cache/ whose originals have already been deleted, and removes this trash.';
+$_['help_tool_translit'] = 'Finds files with Cyrillic or spaces, renames them using transliteration, and updates paths in the DB.';
+$_['help_tool_cache_restore'] = 'If you accidentally deleted an original from catalog, this tool will try to find its copy in cache and restore it.';
+$_['entry_quarantine'] = 'Move originals to Quarantine (instead of deleting)';
+$_['entry_backup_db'] = 'Create database backup of modified tables in SQL before starting';
+$_['button_fix_selected'] = 'FIX SELECTED';
+$_['button_export_log'] = 'Export to CSV';
+$_['text_th_before'] = 'State BEFORE';
+$_['text_th_after'] = 'Expected result AFTER';
+$_['text_th_select'] = 'Action';
+$_['text_th_preview'] = 'Preview';
+$_['text_th_name'] = 'Object / File';
+$_['text_global_action'] = 'Bulk action for selected:';
+$_['text_action_clear'] = 'Clear DB Link';
+$_['text_action_disable'] = 'Disable (Status = 0)';
+$_['text_action_stock'] = 'Set Out of Stock';
+$_['text_action_placeholder'] = 'Set Placeholder';
+$_['text_action_ignore'] = 'Do nothing';
+$_['text_action_merge'] = 'Merge (delete duplicate, keep original)';
+$_['text_action_convert'] = 'Convert to JPG';
+$_['text_action_rename'] = 'Rename to translit';
+$_['text_action_delete'] = 'Delete';
+$_['text_action_restore'] = 'Restore';
+$_['text_db_backup_created'] = 'Database backup created: %s';
+$_['text_quarantine_created'] = 'Quarantined to: %s';
+$_['text_fix_status_success'] = 'Successfully executed';
+$_['text_fix_status_error'] = 'Error: %s';
+$_['text_log_exported'] = 'Log successfully exported!';
+$_['text_select_action'] = '-- Select Action --';
+$_['error_empty_selection'] = 'Error: No rows selected for fixing!';
+$_['text_link_admin'] = 'Admin';
+$_['text_link_catalog'] = 'Store';
+$_['entry_threads'] = 'Processing Threads';
+$_['text_threads_optimal'] = 'optimal';
+$_['button_download_zip'] = 'Download ZIP and Delete Folder';
+$_['confirm_download_and_delete'] = 'Did the ZIP download successfully? Can we delete the temporary copies folder on the server?';
+$_['button_clear_backups'] = 'Delete DB Backups';
+$_['button_clear_backups_short'] = 'Clear Backups';
+$_['confirm_clear_backups'] = 'Are you sure you want to delete all SQL database backups?';
+$_['text_compare_title'] = 'Compare Images BEFORE / AFTER';
+$_['text_compare_before'] = 'BEFORE';
+$_['text_compare_after'] = 'AFTER';
+$_['text_backups_cleared'] = 'All database backups deleted successfully!';
+$_['error_no_backups'] = 'No backups found or folder is empty.';
+$_['button_restore'] = 'Restore';
+$_['button_empty'] = 'Clear';
+$_['entry_wm_targets'] = 'Apply watermark to:';
+$_['text_wm_target_product'] = 'Product Photos';
+$_['text_wm_target_category'] = 'Category Photos';
+$_['text_wm_target_brand'] = 'Brand Logos';
+$_['text_wm_target_banner'] = 'Banners and Slides';
+$_['text_wm_target_blog'] = 'Articles / Blog';
+$_['error_no_scan_data'] = 'Error: No scan data found. Please run scan first!';
+$_['error_row_not_found'] = 'Error: Row not found in scan results!';
+$_['error_file_not_found'] = 'Error: File not found on server!';
+$_['text_folder_rules_title'] = 'Individual Folder Rules';
+$_['text_folder_rules_desc'] = 'You can override compression quality and maximum sizes for specific folders. Leave a field blank to use global settings. The path is relative to the image/ directory (e.g. catalog/banners/).';
+$_['entry_folder_path'] = 'Folder Path';
+$_['entry_jpg_quality'] = 'JPG Quality';
+$_['entry_webp_quality'] = 'WebP Quality';
+$_['button_add_rule'] = 'Add Rule';
+$_['button_remove'] = 'Remove';
+$_['entry_wm_angle'] = 'Watermark Rotation Angle (degrees)';
+$_['entry_wm_corner_radius'] = 'Watermark Image Corner Radius (px)';
+$_['entry_wm_size_type'] = 'Watermark Scaling';
+$_['entry_wm_size_percent'] = 'Watermark Size (% of image width)';
+$_['entry_wm_text_color'] = 'Watermark Text Color';
+$_['entry_wm_text_font'] = 'Watermark Font (TTF)';
+$_['entry_wm_filter_mode'] = 'Categories/Brands Filter Mode';
+$_['text_wm_filter_include'] = 'Only for Selected';
+$_['text_wm_filter_exclude'] = 'For All Except Selected';
+$_['text_wm_size_original'] = 'Original Size';
+$_['text_wm_size_percent'] = 'Proportional to image width (%)';
+$_['button_preview_watermark'] = 'Preview Watermark';
+$_['text_wm_preview_title'] = 'Watermark Preview';
+$_['text_cron_exclude_folders'] = 'Exclude folders from scan';
+$_['text_cron_entities'] = 'Which photo types to process?';
+$_['text_cron_quality_override'] = 'Override compression settings for Cron';
+$_['text_cron_recommendations'] = 'Recommended Cron Schedule';
+$_['text_cron_recommendations_desc'] = '
0 * * * *).0 */6 * * *).0 2 * * *).';
+$_['text_tool_broken_files'] = 'Server Broken/Empty Files Scanner';
+$_['help_tool_broken_files'] = 'Scans physical files in image/catalog/ for corrupted images or empty files of 0 bytes.';
+$_['text_grid_broken_file'] = 'Broken or empty file on server';
+$_['text_grid_broken_file_after'] = 'Delete or replace with placeholder';
+$_['text_description_broken_files'] = 'Scanning results of physical files in image/catalog/. Shows broken (corrupted) images or files with 0 bytes size.';
+$_['text_action_strip'] = 'Strip EXIF';
+$_['text_action_set_placeholder'] = 'Set Placeholder';
+$_['text_th_entity'] = 'Entity / Related Links';
+$_['text_cron_entity_product'] = 'Products';
+$_['text_cron_entity_category'] = 'Categories';
+$_['text_cron_entity_manufacturer'] = 'Manufacturers / Brands';
+$_['text_cron_entity_banner'] = 'Banners';
+$_['text_cron_entity_blog'] = 'Blog / Articles';
+$_['entry_cron_jpg_quality'] = 'JPG Quality (Cron)';
+$_['entry_cron_webp_quality'] = 'WebP Quality (Cron)';
+$_['entry_cron_max_width'] = 'Max Width px (Cron)';
+$_['entry_cron_max_height'] = 'Max Height px (Cron)';
+$_['text_cron_folder_scan'] = 'Folders to scan';
+$_['text_no_fonts'] = 'No TTF fonts found in system/library/font/';
+$_['button_apply'] = 'Apply';
+$_['text_backup_will_be_created'] = 'A database backup will be automatically created before running this action.';
+$_['text_bulk_action'] = 'Bulk action for selected';
+$_['text_grid_results'] = 'Scanner Results & Details';
+$_['text_quarantine_will_be_created'] = 'Original files will be moved to quarantine (image/catalog_trash/) before modification.';
+$_['text_success_apply'] = 'Changes successfully applied!';
+$_['text_description_broken'] = 'Database scan results for broken links (table records referencing non-existent files).';
+$_['text_description_cache_restore'] = 'Results of search for cache files whose originals are missing in the catalog.';
+$_['text_description_duplicates'] = 'Results of search for exact duplicate images by MD5 hash.';
+$_['text_description_empty_folders'] = 'List of empty folders in image/catalog/ directory for deletion.';
+$_['text_description_exif'] = 'Results of search for images containing EXIF metadata that can be stripped.';
+$_['text_description_folder_tree'] = 'Folder size statistics in image/catalog/ directory.';
+$_['text_description_html_broken'] = 'Results of search for non-existent image files referenced in HTML descriptions.';
+$_['text_description_placeholders'] = 'Results of search for entities without images to fill with placeholders.';
+$_['text_description_png_jpg'] = 'Results of search for heavy PNG images without transparency that can be compressed to JPG.';
+$_['text_description_small_photos'] = 'List of images with dimensions smaller than the minimum specified (%sx%s px).';
+$_['text_description_smart_cache'] = 'Results of search for cache files whose original images have already been deleted.';
+$_['text_description_translit'] = 'Results of search for files with Cyrillic characters or spaces in their names.';
+$_['text_grid_broken_db_after'] = 'Clear link or set placeholder';
+$_['text_grid_dupe_after'] = 'Original (remains)';
+$_['text_grid_dupe_before'] = 'Duplicate (will be deleted)';
+$_['text_grid_empty_folder'] = 'Empty folder';
+$_['text_grid_empty_folder_after'] = 'Delete empty folder';
+$_['text_grid_exif_after'] = 'Strip metadata';
+$_['text_grid_exif_before'] = 'Contains EXIF';
+$_['text_grid_html_after'] = 'Fix link manually';
+$_['text_grid_missing_file'] = 'File is missing';
+$_['text_grid_missing_html'] = 'Image not found in description';
+$_['text_grid_missing_orig'] = 'Original is missing';
+$_['text_grid_missing_orig_after'] = 'Restore original from cache';
+$_['text_grid_no_image'] = 'No image (empty)';
+$_['text_grid_orph_cache'] = 'Orphaned cache';
+$_['text_grid_orph_cache_after'] = 'Delete unneeded cache';
+$_['text_grid_ph_brand_after'] = 'Set brand placeholder';
+$_['text_grid_ph_category_after'] = 'Set category placeholder';
+$_['text_grid_ph_product_after'] = 'Set product placeholder';
+$_['text_grid_small_photo_after'] = 'Recommended to replace with larger one';
+$_['text_grid_too_small'] = 'Too small';
+$_['text_no_relations'] = 'No database relations';
+$_['text_action_archive'] = 'Archive (to ZIP)';
+$_['text_action_archive_delete'] = 'Archive + Delete';
+$_['text_tool_sanitizer'] = 'File Name Sanitizer';
+$_['help_tool_sanitizer'] = 'Bulk cleanup of file and folder names from Cyrillic, spaces and special characters, normalization of extensions.';
+$_['text_description_sanitizer'] = 'Results of file name analysis for sanitization based on selected rules.';
+$_['entry_sz_translit'] = 'Transliterate (Cyrillic to Latin)';
+$_['entry_sz_spaces'] = 'Replace spaces with "_"';
+$_['entry_sz_special'] = 'Remove special characters';
+$_['entry_sz_lowercase_name'] = 'Lowercase file name';
+$_['entry_sz_lowercase_ext'] = 'Lowercase extension';
+$_['entry_sz_normalize_ext'] = 'Normalize extension (jpeg -> jpg)';
+$_['entry_cron_folder_mode'] = 'Folder filtering mode';
+$_['text_cron_folder_mode_exclude'] = 'Scan all folders except selected (Exceptions)';
+$_['text_cron_folder_mode_include'] = 'Scan only selected folders';
+$_['button_select_all'] = 'Select All';
+$_['button_deselect_all'] = 'Deselect All';
+$_['entry_wm_upload_font'] = 'Upload custom TTF font';
+$_['button_upload_font'] = 'Upload Font';
+$_['text_success_font_upload'] = 'Font successfully uploaded!';
+$_['error_font_upload'] = 'Error uploading font! Only .ttf files up to 5 MB are allowed.';
+$_['entry_lazy_load'] = 'Progressive Lazy Load';
+$_['help_lazy_load'] = 'Enables a premium progressive "blur-up" lazy loading on the frontend. Images are replaced with tiny blurred versions and loaded fully when scrolled into view.';
+$_['confirm_replace_mode'] = 'WARNING! You have selected the "Replace originals" mode. All images will be overwritten directly on the server. We strongly recommend making a backup first. Are you sure you want to continue?';
+$_['text_compare'] = 'Compare';
+$_['text_home'] = 'Home';
+$_['text_tech_gd'] = 'GD Library';
+$_['text_tech_imagick'] = 'Imagick';
+$_['text_tech_webp'] = 'WebP Support';
+$_['text_position_1'] = '1 - Top Left';
+$_['text_position_2'] = '2 - Top Center';
+$_['text_position_3'] = '3 - Top Right';
+$_['text_position_4'] = '4 - Middle Left';
+$_['text_position_5'] = '5 - Center';
+$_['text_position_6'] = '6 - Middle Right';
+$_['text_position_7'] = '7 - Bottom Left';
+$_['text_position_8'] = '8 - Bottom Center';
+$_['text_position_9'] = '9 - Bottom Right';
+
+$_['entry_log_level'] = 'Log Level';
+$_['text_log_changed_only'] = 'Changed files only';
+$_['text_log_all'] = 'All files (verbose)';
+$_['text_skipped_no_gain'] = 'Skipped (no improvement)';
+$_['text_reset_cache_hint'] = 'Reset optimization cache (force full rescan)';
+$_['confirm_reset_cache'] = 'Reset optimization cache? Next scan will check ALL files again.';
+$_['text_cache_cleared'] = 'Optimization cache cleared. Next scan will check all files.';
+$_['error_wm_preview'] = 'Failed to generate watermark preview.';
+$_['error_wm_too_large'] = 'Watermark image is too large (%dx%d px)! Maximum allowed size is %dx%d px.';
+$_['text_apply_errors_warning'] = 'WARNING: Some files could not be replaced (locked or permission denied):';
+$_['text_action_compress'] = 'Compress Images';
+$_['text_estimated'] = 'Estimated';
+$_['entry_min_savings_bytes'] = 'Min savings (bytes)';
+$_['entry_min_savings_percent'] = 'Min savings (%)';
+$_['text_optimized'] = 'Optimized';
+$_['entry_broken_fallback'] = 'Broken Image Fallback';
+$_['help_broken_fallback'] = 'Automatically replaces missing or corrupted catalog images on the storefront with a fallback image.';
+$_['entry_fallback_image'] = 'Fallback Image';
+$_['help_fallback_image'] = 'Select a custom image to display when a product or category image is broken. Defaults to no_image.png.';
+$_['text_tool_cmyk'] = 'CMYK to RGB Converter';
+$_['help_tool_cmyk'] = 'Convert JPEG images from CMYK color profile to sRGB to ensure correct display on Safari and iOS devices.';
+$_['text_tool_heavy_files'] = 'Top Heavy Files Scanner';
+$_['help_tool_heavy_files'] = 'Scan and find the heaviest original files in catalog directory and optimize them directly.';
+$_['entry_heavy_files_limit'] = 'Max files to find (total limit)';
+$_['text_action_convert_rgb'] = 'Convert to RGB';
+$_['text_description_cmyk'] = 'CMYK JPEGs found. Conversion to sRGB guarantees they are displayed correctly on all platforms, including iOS/macOS.';
+$_['text_description_heavy'] = 'List of the top %d heaviest files in the image directory. You can optimize them here directly.';
+$_['text_cron_folder_hint_exclude_all'] = 'Status: All folders will be scanned (no exclusions).';
+$_['text_cron_folder_hint_exclude_some'] = 'Status: All folders will be scanned EXCEPT the %d checked folders.';
+$_['text_cron_folder_hint_include_some'] = 'Status: Only the %d checked folders will be scanned.';
+$_['text_cron_folder_hint_include_none'] = 'WARNING: No folders are selected! The Cron job will scan nothing.';
+$_['text_action_apply_changes'] = 'Apply specified changes';
+$_['entry_wm_status'] = 'Watermark Status';
+$_['text_disabled'] = 'Disabled';
+$_['entry_rule_name'] = 'Rule Name';
+$_['entry_rule_status'] = 'Rule Status';
+$_['entry_action'] = 'Action';
+$_['entry_ph_status'] = 'Placeholder Status';
+$_['entry_sanitizer_limit'] = 'Max files to scan (limit)';
+$_['entry_heavy_files_min_size'] = 'Min Size Threshold';
+$_['entry_png_jpg_limit'] = 'Max files to scan (limit)';
+$_['entry_png_jpg_min_size'] = 'Min Size Threshold';
+$_['text_wm_rule_title'] = 'Watermark Rule';
+$_['button_close'] = 'Close';
+$_['text_ph_rule_title'] = 'Placeholder Rule';
+$_['entry_ph_image'] = 'Placeholder Image';
+$_['button_clear_cache_wm'] = 'Clear Cache (apply watermarks)';
+$_['text_active'] = 'Active';
+$_['text_error'] = 'Not Active';
+$_['text_th_dupe_delete'] = 'File to delete (Duplicate)';
+$_['text_th_dupe_keep'] = 'File to keep (Original)';
+$_['text_dupe_original_label'] = 'Original';
+$_['text_dupe_duplicate_label'] = 'Duplicate';
+
+$_['text_disk_usage'] = 'Disk Usage';
+$_['text_cumulative_stats'] = 'Optimization Statistics';
+$_['text_disk_catalog'] = 'Catalog images';
+$_['text_disk_cache'] = 'OC Cache';
+$_['text_disk_trash'] = 'Module trash';
+$_['text_disk_other'] = 'Other';
+$_['text_disk_free'] = 'Free';
+$_['text_total_files_opt'] = 'Files optimized';
+$_['text_total_saved'] = 'Total saved';
+$_['text_avg_saving_pct'] = 'Avg. savings';
+$_['text_stats_hint'] = 'Statistics accumulate across all optimization sessions (stored locally)';
+$_['text_reset_stats'] = 'Reset statistics';
+$_['text_reset_stats_confirm']= 'Are you sure you want to reset optimization statistics?';
+$_['text_export_import'] = 'Export / Import Settings';
+$_['button_export_settings'] = 'Export settings (JSON)';
+$_['button_import_settings'] = 'Import settings';
+$_['help_export_settings'] = 'Download all module settings as a JSON file';
+$_['help_import_settings'] = 'Upload a previously exported JSON to restore settings';
+$_['text_import_success'] = 'Settings imported successfully. Reload the page to apply.';
+$_['error_import_file'] = 'No file uploaded or upload error';
+$_['error_import_invalid'] = 'Invalid settings file (wrong module or format)';
+$_['text_click_to_load'] = 'Click ↑ to load disk usage';
+$_['tab_formats'] = 'Image Formats';
+
+$_['text_disk_quota_legend'] = 'Hosting Disk Quota Limits (manual)';
+$_['text_disk_quota_desc'] = 'If the disk space graph shows incorrect data (the server\'s physical partition instead of your hosting account limit), you can specify your hosting quota limits manually. Enter 0 for auto-detect.';
+$_['entry_disk_quota_total'] = 'Total Hosting Disk Space (GB)';
+$_['entry_disk_quota_used'] = 'Total Hosting Used Disk Space (GB)';
+$_['entry_auto_disk'] = 'Auto-update on page load';
+
+// Cron Tasks
+$_['entry_cron_task_name'] = 'Task Name';
+$_['entry_cron_task_summary'] = 'Compression settings';
+$_['button_add_cron_task'] = 'Add Cron Task';
+$_['button_copy_cron_url'] = 'Copy URL';
+$_['text_cron_recommended_url'] = 'Cron task execution URL:';
+
+// Licensing
+$_['text_license_required'] = 'License Key Required';
+$_['text_license_required_desc'] = 'Please enter your license key to activate the module. All configuration and optimization features are locked until a valid key is provided.';
+$_['entry_token_desc'] = 'Enter the activation token issued for your root domain. You can obtain it from your purchase page or by contacting support.';
+$_['button_activate'] = 'Activate Module';
+$_['text_support'] = 'Technical Support';
+$_['text_support_desc'] = 'If you don\'t have a key yet, or face issues, please provide your order ID and domain name.';
+
+// New settings
+$_['entry_wm_max_width'] = 'Watermark Max Width (px)';
+$_['entry_wm_max_height'] = 'Watermark Max Height (px)';
+$_['help_wm_max_width'] = 'If the source watermark image exceeds this width, it will be automatically scaled down. Set 0 or leave empty for default 800px.';
+$_['help_wm_max_height'] = 'If the source watermark image exceeds this height, it will be automatically scaled down. Set 0 or leave empty for default 800px.';
+$_['entry_disk_status'] = 'Enable Disk Space Scan & Graph';
+$_['help_disk_status'] = 'If enabled, the module will periodically scan the image folder size and display a pie chart. Disable this on large sites to improve settings page load speed.';
+$_['text_disk_settings_title'] = 'Disk Limits & Scanning Settings';
+
diff --git a/upload/admin/language/ru-ru/module/img_opti.php b/upload/admin/language/ru-ru/module/img_opti.php
new file mode 100644
index 0000000..28fd6a4
--- /dev/null
+++ b/upload/admin/language/ru-ru/module/img_opti.php
@@ -0,0 +1,565 @@
+Image Optimizer [NAT]: Сжатие оригиналов и удаление мусора';
+$_['text_extension'] = 'Расширения';
+$_['text_edit'] = 'Настройки модуля';
+$_['text_success'] = 'Настройки успешно сохранены!';
+$_['entry_warning'] = 'ВНИМАНИЕ: Обязательно сделайте бэкап файлов сайта и особенно папки /image/catalog перед началом работы!';
+$_['text_tech'] = 'Проверка технологий сервера:';
+$_['text_author'] = 'Техническая поддержка и пожелания: info@nat.od.ua';
+$_['tab_optimize'] = 'Оптимизация и Ресайз';
+$_['tab_cleaner'] = 'Очистка неиспользуемых изображений';
+$_['tab_info'] = 'Инфо / Экосистема [NAT]';
+$_['tab_settings'] = 'Настройки';
+$_['tab_tools'] = 'Инструменты (pro)';
+$_['tab_cron'] = 'Крон-задачи';
+$_['entry_mode'] = 'Режим работы';
+$_['text_copy'] = 'Создать копию (безопасно)';
+$_['text_replace'] = 'Заменять оригиналы (требует бэкап!)';
+$_['entry_folder'] = 'Имя папки для копии';
+$_['entry_batch'] = 'Файлов за проход';
+$_['entry_log_limit'] = 'Строк лога на экране';
+$_['entry_max_width'] = 'Макс. ширина (px) [0 - без лимита]';
+$_['entry_max_height'] = 'Макс. высота (px) [0 - без лимита]';
+$_['entry_jpg'] = 'Качество JPG (0-100)';
+$_['entry_png'] = 'Сжатие PNG (0-9)';
+$_['entry_webp'] = 'Качество WebP (0-100)';
+$_['entry_targets'] = 'Что оптимизировать?';
+$_['text_all_catalog'] = '[ Весь каталог image/catalog ]';
+$_['text_root_files'] = 'Файлы только в корне image/catalog';
+$_['entry_threshold'] = 'Порог замены (%)';
+$_['help_threshold'] = '0: заменять только если вес стал меньше. 10: разрешить увеличение веса до 10%.';
+$_['entry_token'] = 'Токен активации (на корень домена)';
+$_['entry_status'] = 'Статус модуля';
+$_['entry_menu_position'] = 'Позиция меню [NAT]';
+$_['text_menu_module_only'] = 'Только в модулях';
+$_['text_menu_header'] = 'В шапке (Header)';
+$_['text_menu_sidebar'] = 'В боковом меню (Sidebar)';
+$_['text_menu_both'] = 'Везде (Шапка + Сайдбар)';
+$_['text_status_on'] = 'Включено';
+$_['text_status_off'] = 'Отключено';
+$_['entry_engine'] = 'Движок обработки изображений';
+$_['text_engine_gd'] = 'GD (Стандарт)';
+$_['text_engine_imagick'] = 'Imagick (Высокое качество, сохранение профилей sRGB)';
+$_['entry_license'] = 'Лицензия';
+$_['text_cleaner_info'] = 'Этот инструмент ищет файлы, которые физически существуют на диске, но нигде не используются в базе данных или файлах шаблона.';
+$_['entry_clean_targets'] = 'В каких папках искать мусор?';
+$_['entry_extended_log'] = 'Расширенный лог (показать используемые фото и где они найдены)';
+$_['entry_process_broken'] = 'Учитывать файлы с битой кодировкой (имена с символом "?")';
+$_['button_scan_trash'] = 'НАЙТИ МУСОР';
+$_['button_quarantine'] = 'В Карантин (Безопасно)';
+$_['button_delete_trash'] = 'Удалить навсегда';
+$_['button_empty_quarantine'] = 'Очистить папку Карантина';
+$_['button_restore_quarantine'] = 'Вернуть из Карантина';
+$_['text_scan_result'] = 'Результаты сканирования';
+$_['text_trash_files'] = 'Мусорных файлов: ';
+$_['text_trash_size'] = 'Потенциально освободится: ';
+$_['button_start'] = 'СТАРТ ОПТИМИЗАЦИИ';
+$_['button_pause'] = 'ПАУЗА';
+$_['button_stop'] = 'ОСТАНОВИТЬ';
+$_['button_continue'] = 'ПРОДОЛЖИТЬ';
+$_['button_start_new'] = 'СТАРТ (Новый запуск)';
+$_['button_download_log'] = 'Скачать лог в TXT';
+$_['button_download'] = 'Скачать';
+$_['button_save'] = 'Сохранить';
+$_['button_cancel'] = 'Отмена';
+$_['text_progress'] = 'Прогресс:';
+$_['text_old_size'] = 'Было:';
+$_['text_new_size'] = 'Стало:';
+$_['text_savings'] = 'Экономия:';
+$_['text_detailed_log'] = 'Детальный лог';
+$_['text_page'] = 'Стр.';
+$_['text_waiting'] = 'Ожидание запуска...';
+$_['text_enabled'] = 'Включено';
+$_['text_disabled_error'] = 'Выключено (ОШИБКА)';
+$_['text_supported'] = 'Поддерживается';
+$_['text_no'] = 'Нет';
+$_['error_permission'] = 'У вас нет прав для управления модулем!';
+$_['error_license'] = 'Ошибка доступа: Неверный токен активации для этого домена!';
+$_['error_domain'] = ' (Домен: ';
+$_['text_found'] = 'Найдено файлов: ';
+$_['text_compressed'] = 'Сжат';
+$_['text_original'] = 'Оригинал';
+$_['text_error_process'] = 'Ошибка обработки: ';
+$_['error_log_empty'] = 'Лог пуст! Сначала запустите процесс.';
+$_['text_report_header'] = '=== ОТЧЕТ ===';
+$_['text_total_files'] = 'Всего файлов: ';
+$_['text_searching'] = 'Поиск файлов...';
+$_['error_ajax'] = 'Ошибка: ';
+$_['text_resumed'] = '▶ ПРОЦЕСС ВОЗОБНОВЛЕН...';
+$_['text_paused'] = '⏸ ПАУЗА. Процесс приостановлен.';
+$_['confirm_stop'] = 'Вы уверены, что хотите полностью прервать процесс?';
+$_['confirm_delete'] = 'ВНИМАНИЕ! Файлы будут удалены с сервера навсегда без возможности восстановления! Продолжить?';
+$_['confirm_empty_q'] = 'Вы уверены, что хотите полностью очистить папку карантина?';
+$_['confirm_restore'] = 'Все файлы из папки карантина будут возвращены на свои места. Продолжить?';
+$_['button_stopping'] = 'Остановка...';
+$_['text_stopped_user'] = '🛑 ПРОЦЕСС ПРЕРВАН ПОЛЬЗОВАТЕЛЕМ!';
+$_['text_increase'] = 'Увеличение';
+$_['text_done'] = '✅ ГОТОВО! Процесс завершен.';
+$_['error_timeout'] = 'Таймаут сервера. Автоповтор через 3 секунды...';
+$_['text_building_index'] = 'Шаг 1/2: Индексация БД и шаблонов (поиск используемых фото)...';
+$_['text_comparing'] = 'Шаг 2/2: Сравнение физических файлов с индексом...';
+$_['text_quarantine_done'] = '✅ Процесс переноса в image/catalog_trash/ завершен!';
+$_['text_deleted_done'] = '✅ Процесс удаления мусора завершен!';
+$_['text_quarantine_empty'] = '✅ Папка карантина успешно удалена с сервера!';
+$_['text_restore_done'] = '✅ Файлы успешно возвращены из карантина в рабочую папку!';
+$_['text_quarantine_move'] = 'В карантине: ';
+$_['text_deleted_file'] = 'Удален: ';
+$_['text_no_trash'] = 'В выбранных папках мусор не найден! Все файлы используются.';
+$_['text_scanning_dirs'] = 'Сканируем директории: ';
+$_['text_log_used'] = '[ИСПОЛЬЗУЕТСЯ]';
+$_['text_log_trash'] = '[МУСОР]';
+$_['text_log_deleted'] = '[УДАЛЕНО]';
+$_['text_log_del_err'] = '[ОШИБКА УДАЛЕНИЯ]';
+$_['text_log_quarantine'] = '[В КАРАНТИН]';
+$_['text_log_q_err'] = '[ОШИБКА ПЕРЕНОСА]';
+$_['text_log_restored'] = '[ВОССТАНОВЛЕНО]';
+$_['text_log_rest_err'] = '[ОШИБКА ВОССТАНОВЛЕНИЯ]';
+$_['text_used_in'] = 'в: ';
+$_['text_analyzing'] = 'Анализ базы данных...';
+$_['text_clean_empty'] = 'Чисто! Мусора нет.';
+$_['text_processing'] = 'Обработка...';
+$_['text_restoring'] = 'Восстановление файлов...';
+$_['text_all_folders'] = 'image/catalog/ (И все вложенные)';
+$_['text_root_folder'] = 'image/catalog/ (Только корень)';
+$_['text_q_empty_err'] = 'Папка карантина пуста или не существует.';
+$_['error_token'] = 'Неверный токен. Перезагрузите страницу.';
+$_['text_apply_title'] = 'Оптимизация завершена в папку: ';
+$_['text_apply_info'] = 'Проверьте результат. Если всё устраивает — примените изменения к основному каталогу.';
+$_['entry_apply_backup'] = 'Перенести с созданием бэкапа (в Карантин)';
+$_['entry_apply_replace'] = 'Заменить оригиналы безвозвратно';
+$_['button_apply_main'] = 'ПРИМЕНИТЬ К ОСНОВНОМУ КАТАЛОГУ';
+$_['button_delete_copies'] = 'Удалить временные копии (отмена)';
+$_['button_full_cleanup'] = 'Полная очистка всех временных папок и бэкапов';
+$_['text_apply_success'] = '✅ Файлы успешно перенесены. ';
+$_['text_apply_backup_ok'] = 'Оригиналы сохранены в папку: ';
+$_['text_apply_no_backup'] = 'Оригиналы удалены.';
+$_['text_error_source'] = 'Ошибка: Папка с оптимизированными файлами не найдена.';
+$_['confirm_full_cleanup'] = 'ВНИМАНИЕ! Это действие безвозвратно удалит ВСЕ папки бэкапов (backup_*) и временные папки с копиями. Продолжить?';
+$_['text_tools_desc'] = 'Глобальные сканеры и инструменты для работы с файловой системой и БД изображений.';
+$_['text_tool_select'] = 'Выберите инструмент:';
+$_['text_group_scanners'] = 'Сканеры и Анализ';
+$_['text_group_generators'] = 'Генераторы и Обработка';
+$_['text_group_experimental'] = 'Экспериментальные функции (BETA)';
+$_['text_experimental_warn'] = 'Внимание: Экспериментальные функции затрагивают системные файлы или базу данных. Настоятельно рекомендуется сделать бэкап!';
+$_['text_tool_broken_db'] = 'Сканер битых/пустых фото БД';
+$_['text_tool_watermark'] = 'Динамический Вотермарк (Только на кэш)';
+$_['text_tool_duplicates'] = 'Поиск дубликатов файлов (по MD5)';
+$_['text_tool_png_jpg'] = 'Конвертер тяжелых PNG в JPG (сканер файлов без прозрачности)';
+$_['text_tool_placeholders'] = 'Генератор заглушек (Вместо No Image)';
+$_['text_tool_exif'] = 'Очистка EXIF-данных (Геотеги/Мета)';
+$_['text_tool_small_photos'] = 'Сканер слишком мелких фото';
+$_['text_tool_html_broken'] = 'Поиск битых картинок в HTML (Описаниях)';
+$_['text_tool_empty_folders'] = 'Очистка пустых папок (image/catalog)';
+$_['text_tool_smart_cache'] = 'Умная очистка кэша изображений';
+$_['text_tool_folder_tree'] = 'Дерево папок (Статистика веса)';
+$_['text_tool_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load (Без БД)';
+$_['text_tool_translit'] = 'Транслитерация названий файлов';
+$_['text_tool_cache_restore'] = 'Воскрешение оригиналов из Кэша';
+$_['entry_wm_type'] = 'Тип вотермарка';
+$_['text_wm_image'] = 'Изображение (PNG)';
+$_['text_wm_text'] = 'Текст';
+$_['entry_wm_image_path'] = 'Путь к изображению';
+$_['entry_wm_text_val'] = 'Текст вотермарка';
+$_['entry_wm_position'] = 'Позиция (1-9)';
+$_['entry_wm_opacity'] = 'Прозрачность (0-100)';
+$_['entry_wm_category'] = 'Только для Категорий';
+$_['entry_wm_brand'] = 'Только для Брендов';
+$_['entry_min_width'] = 'Минимальная ширина (px)';
+$_['entry_min_height'] = 'Минимальная высота (px)';
+$_['entry_ph_product'] = 'Заглушка для Товаров';
+$_['entry_ph_category'] = 'Заглушка для Категорий';
+$_['entry_ph_brand'] = 'Заглушка для Брендов';
+$_['button_run_tool'] = 'Запустить инструмент';
+$_['button_scan_tool'] = 'Сканировать';
+$_['button_fix_tool'] = 'Исправить найденное';
+$_['text_th_path'] = 'Файл / Путь';
+$_['text_th_size'] = 'Размер';
+$_['text_th_action'] = 'Действие';
+$_['text_th_status'] = 'Статус';
+$_['text_th_problem'] = 'Проблема / Значение';
+$_['text_cron_title'] = 'Настройка Cron';
+$_['text_cron_desc'] = 'Добавьте команду в Cron хостинга для автоматического фонового сжатия.';
+$_['entry_cron_new_only'] = 'Обрабатывать только новые (необработанные) файлы';
+$_['entry_cron_folders'] = 'Папки для сканирования (оставьте пустым для всего каталога)';
+$_['text_promo_title'] = 'Другие модули серии [NAT]';
+$_['text_promo_desc'] = 'Модули серии [NAT] — это инструменты для глубокой оптимизации OpenCart. Мы фокусируемся на автоматизации рутины, ускорении базы данных и очистке серверов от мусора. Все модули имеют открытый исходный код и единую логику управления.';
+$_['text_support'] = 'Техническая поддержка и пожелания:';
+$_['button_more'] = 'Смотреть все модули на OpenCart.com';
+$_['button_more_forum'] = 'Opencartforum';
+$_['text_mod_cat_opt'] = 'Catalog Optimizer [NAT]';
+$_['text_mod_cat_opt_desc'] = 'Скрытие пустых категорий и брендов + SEO сортировка товаров (в наличии сверху) + Очистка мусора БД.';
+$_['text_mod_new_arrivals'] = 'New Arrivals [NAT]: Новинки по датам';
+$_['text_mod_new_arrivals_desc'] = 'Автоматическая SEO-страница новинок. Отображает новинки строго по реальной дате добавления. Слайдеры, сетка и умный сайдбар архива дат.';
+$_['text_mod_img_opt'] = 'Image Optimizer [NAT]';
+$_['text_mod_img_opt_desc'] = 'Текущий модуль. Сжатие и ресайз ОРИГИНАЛОВ фото (image/catalog) в WebP + Очистка хостинга от неиспользуемых изображений.';
+$_['text_mod_dom_scan'] = 'Domain Scanner [NAT]';
+$_['text_mod_dom_scan_desc'] = 'Поиск и чистка внешних ссылок и HTTP-картинок в БД и шаблонах (для переезда на HTTPS).';
+$_['text_mod_poll'] = 'Poll Widget [NAT]';
+$_['text_mod_poll_desc'] = 'Создание опросов и голосований с визуальным конструктором дизайна, глубокой статистикой и защитой от накруток.';
+$_['text_mod_db_opt'] = 'Database Optimizer [NAT]';
+$_['text_mod_db_opt_desc'] = 'Интеллектуальная очистка мусора в базе данных, управление системными логами и добавление критически важных индексов для ускорения.';
+$_['text_mod_im_ex'] = 'Import/Export [NAT]';
+$_['text_mod_im_ex_desc'] = 'Быстрый импорт/экспорт цен и остатков из файлов xls/xlsx/csv/xml/json или по ссылке. Маппинг полей, работа по Cron, текстовые функции.';
+$_['text_mod_admin_tools'] = 'Admin Tools [NAT]';
+$_['text_mod_admin_tools_desc'] = 'Удобные инструменты администратора: вход под клиентом, кастомизация админки, управление правами пользователей и скрытие ненужных пунктов меню.';
+$_['text_mod_utm_tracker'] = 'Order UTM Tracker [NAT]';
+$_['text_mod_utm_tracker_desc'] = 'Автоматическое отслеживание UTM-меток при оформлении заказа. Сохраняет источник перехода, кампанию, ключевые слова и выводит эти данные в заказе для аналитики рекламы.';
+$_['text_info_title_promo_top_bar'] = 'Promo Top Bar [NAT]: Верхний рекламный инфо-баннер, информационная полоса над шапкой, бегущая строка и топ-баннер';
+$_['text_info_desc_promo_top_bar'] = 'Создание интерактивных промо-панелей и слайд-шоу в шапке сайта с конструктором блоков, таймерами, формой подписки и гибким таргетингом.';
+$_['text_tool_no_image'] = 'Товары без главного фото (пусто в БД)';
+$_['text_problem_main_broken'] = 'Битое главное фото (файл удален)';
+$_['text_problem_add_broken'] = 'Битое доп. фото (файл удален)';
+$_['text_problem_no_image'] = 'Отсутствует главное фото';
+$_['text_err_dir'] = 'Ошибка: Директория не найдена или неверный путь.';
+$_['text_err_no_queue'] = 'Ошибка: Очередь не найдена. Сначала запустите сканирование.';
+$_['text_log_del_empty_folder'] = 'Удалена пустая папка: ';
+$_['text_log_no_empty_folders'] = 'Пустых папок в image/catalog/ не найдено.';
+$_['text_log_total_empty_folders'] = 'Всего удалено пустых папок: ';
+$_['text_log_cache_cleared'] = 'Кэш изображений успешно очищен.';
+$_['text_log_wm_smart_mode'] = 'Вотермарки работают в SMART-режиме: оригиналы не трогаются, логотип накладывается только при генерации кэша.';
+$_['text_log_scan_dupes'] = 'Сканирование image/catalog/ на дубликаты (по хешу MD5)... Это может занять время.';
+$_['text_log_found_dupe'] = 'Найден дубликат: ';
+$_['text_log_no_dupes'] = 'Дубликаты не найдены.';
+$_['text_log_total_dupes'] = 'Найдено дубликатов: %s. Потенциальная экономия: %s';
+$_['text_log_fix_dupes_prompt'] = 'Нажмите "Исправить найденное" для перелинковки БД на оригинал и удаления дублей.';
+$_['text_log_fixed_dupe'] = 'Исправлен и удален дубликат: ';
+$_['text_log_success_dupes'] = 'Успешно исправлено дубликатов: %s. Ссылки в БД обновлены.';
+$_['text_log_scan_png'] = 'Поиск тяжелых PNG файлов без прозрачности...';
+$_['text_log_no_png'] = 'Подходящих PNG файлов не найдено.';
+$_['text_log_total_png'] = 'Найдено PNG без прозрачности: %s. Нажмите "Исправить" для конвертации в JPG.';
+$_['text_log_converted_jpg'] = 'Сконвертировано в JPG: ';
+$_['text_log_success_png'] = 'Успешно сконвертировано %s файлов. Освобождено: %s';
+$_['text_log_ph_prod'] = 'Обновлено товаров заглушкой: ';
+$_['text_log_ph_cat'] = 'Обновлено категорий заглушкой: ';
+$_['text_log_ph_brand'] = 'Обновлено брендов заглушкой: ';
+$_['text_log_ph_empty'] = 'Заглушки не выбраны в настройках.';
+$_['text_log_scan_exif'] = 'Сканирование EXIF-данных (мета-теги камер)...';
+$_['text_log_stripped_exif'] = 'Очищен EXIF: ';
+$_['text_log_no_exif'] = 'Файлов с EXIF для очистки не найдено.';
+$_['text_log_success_exif'] = 'Очищен EXIF у %s файлов. Освобождено: %s';
+$_['text_log_scan_small'] = 'Поиск фото размером менее %sx%s px...';
+$_['text_log_too_small'] = 'Слишком маленькое (%sx%s): ';
+$_['text_log_no_small'] = 'Мелких фото не найдено.';
+$_['text_log_total_small'] = 'Найдено мелких фото: %s. Пожалуйста, замените их на качественные.';
+$_['text_log_html_broken'] = 'Битый IMG в HTML таблицы %s (ID: %s): ';
+$_['text_log_no_html_broken'] = 'Битых картинок в HTML-описаниях не найдено.';
+$_['text_log_total_html_broken'] = 'Найдено битых ссылок в HTML: %s. Исправьте их вручную в редакторе.';
+$_['text_log_scan_smart_cache'] = 'Поиск потерянных файлов в image/cache/...';
+$_['text_log_del_orph_cache'] = 'Удален потерянный кэш: ';
+$_['text_log_no_orph_cache'] = 'Потерянного кэша не найдено.';
+$_['text_log_success_smart_cache'] = 'Удалено файлов потерянного кэша: %s. Освобождено: %s';
+$_['text_log_scan_translit'] = 'Поиск файлов с кириллицей или пробелами...';
+$_['text_log_no_bad_names'] = 'Файлов с некорректными именами не найдено.';
+$_['text_log_total_bad_names'] = 'Найдено файлов с кириллицей/пробелами: %s. Нажмите "Исправить" для транслитерации и обновления БД.';
+$_['text_log_renamed'] = 'Переименовано: ';
+$_['text_log_success_translit'] = 'Успешно транслитерировано файлов: %s. БД обновлена.';
+$_['text_log_scan_restore'] = 'Сканирование image/cache/ для восстановления утерянных оригиналов...';
+$_['text_log_restored_cache'] = 'Восстановлено из кэша: ';
+$_['text_log_no_lost_orig'] = 'Отсутствующих оригиналов в кэше не найдено.';
+$_['text_log_success_restore'] = 'Успешно восстановлено оригиналов из кэша: %s.';
+$_['error_invalid_folder'] = 'Ошибка: Недопустимое имя папки!';
+$_['text_formats_title'] = 'Современные форматы (WebP & SVG)';
+$_['text_formats_desc'] = 'Настройки интеграции современных форматов в файловую систему и шаблоны OpenCart.';
+$_['entry_support_svg'] = 'Поддержка загрузки SVG';
+$_['help_support_svg'] = 'Разрешает загрузку векторных SVG-изображений через стандартный Filemanager и их безопасный вывод на витрине без искажений размеров.';
+$_['entry_support_webp'] = 'Поддержка загрузки WebP';
+$_['help_support_webp'] = 'Позволяет вручную загружать, подвязывать и выводить на сайте готовые файлы .webp через Filemanager.';
+$_['entry_truefilemanager_webp'] = 'WebP в TrueFileManager';
+$_['help_truefilemanager_webp'] = 'Добавляет поддержку загрузки, предпросмотра и эскизов WebP в TrueFileManager от sitecreator (если он установлен).';
+$_['entry_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load';
+$_['help_webp_on_fly'] = 'Сверхбыстрая подмена: автоматически меняет расширения .jpg/.png на .webp прямо в HTML-выводе витрины и добавляет атрибут loading="lazy". Не трогает базу данных!';
+$_['help_tool_broken_db'] = 'Ищет в базе данных картинки, физические файлы которых были удалены с сервера. Позволяет очистить битые ссылки.';
+$_['help_tool_empty_folders'] = 'Рекурсивно сканирует директорию image/catalog/ и безопасно удаляет все папки, в которых нет ни одного файла.';
+$_['help_tool_folder_tree'] = 'Строит дерево каталогов и показывает реальный вес каждой папки для поиска "пожирателей" места.';
+$_['help_tool_html_broken'] = 'Сканирует HTML-описания товаров и категорий на наличие тегов с несуществующими картинками.';
+$_['help_tool_small_photos'] = 'Находит картинки с разрешением ниже заданного (например, меньше 300x300 px). Добавлены ссылки на поиск качественных аналогов в Google Images и Яндекс Картинках.';
+$_['help_tool_duplicates'] = 'Ищет полные дубликаты файлов по MD5 хешу (разные имена, но одинаковая картинка). Позволяет склеить их в один файл и обновить БД.';
+$_['help_tool_watermark'] = 'Наложение водяных знаков в SMART-режиме (только на кэш витрины, оригиналы не изменяются).';
+$_['help_tool_placeholders'] = 'Массово устанавливает выбранную картинку-заглушку для товаров и категорий, у которых вообще нет фото.';
+$_['help_tool_png_jpg'] = 'Инструмент сканирует папку изображений и находит тяжелые PNG-файлы, которые не имеют прозрачного фона (альфа-канала). Поскольку прозрачность им не нужна, их можно безопасно конвертировать в формат JPG. Это значительно облегчает файлы (в среднем на 70-80%) и ускоряет загрузку сайта, а все ссылки в базе данных автоматически обновляются.';
+$_['help_tool_exif'] = 'Сканирует JPEG-файлы и удаляет из них скрытые EXIF-данные (геотеги, данные о камере), уменьшая вес на 10-15%.';
+$_['help_tool_smart_cache'] = 'Ищет в папке image/cache/ кэшированные файлы, оригиналы которых уже удалены из image/catalog/, и стирает этот мусор.';
+$_['help_tool_translit'] = 'Находит файлы с кириллицей или пробелами в названиях, переименовывает их транслитом (tovar_1.jpg) и обновляет пути в БД.';
+$_['help_tool_cache_restore'] = 'Если вы случайно удалили оригинал из catalog, инструмент попытается найти его копию в cache и восстановить обратно.';
+$_['entry_quarantine'] = 'Переместить оригиналы в Карантин (вместо удаления)';
+$_['entry_backup_db'] = 'Создать бэкап изменяемых таблиц в SQL перед запуском';
+$_['button_fix_selected'] = 'ИСПРАВИТЬ ВЫБРАННЫЕ';
+$_['button_export_log'] = 'Экспортировать в CSV';
+$_['text_th_before'] = 'Состояние ДО';
+$_['text_th_after'] = 'Ожидаемый результат ПОСЛЕ';
+$_['text_th_select'] = 'Действие';
+$_['text_th_preview'] = 'Предпросмотр';
+$_['text_th_name'] = 'Объект / Файл';
+$_['text_global_action'] = 'Групповое действие для выбранных:';
+$_['text_action_clear'] = 'Очистить ссылку в БД';
+$_['text_action_disable'] = 'Отключить (Статус = 0)';
+$_['text_action_stock'] = 'Установить "Нет в наличии"';
+$_['text_action_placeholder'] = 'Установить заглушку';
+$_['text_action_ignore'] = 'Ничего не делать';
+$_['text_action_merge'] = 'Склеить (удалить дубликат, оставить оригинал)';
+$_['text_action_convert'] = 'Конвертировать в JPG';
+$_['text_action_rename'] = 'Переименовать транслитом';
+$_['text_action_delete'] = 'Удалить';
+$_['text_action_restore'] = 'Восстановить';
+$_['text_db_backup_created'] = 'Создан бэкап БД: %s';
+$_['text_quarantine_created'] = 'Перенесено в карантин: %s';
+$_['text_fix_status_success'] = 'Успешно выполнено';
+$_['text_fix_status_error'] = 'Ошибка: %s';
+$_['text_log_exported'] = 'Лог успешно экспортирован!';
+$_['text_select_action'] = '-- Выберите действие --';
+$_['error_empty_selection'] = 'Ошибка: Не выбрано ни одной строки для исправления!';
+$_['text_link_admin'] = 'Админка';
+$_['text_link_catalog'] = 'Витрина';
+$_['entry_threads'] = 'Потоков обработки';
+$_['text_threads_optimal'] = 'оптимально';
+$_['button_download_zip'] = 'Скачать ZIP-архив и удалить папку';
+$_['confirm_download_and_delete'] = 'Архив успешно скачался? Можно удалить временную папку копий на сервере?';
+$_['button_clear_backups'] = 'Удалить бэкапы БД';
+$_['button_clear_backups_short'] = 'Очистить бэкапы';
+$_['confirm_clear_backups'] = 'Вы уверены, что хотите полностью удалить все SQL-бэкапы таблиц?';
+$_['text_compare_title'] = 'Сравнение изображений ДО / ПОСЛЕ';
+$_['text_compare_before'] = 'ДО';
+$_['text_compare_after'] = 'ПОСЛЕ';
+$_['text_backups_cleared'] = 'Все SQL-бэкапы таблиц успешно удалены!';
+$_['error_no_backups'] = 'Бэкапы не найдены или папка пуста.';
+$_['button_restore'] = 'Вернуть';
+$_['button_empty'] = 'Очистить';
+$_['entry_wm_targets'] = 'Применять водяной знак к:';
+$_['text_wm_target_product'] = 'Фото товаров';
+$_['text_wm_target_category'] = 'Фото категорий';
+$_['text_wm_target_brand'] = 'Логотипы брендов';
+$_['text_wm_target_banner'] = 'Баннеры и слайды';
+$_['text_wm_target_blog'] = 'Статьи / Блог';
+$_['error_no_scan_data'] = 'Ошибка: Данные сканирования не найдены. Сначала запустите сканирование!';
+$_['error_row_not_found'] = 'Ошибка: Объект не найден в результатах сканирования!';
+$_['error_file_not_found'] = 'Ошибка: Файл не найден на сервере!';
+$_['text_folder_rules_title'] = 'Индивидуальные правила сжатия для папок';
+$_['text_folder_rules_desc'] = 'Вы можете переопределить качество и максимальные размеры для конкретных папок. Если оставить поле пустым, будет использоваться глобальная настройка. Путь указывается относительно папки image (например, catalog/banners/).';
+$_['entry_folder_path'] = 'Путь к папке';
+$_['entry_jpg_quality'] = 'Качество JPG';
+$_['entry_webp_quality'] = 'Качество WebP';
+$_['button_add_rule'] = 'Добавить правило';
+$_['button_remove'] = 'Удалить';
+$_['entry_wm_angle'] = 'Угол наклона водяного знака (градусы)';
+$_['entry_wm_corner_radius'] = 'Скругление углов водяного знака (px)';
+$_['entry_wm_size_type'] = 'Масштабирование водяного знака';
+$_['entry_wm_size_percent'] = 'Размер водяного знака (% от ширины изображения)';
+$_['entry_wm_text_color'] = 'Цвет текста водяного знака';
+$_['entry_wm_text_font'] = 'Шрифт водяного знака (TTF)';
+$_['entry_wm_filter_mode'] = 'Режим фильтрации Категорий/Брендов';
+$_['text_wm_filter_include'] = 'Только для выбранных';
+$_['text_wm_filter_exclude'] = 'Для всех, кроме выбранных';
+$_['text_wm_size_original'] = 'Оригинальный размер';
+$_['text_wm_size_percent'] = 'Пропорционально ширине изображения (%)';
+$_['button_preview_watermark'] = 'Предпросмотр водяного знака';
+$_['text_wm_preview_title'] = 'Предпросмотр водяного знака';
+$_['text_cron_exclude_folders'] = 'Исключить папки из сканирования';
+$_['text_cron_entities'] = 'Какие типы фото обрабатывать?';
+$_['text_cron_quality_override'] = 'Переопределить параметры сжатия для Крона';
+$_['text_cron_recommendations'] = 'Рекомендации по расписанию запуска Cron';
+$_['text_cron_recommendations_desc'] = '
0 * * * *).0 */6 * * *).0 2 * * *).';
+$_['text_tool_broken_files'] = 'Сканер битых/пустых файлов на сервере';
+$_['help_tool_broken_files'] = 'Сканирует физические файлы в папке image/catalog/ на наличие поврежденных изображений или пустых файлов размером 0 байт.';
+$_['text_grid_broken_file'] = 'Битый или пустой файл на сервере';
+$_['text_grid_broken_file_after'] = 'Удаление или замена заглушкой';
+$_['text_description_broken_files'] = 'Результаты сканирования физических файлов в папке image/catalog/. Здесь отображаются битые (поврежденные) изображения или файлы размером 0 байт.';
+$_['text_action_strip'] = 'Очистить EXIF';
+$_['text_action_set_placeholder'] = 'Установить заглушку';
+$_['text_th_entity'] = 'Объект / Связанные ссылки';
+$_['text_cron_entity_product'] = 'Товары';
+$_['text_cron_entity_category'] = 'Категории';
+$_['text_cron_entity_manufacturer'] = 'Производители / Бренды';
+$_['text_cron_entity_banner'] = 'Баннеры';
+$_['text_cron_entity_blog'] = 'Блог / Статьи';
+$_['entry_cron_jpg_quality'] = 'Качество JPG (Крон)';
+$_['entry_cron_webp_quality'] = 'Качество WebP (Крон)';
+$_['entry_cron_max_width'] = 'Макс. ширина px (Крон)';
+$_['entry_cron_max_height'] = 'Макс. высота px (Крон)';
+$_['text_cron_folder_scan'] = 'Папки для сканирования';
+$_['text_no_fonts'] = 'TTF-шрифты не найдены в system/library/font/';
+$_['button_apply'] = 'Применить';
+$_['text_backup_will_be_created'] = 'Резервная копия базы данных будет автоматически создана перед выполнением этого действия.';
+$_['text_bulk_action'] = 'Групповое действие для выбранных';
+$_['text_grid_results'] = 'Результаты и детали сканирования';
+$_['text_quarantine_will_be_created'] = 'Оригинальные файлы будут перемещены в карантин (image/catalog_trash/) перед изменением.';
+$_['text_success_apply'] = 'Изменения успешно применены!';
+$_['text_description_broken'] = 'Результаты сканирования базы данных на наличие битых ссылок (записи в таблицах с несуществующими файлами).';
+$_['text_description_cache_restore'] = 'Результаты поиска файлов в кэше, оригиналы которых отсутствуют в каталоге.';
+$_['text_description_duplicates'] = 'Результаты поиска полных дубликатов изображений по MD5-хешу.';
+$_['text_description_empty_folders'] = 'Список пустых папок в директории image/catalog/ для удаления.';
+$_['text_description_exif'] = 'Результаты поиска изображений с метаданными EXIF, которые можно очистить.';
+$_['text_description_folder_tree'] = 'Статистика размера папок в каталоге image/catalog/.';
+$_['text_description_html_broken'] = 'Результаты поиска несуществующих файлов картинок, на которые ссылаются описания в HTML.';
+$_['text_description_placeholders'] = 'Результаты поиска сущностей без изображений для заполнения заглушками.';
+$_['text_description_png_jpg'] = 'Результаты поиска тяжелых изображений PNG без прозрачности, которые можно пережать в JPG.';
+$_['text_description_small_photos'] = 'Список изображений, размеры которых меньше минимально заданных (%sx%s px).';
+$_['text_description_smart_cache'] = 'Результаты поиска файлов кэша, оригиналы которых уже были удалены.';
+$_['text_description_translit'] = 'Результаты поиска файлов с кириллицей или пробелами в названиях.';
+$_['text_grid_broken_db_after'] = 'Очистить ссылку или установить заглушку';
+$_['text_grid_dupe_after'] = 'Оригинал (остаётся)';
+$_['text_grid_dupe_before'] = 'Дубликат (будет удалён)';
+$_['text_grid_empty_folder'] = 'Пустая папка';
+$_['text_grid_empty_folder_after'] = 'Удалить пустую папку';
+$_['text_grid_exif_after'] = 'Очистить метаданные';
+$_['text_grid_exif_before'] = 'Содержит EXIF';
+$_['text_grid_html_after'] = 'Исправить ссылку вручную';
+$_['text_grid_missing_file'] = 'Файл отсутствует';
+$_['text_grid_missing_html'] = 'Картинка не найдена в описании';
+$_['text_grid_missing_orig'] = 'Оригинал отсутствует';
+$_['text_grid_missing_orig_after'] = 'Восстановить оригинал из кэша';
+$_['text_grid_no_image'] = 'Нет изображения (пусто)';
+$_['text_grid_orph_cache'] = 'Осиротевший кэш';
+$_['text_grid_orph_cache_after'] = 'Удалить ненужный кэш';
+$_['text_grid_ph_brand_after'] = 'Установить заглушку бренда';
+$_['text_grid_ph_category_after'] = 'Установить заглушку категории';
+$_['text_grid_ph_product_after'] = 'Установить заглушку товара';
+$_['text_grid_small_photo_after'] = 'Рекомендуется заменить на более крупное';
+$_['text_grid_too_small'] = 'Слишком маленькое';
+$_['text_no_relations'] = 'Нет связей в БД';
+$_['text_action_archive'] = 'Архивировать (в ZIP)';
+$_['text_action_archive_delete'] = 'Архивировать + Удалить';
+$_['text_tool_sanitizer'] = 'Санитайзер имен файлов';
+$_['help_tool_sanitizer'] = 'Массовая очистка имен файлов и папок от кириллицы, пробелов и спецсимволов, приведение расширений к единому виду.';
+$_['text_description_sanitizer'] = 'Результаты анализа имен файлов для санитаризации по выбранным правилам.';
+$_['entry_sz_translit'] = 'Транслитерация (кириллица в латиницу)';
+$_['entry_sz_spaces'] = 'Заменять пробелы на символ "_"';
+$_['entry_sz_special'] = 'Удалять специальные символы';
+$_['entry_sz_lowercase_name'] = 'Имя файла в нижний регистр';
+$_['entry_sz_lowercase_ext'] = 'Расширение в нижний регистр';
+$_['entry_sz_normalize_ext'] = 'Нормализовать расширения (jpeg -> jpg)';
+$_['entry_cron_folder_mode'] = 'Режим фильтрации папок';
+$_['text_cron_folder_mode_exclude'] = 'Сканировать все, кроме выбранных (Исключения)';
+$_['text_cron_folder_mode_include'] = 'Сканировать только выбранные папки';
+$_['button_select_all'] = 'Выбрать все';
+$_['button_deselect_all'] = 'Снять все';
+$_['entry_wm_upload_font'] = 'Загрузить свой шрифт TTF';
+$_['button_upload_font'] = 'Загрузить шрифт';
+$_['text_success_font_upload'] = 'Шрифт успешно загружен!';
+$_['error_font_upload'] = 'Ошибка загрузки шрифта! Разрешены только файлы .ttf весом до 5 МБ.';
+$_['entry_lazy_load'] = 'Прогрессивная ленивая загрузка';
+$_['help_lazy_load'] = 'Включает премиальную прогрессивную отложенную загрузку на витрине. Изображения заменяются микро-превью и плавно размываются (blur-up), подгружаясь по мере прокрутки.';
+$_['confirm_replace_mode'] = 'ВНИМАНИЕ! Вы выбрали режим «Заменять оригиналы». Все изображения будут перезаписаны прямо на сервере. Настоятельно рекомендуем сделать резервную копию. Вы уверены, что хотите продолжить?';
+$_['text_compare'] = 'Сравнить';
+$_['text_home'] = 'Главная';
+$_['text_tech_gd'] = 'Библиотека GD';
+$_['text_tech_imagick'] = 'Imagick';
+$_['text_tech_webp'] = 'Поддержка WebP';
+$_['text_position_1'] = '1 - Слева вверху';
+$_['text_position_2'] = '2 - По центру вверху';
+$_['text_position_3'] = '3 - Справа вверху';
+$_['text_position_4'] = '4 - Слева посередине';
+$_['text_position_5'] = '5 - По центру';
+$_['text_position_6'] = '6 - Справа посередине';
+$_['text_position_7'] = '7 - Слева внизу';
+$_['text_position_8'] = '8 - По центру внизу';
+$_['text_position_9'] = '9 - Справа внизу';
+
+$_['entry_log_level'] = 'Уровень лога';
+$_['text_log_changed_only'] = 'Только изменённые';
+$_['text_log_all'] = 'Все файлы (подробно)';
+$_['text_skipped_no_gain'] = 'Пропущен (нет улучшения)';
+$_['text_reset_cache_hint'] = 'Сбросить кэш оптимизации (полный пересканирование)';
+$_['confirm_reset_cache'] = 'Сбросить кэш оптимизации? Следующее сканирование проверит ВСЕ файлы заново.';
+$_['text_cache_cleared'] = 'Кэш оптимизации очищен. Следующее сканирование проверит все файлы.';
+$_['error_wm_preview'] = 'Ошибка генерации превью водяного знака.';
+$_['error_wm_too_large'] = 'Изображение водяного знака слишком большое (%dx%d px)! Максимально допустимый размер: %dx%d px.';
+$_['text_apply_errors_warning'] = 'ВНИМАНИЕ: Некоторые файлы не удалось заменить (заблокированы или нет прав):';
+$_['text_action_compress'] = 'Сжать изображения';
+$_['text_estimated'] = 'Прогноз';
+$_['entry_min_savings_bytes'] = 'Мин. сжатие (байт)';
+$_['entry_min_savings_percent'] = 'Мин. сжатие (%)';
+$_['text_optimized'] = 'Оптимизировано';
+$_['entry_broken_fallback'] = 'Заглушка для битых фото';
+$_['help_broken_fallback'] = 'Автоматически заменяет отсутствующие или поврежденные изображения каталога на витрине на изображение-заглушку.';
+$_['entry_fallback_image'] = 'Изображение-заглушка';
+$_['help_fallback_image'] = 'Выберите изображение, которое будет показано, если оригинальный файл картинки товара или категории отсутствует. По умолчанию используется no_image.png.';
+$_['text_tool_cmyk'] = 'Конвертер CMYK в RGB';
+$_['help_tool_cmyk'] = 'Конвертация JPEG изображений из цветового профиля CMYK в sRGB для корректного отображения в браузерах Safari и на устройствах iOS.';
+$_['text_tool_heavy_files'] = 'Поиск тяжелых файлов';
+$_['help_tool_heavy_files'] = 'Поиск самых тяжелых оригинальных изображений в каталоге с возможностью их быстрого сжатия прямо здесь.';
+$_['entry_heavy_files_limit'] = 'Макс. количество найденных файлов (всего)';
+$_['text_action_convert_rgb'] = 'Конвертировать в RGB';
+$_['text_description_cmyk'] = 'Найдены файлы в цветовом пространстве CMYK. Конвертация в sRGB гарантирует корректное отображение на всех платформах, включая iOS/macOS.';
+$_['text_description_heavy'] = 'Список из %d самых тяжелых файлов в каталоге изображений. Вы можете оптимизировать их прямо здесь.';
+$_['text_cron_folder_hint_exclude_all'] = 'Статус: Будут сканироваться все папки (исключения не выбраны).';
+$_['text_cron_folder_hint_exclude_some'] = 'Статус: Будут сканироваться все папки, КРОМЕ %d отмеченных.';
+$_['text_cron_folder_hint_include_some'] = 'Статус: Будут сканироваться только %d отмеченных папок.';
+$_['text_cron_folder_hint_include_none'] = 'ВНИМАНИЕ: Ни одна папка не выбрана! Крон ничего не будет сканировать.';
+$_['text_action_apply_changes'] = 'Применить указанные изменения';
+$_['entry_wm_status'] = 'Статус водяного знака';
+$_['text_disabled'] = 'Отключено';
+$_['entry_rule_name'] = 'Название правила';
+$_['entry_rule_status'] = 'Статус правила';
+$_['entry_action'] = 'Действие';
+$_['entry_ph_status'] = 'Статус заглушек';
+$_['entry_sanitizer_limit'] = 'Макс. файлов для поиска (лимит)';
+$_['entry_heavy_files_min_size'] = 'Минимальный размер';
+$_['entry_png_jpg_limit'] = 'Макс. файлов для поиска (лимит)';
+$_['entry_png_jpg_min_size'] = 'Минимальный размер';
+$_['text_wm_rule_title'] = 'Правило водяного знака';
+$_['button_close'] = 'Закрыть';
+$_['text_ph_rule_title'] = 'Правило заглушки';
+$_['entry_ph_image'] = 'Изображение заглушки';
+$_['button_clear_cache_wm'] = 'Очистить кэш (применить водяной знак)';
+$_['text_active'] = 'Активна';
+$_['text_error'] = 'Не активна';
+$_['text_th_dupe_delete'] = 'Файл для удаления (Дубликат)';
+$_['text_th_dupe_keep'] = 'Файл для сохранения (Оригинал)';
+$_['text_dupe_original_label'] = 'Оригинал';
+$_['text_dupe_duplicate_label'] = 'Дубликат';
+
+$_['text_disk_usage'] = 'Использование диска';
+$_['text_cumulative_stats'] = 'Статистика оптимизации';
+$_['text_disk_catalog'] = 'Каталог изображений';
+$_['text_disk_cache'] = 'Кэш OC';
+$_['text_disk_trash'] = 'Корзина модуля';
+$_['text_disk_other'] = 'Прочее';
+$_['text_disk_free'] = 'Свободно';
+$_['text_total_files_opt'] = 'Файлов обработано';
+$_['text_total_saved'] = 'Всего сэкономлено';
+$_['text_avg_saving_pct'] = 'Средняя экономия';
+$_['text_stats_hint'] = 'Статистика накапливается по всем сессиям (хранится локально в браузере)';
+$_['text_reset_stats'] = 'Сбросить статистику';
+$_['text_reset_stats_confirm']= 'Сбросить накопленную статистику оптимизации?';
+$_['text_export_import'] = 'Экспорт / Импорт настроек';
+$_['button_export_settings'] = 'Экспорт настроек (JSON)';
+$_['button_import_settings'] = 'Импортировать настройки';
+$_['help_export_settings'] = 'Скачать все настройки модуля в JSON-файл';
+$_['help_import_settings'] = 'Загрузить ранее экспортированный JSON для восстановления настроек';
+$_['text_import_success'] = 'Настройки успешно импортированы. Перезагрузите страницу для применения.';
+$_['error_import_file'] = 'Файл не загружен или ошибка загрузки';
+$_['error_import_invalid'] = 'Некорректный файл настроек (неверный модуль или формат)';
+$_['text_click_to_load'] = 'Нажмите ↑ для загрузки данных диска';
+$_['tab_formats'] = 'Форматы изображений';
+
+$_['text_disk_quota_legend'] = 'Лимиты диска хостинга (вручную)';
+$_['text_disk_quota_desc'] = 'Если график дискового пространства показывает неверные данные (физический диск сервера вместо лимита вашего хостинга), вы можете указать лимиты вашего тарифа вручную. Укажите 0, чтобы использовать автоопределение.';
+$_['entry_disk_quota_total'] = 'Выделено диска на хостинге (ГБ)';
+$_['entry_disk_quota_used'] = 'Занято диска на хостинге всего (ГБ)';
+$_['entry_auto_disk'] = 'Автообновление при входе';
+
+// Cron Tasks
+$_['entry_cron_task_name'] = 'Название задачи';
+$_['entry_cron_task_summary'] = 'Параметры сжатия';
+$_['button_add_cron_task'] = 'Добавить задачу';
+$_['button_copy_cron_url'] = 'Копировать URL';
+$_['text_cron_recommended_url'] = 'URL для запуска задачи крона:';
+
+// Licensing
+$_['text_license_required'] = 'Требуется лицензионный ключ';
+$_['text_license_required_desc'] = 'Пожалуйста, введите лицензионный ключ для активации модуля. Все функции настроек и оптимизации заблокированы до ввода валидного ключа.';
+$_['entry_token_desc'] = 'Введите токен активации, выданный для вашего основного домена. Вы можете получить его в личном кабинете на форуме или обратившись в поддержку.';
+$_['button_activate'] = 'Активировать модуль';
+$_['text_support'] = 'Техническая поддержка';
+$_['text_support_desc'] = 'Если у вас ещё нет ключа или возникли проблемы, пожалуйста, сообщите номер вашего заказа и домен.';
+
+// New settings
+$_['entry_wm_max_width'] = 'Макс. ширина водяного знака (px)';
+$_['entry_wm_max_height'] = 'Макс. высота водяного знака (px)';
+$_['help_wm_max_width'] = 'Если исходное изображение водяного знака превышает эту ширину, оно будет автоматически масштабировано. Укажите 0 или оставьте пустым для значения по умолчанию (800px).';
+$_['help_wm_max_height'] = 'Если исходное изображение водяного знака превышает эту высоту, оно будет автоматически масштабировано. Укажите 0 или оставьте пустым для значения по умолчанию (800px).';
+$_['entry_disk_status'] = 'Включить сканирование диска и график';
+$_['help_disk_status'] = 'Если включено, модуль будет периодически сканировать объем папки картинок и отображать круговую диаграмму. Рекомендуется выключить на больших сайтах для ускорения загрузки панели.';
+$_['text_disk_settings_title'] = 'Настройка лимитов и сканирования диска';
+
diff --git a/upload/admin/language/russian/module/img_opti.php b/upload/admin/language/russian/module/img_opti.php
new file mode 100644
index 0000000..62bdf87
--- /dev/null
+++ b/upload/admin/language/russian/module/img_opti.php
@@ -0,0 +1,564 @@
+Image Optimizer [NAT]: Сжатие оригиналов и удаление мусора';
+$_['text_extension'] = 'Расширения';
+$_['text_edit'] = 'Настройки модуля';
+$_['text_success'] = 'Настройки успешно сохранены!';
+$_['entry_warning'] = 'ВНИМАНИЕ: Обязательно сделайте бэкап файлов сайта и особенно папки /image/catalog перед началом работы!';
+$_['text_tech'] = 'Проверка технологий сервера:';
+$_['text_author'] = 'Техническая поддержка и пожелания: info@nat.od.ua';
+$_['tab_optimize'] = 'Оптимизация и Ресайз';
+$_['tab_cleaner'] = 'Очистка неиспользуемых изображений';
+$_['tab_info'] = 'Инфо / Экосистема [NAT]';
+$_['tab_settings'] = 'Настройки';
+$_['tab_tools'] = 'Инструменты (pro)';
+$_['tab_cron'] = 'Крон-задачи';
+$_['entry_mode'] = 'Режим работы';
+$_['text_copy'] = 'Создать копию (безопасно)';
+$_['text_replace'] = 'Заменять оригиналы (требует бэкап!)';
+$_['entry_folder'] = 'Имя папки для копии';
+$_['entry_batch'] = 'Файлов за проход';
+$_['entry_log_limit'] = 'Строк лога на экране';
+$_['entry_max_width'] = 'Макс. ширина (px) [0 - без лимита]';
+$_['entry_max_height'] = 'Макс. высота (px) [0 - без лимита]';
+$_['entry_jpg'] = 'Качество JPG (0-100)';
+$_['entry_png'] = 'Сжатие PNG (0-9)';
+$_['entry_webp'] = 'Качество WebP (0-100)';
+$_['entry_targets'] = 'Что оптимизировать?';
+$_['text_all_catalog'] = '[ Весь каталог image/catalog ]';
+$_['text_root_files'] = 'Файлы только в корне image/catalog';
+$_['entry_threshold'] = 'Порог замены (%)';
+$_['help_threshold'] = '0: заменять только если вес стал меньше. 10: разрешить увеличение веса до 10%.';
+$_['entry_token'] = 'Токен активации (на корень домена)';
+$_['entry_status'] = 'Статус модуля';
+$_['entry_menu_position'] = 'Позиция меню [NAT]';
+$_['text_menu_module_only'] = 'Только в модулях';
+$_['text_menu_header'] = 'В шапке (Header)';
+$_['text_menu_sidebar'] = 'В боковом меню (Sidebar)';
+$_['text_menu_both'] = 'Везде (Шапка + Сайдбар)';
+$_['text_status_on'] = 'Включено';
+$_['text_status_off'] = 'Отключено';
+$_['entry_engine'] = 'Движок обработки изображений';
+$_['text_engine_gd'] = 'GD (Стандарт)';
+$_['text_engine_imagick'] = 'Imagick (Высокое качество, сохранение профилей sRGB)';
+$_['entry_license'] = 'Лицензия';
+$_['text_cleaner_info'] = 'Этот инструмент ищет файлы, которые физически существуют на диске, но нигде не используются в базе данных или файлах шаблона.';
+$_['entry_clean_targets'] = 'В каких папках искать мусор?';
+$_['entry_extended_log'] = 'Расширенный лог (показать используемые фото и где они найдены)';
+$_['entry_process_broken'] = 'Учитывать файлы с битой кодировкой (имена с символом "?")';
+$_['button_scan_trash'] = 'НАЙТИ МУСОР';
+$_['button_quarantine'] = 'В Карантин (Безопасно)';
+$_['button_delete_trash'] = 'Удалить навсегда';
+$_['button_empty_quarantine'] = 'Очистить папку Карантина';
+$_['button_restore_quarantine'] = 'Вернуть из Карантина';
+$_['text_scan_result'] = 'Результаты сканирования';
+$_['text_trash_files'] = 'Мусорных файлов: ';
+$_['text_trash_size'] = 'Потенциально освободится: ';
+$_['button_start'] = 'СТАРТ ОПТИМИЗАЦИИ';
+$_['button_pause'] = 'ПАУЗА';
+$_['button_stop'] = 'ОСТАНОВИТЬ';
+$_['button_continue'] = 'ПРОДОЛЖИТЬ';
+$_['button_start_new'] = 'СТАРТ (Новый запуск)';
+$_['button_download_log'] = 'Скачать лог в TXT';
+$_['button_download'] = 'Скачать';
+$_['button_save'] = 'Сохранить';
+$_['button_cancel'] = 'Отмена';
+$_['text_progress'] = 'Прогресс:';
+$_['text_old_size'] = 'Было:';
+$_['text_new_size'] = 'Стало:';
+$_['text_savings'] = 'Экономия:';
+$_['text_detailed_log'] = 'Детальный лог';
+$_['text_page'] = 'Стр.';
+$_['text_waiting'] = 'Ожидание запуска...';
+$_['text_enabled'] = 'Включено';
+$_['text_disabled_error'] = 'Выключено (ОШИБКА)';
+$_['text_supported'] = 'Поддерживается';
+$_['text_no'] = 'Нет';
+$_['error_permission'] = 'У вас нет прав для управления модулем!';
+$_['error_license'] = 'Ошибка доступа: Неверный токен активации для этого домена!';
+$_['error_domain'] = ' (Домен: ';
+$_['text_found'] = 'Найдено файлов: ';
+$_['text_compressed'] = 'Сжат';
+$_['text_original'] = 'Оригинал';
+$_['text_error_process'] = 'Ошибка обработки: ';
+$_['error_log_empty'] = 'Лог пуст! Сначала запустите процесс.';
+$_['text_report_header'] = '=== ОТЧЕТ ===';
+$_['text_total_files'] = 'Всего файлов: ';
+$_['text_searching'] = 'Поиск файлов...';
+$_['error_ajax'] = 'Ошибка: ';
+$_['text_resumed'] = '▶ ПРОЦЕСС ВОЗОБНОВЛЕН...';
+$_['text_paused'] = '⏸ ПАУЗА. Процесс приостановлен.';
+$_['confirm_stop'] = 'Вы уверены, что хотите полностью прервать процесс?';
+$_['confirm_delete'] = 'ВНИМАНИЕ! Файлы будут удалены с сервера навсегда без возможности восстановления! Продолжить?';
+$_['confirm_empty_q'] = 'Вы уверены, что хотите полностью очистить папку карантина?';
+$_['confirm_restore'] = 'Все файлы из папки карантина будут возвращены на свои места. Продолжить?';
+$_['button_stopping'] = 'Остановка...';
+$_['text_stopped_user'] = '🛑 ПРОЦЕСС ПРЕРВАН ПОЛЬЗОВАТЕЛЕМ!';
+$_['text_increase'] = 'Увеличение';
+$_['text_done'] = '✅ ГОТОВО! Процесс завершен.';
+$_['error_timeout'] = 'Таймаут сервера. Автоповтор через 3 секунды...';
+$_['text_building_index'] = 'Шаг 1/2: Индексация БД и шаблонов (поиск используемых фото)...';
+$_['text_comparing'] = 'Шаг 2/2: Сравнение физических файлов с индексом...';
+$_['text_quarantine_done'] = '✅ Процесс переноса в image/catalog_trash/ завершен!';
+$_['text_deleted_done'] = '✅ Процесс удаления мусора завершен!';
+$_['text_quarantine_empty'] = '✅ Папка карантина успешно удалена с сервера!';
+$_['text_restore_done'] = '✅ Файлы успешно возвращены из карантина в рабочую папку!';
+$_['text_quarantine_move'] = 'В карантине: ';
+$_['text_deleted_file'] = 'Удален: ';
+$_['text_no_trash'] = 'В выбранных папках мусор не найден! Все файлы используются.';
+$_['text_scanning_dirs'] = 'Сканируем директории: ';
+$_['text_log_used'] = '[ИСПОЛЬЗУЕТСЯ]';
+$_['text_log_trash'] = '[МУСОР]';
+$_['text_log_deleted'] = '[УДАЛЕНО]';
+$_['text_log_del_err'] = '[ОШИБКА УДАЛЕНИЯ]';
+$_['text_log_quarantine'] = '[В КАРАНТИН]';
+$_['text_log_q_err'] = '[ОШИБКА ПЕРЕНОСА]';
+$_['text_log_restored'] = '[ВОССТАНОВЛЕНО]';
+$_['text_log_rest_err'] = '[ОШИБКА ВОССТАНОВЛЕНИЯ]';
+$_['text_used_in'] = 'в: ';
+$_['text_analyzing'] = 'Анализ базы данных...';
+$_['text_clean_empty'] = 'Чисто! Мусора нет.';
+$_['text_processing'] = 'Обработка...';
+$_['text_restoring'] = 'Восстановление файлов...';
+$_['text_all_folders'] = 'image/catalog/ (И все вложенные)';
+$_['text_root_folder'] = 'image/catalog/ (Только корень)';
+$_['text_q_empty_err'] = 'Папка карантина пуста или не существует.';
+$_['error_token'] = 'Неверный токен. Перезагрузите страницу.';
+$_['text_apply_title'] = 'Оптимизация завершена в папку: ';
+$_['text_apply_info'] = 'Проверьте результат. Если всё устраивает — примените изменения к основному каталогу.';
+$_['entry_apply_backup'] = 'Перенести с созданием бэкапа (в Карантин)';
+$_['entry_apply_replace'] = 'Заменить оригиналы безвозвратно';
+$_['button_apply_main'] = 'ПРИМЕНИТЬ К ОСНОВНОМУ КАТАЛОГУ';
+$_['button_delete_copies'] = 'Удалить временные копии (отмена)';
+$_['button_full_cleanup'] = 'Полная очистка всех временных папок и бэкапов';
+$_['text_apply_success'] = '✅ Файлы успешно перенесены. ';
+$_['text_apply_backup_ok'] = 'Оригиналы сохранены в папку: ';
+$_['text_apply_no_backup'] = 'Оригиналы удалены.';
+$_['text_error_source'] = 'Ошибка: Папка с оптимизированными файлами не найдена.';
+$_['confirm_full_cleanup'] = 'ВНИМАНИЕ! Это действие безвозвратно удалит ВСЕ папки бэкапов (backup_*) и временные папки с копиями. Продолжить?';
+$_['text_tools_desc'] = 'Глобальные сканеры и инструменты для работы с файловой системой и БД изображений.';
+$_['text_tool_select'] = 'Выберите инструмент:';
+$_['text_group_scanners'] = 'Сканеры и Анализ';
+$_['text_group_generators'] = 'Генераторы и Обработка';
+$_['text_group_experimental'] = 'Экспериментальные функции (BETA)';
+$_['text_experimental_warn'] = 'Внимание: Экспериментальные функции затрагивают системные файлы или базу данных. Настоятельно рекомендуется сделать бэкап!';
+$_['text_tool_broken_db'] = 'Сканер битых/пустых фото БД';
+$_['text_tool_watermark'] = 'Динамический Вотермарк (Только на кэш)';
+$_['text_tool_duplicates'] = 'Поиск дубликатов файлов (по MD5)';
+$_['text_tool_png_jpg'] = 'Конвертер тяжелых PNG в JPG (сканер файлов без прозрачности)';
+$_['text_tool_placeholders'] = 'Генератор заглушек (Вместо No Image)';
+$_['text_tool_exif'] = 'Очистка EXIF-данных (Геотеги/Мета)';
+$_['text_tool_small_photos'] = 'Сканер слишком мелких фото';
+$_['text_tool_html_broken'] = 'Поиск битых картинок в HTML (Описаниях)';
+$_['text_tool_empty_folders'] = 'Очистка пустых папок (image/catalog)';
+$_['text_tool_smart_cache'] = 'Умная очистка кэша изображений';
+$_['text_tool_folder_tree'] = 'Дерево папок (Статистика веса)';
+$_['text_tool_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load (Без БД)';
+$_['text_tool_translit'] = 'Транслитерация названий файлов';
+$_['text_tool_cache_restore'] = 'Воскрешение оригиналов из Кэша';
+$_['entry_wm_type'] = 'Тип вотермарка';
+$_['text_wm_image'] = 'Изображение (PNG)';
+$_['text_wm_text'] = 'Текст';
+$_['entry_wm_image_path'] = 'Путь к изображению';
+$_['entry_wm_text_val'] = 'Текст вотермарка';
+$_['entry_wm_position'] = 'Позиция (1-9)';
+$_['entry_wm_opacity'] = 'Прозрачность (0-100)';
+$_['entry_wm_category'] = 'Только для Категорий';
+$_['entry_wm_brand'] = 'Только для Брендов';
+$_['entry_min_width'] = 'Минимальная ширина (px)';
+$_['entry_min_height'] = 'Минимальная высота (px)';
+$_['entry_ph_product'] = 'Заглушка для Товаров';
+$_['entry_ph_category'] = 'Заглушка для Категорий';
+$_['entry_ph_brand'] = 'Заглушка для Брендов';
+$_['button_run_tool'] = 'Запустить инструмент';
+$_['button_scan_tool'] = 'Сканировать';
+$_['button_fix_tool'] = 'Исправить найденное';
+$_['text_th_path'] = 'Файл / Путь';
+$_['text_th_size'] = 'Размер';
+$_['text_th_action'] = 'Действие';
+$_['text_th_status'] = 'Статус';
+$_['text_th_problem'] = 'Проблема / Значение';
+$_['text_cron_title'] = 'Настройка Cron';
+$_['text_cron_desc'] = 'Добавьте команду в Cron хостинга для автоматического фонового сжатия.';
+$_['entry_cron_new_only'] = 'Обрабатывать только новые (необработанные) файлы';
+$_['entry_cron_folders'] = 'Папки для сканирования (оставьте пустым для всего каталога)';
+$_['text_promo_title'] = 'Другие модули серии [NAT]';
+$_['text_promo_desc'] = 'Модули серии [NAT] — это инструменты для глубокой оптимизации OpenCart. Мы фокусируемся на автоматизации рутины, ускорении базы данных и очистке серверов от мусора. Все модули имеют открытый исходный код и единую логику управления.';
+$_['text_support'] = 'Техническая поддержка и пожелания:';
+$_['button_more'] = 'Смотреть все модули на OpenCart.com';
+$_['button_more_forum'] = 'Opencartforum';
+$_['text_mod_cat_opt'] = 'Catalog Optimizer [NAT]';
+$_['text_mod_cat_opt_desc'] = 'Скрытие пустых категорий и брендов + SEO сортировка товаров (в наличии сверху) + Очистка мусора БД.';
+$_['text_mod_new_arrivals'] = 'New Arrivals [NAT]: Новинки по датам';
+$_['text_mod_new_arrivals_desc'] = 'Автоматическая SEO-страница новинок. Отображает новинки строго по реальной дате добавления. Слайдеры, сетка и умный сайдбар архива дат.';
+$_['text_mod_img_opt'] = 'Image Optimizer [NAT]';
+$_['text_mod_img_opt_desc'] = 'Текущий модуль. Сжатие и ресайз ОРИГИНАЛОВ фото (image/catalog) в WebP + Очистка хостинга от неиспользуемых изображений.';
+$_['text_mod_dom_scan'] = 'Domain Scanner [NAT]';
+$_['text_mod_dom_scan_desc'] = 'Поиск и чистка внешних ссылок и HTTP-картинок в БД и шаблонах (для переезда на HTTPS).';
+$_['text_mod_poll'] = 'Poll Widget [NAT]';
+$_['text_mod_poll_desc'] = 'Создание опросов и голосований с визуальным конструктором дизайна, глубокой статистикой и защитой от накруток.';
+$_['text_mod_db_opt'] = 'Database Optimizer [NAT]';
+$_['text_mod_db_opt_desc'] = 'Интеллектуальная очистка мусора в базе данных, управление системными логами и добавление критически важных индексов для ускорения.';
+$_['text_mod_im_ex'] = 'Import/Export [NAT]';
+$_['text_mod_im_ex_desc'] = 'Быстрый импорт/экспорт цен и остатков из файлов xls/xlsx/csv/xml/json или по ссылке. Маппинг полей, работа по Cron, текстовые функции.';
+$_['text_mod_admin_tools'] = 'Admin Tools [NAT]';
+$_['text_mod_admin_tools_desc'] = 'Удобные инструменты администратора: вход под клиентом, кастомизация админки, управление правами пользователей и скрытие ненужных пунктов меню.';
+$_['text_mod_utm_tracker'] = 'Order UTM Tracker [NAT]';
+$_['text_mod_utm_tracker_desc'] = 'Автоматическое отслеживание UTM-меток при оформлении заказа. Сохраняет источник перехода, кампанию, ключевые слова и выводит эти данные в заказе для аналитики рекламы.';
+$_['text_info_title_promo_top_bar'] = 'Promo Top Bar [NAT]: Верхний рекламный инфо-баннер, информационная полоса над шапкой, бегущая строка и топ-баннер';
+$_['text_info_desc_promo_top_bar'] = 'Создание интерактивных промо-панелей и слайд-шоу в шапке сайта с конструктором блоков, таймерами, формой подписки и гибким таргетингом.';
+$_['text_tool_no_image'] = 'Товары без главного фото (пусто в БД)';
+$_['text_problem_main_broken'] = 'Битое главное фото (файл удален)';
+$_['text_problem_add_broken'] = 'Битое доп. фото (файл удален)';
+$_['text_problem_no_image'] = 'Отсутствует главное фото';
+$_['text_err_dir'] = 'Ошибка: Директория не найдена или неверный путь.';
+$_['text_err_no_queue'] = 'Ошибка: Очередь не найдена. Сначала запустите сканирование.';
+$_['text_log_del_empty_folder'] = 'Удалена пустая папка: ';
+$_['text_log_no_empty_folders'] = 'Пустых папок в image/catalog/ не найдено.';
+$_['text_log_total_empty_folders'] = 'Всего удалено пустых папок: ';
+$_['text_log_cache_cleared'] = 'Кэш изображений успешно очищен.';
+$_['text_log_wm_smart_mode'] = 'Вотермарки работают в SMART-режиме: оригиналы не трогаются, логотип накладывается только при генерации кэша.';
+$_['text_log_scan_dupes'] = 'Сканирование image/catalog/ на дубликаты (по хешу MD5)... Это может занять время.';
+$_['text_log_found_dupe'] = 'Найден дубликат: ';
+$_['text_log_no_dupes'] = 'Дубликаты не найдены.';
+$_['text_log_total_dupes'] = 'Найдено дубликатов: %s. Потенциальная экономия: %s';
+$_['text_log_fix_dupes_prompt'] = 'Нажмите "Исправить найденное" для перелинковки БД на оригинал и удаления дублей.';
+$_['text_log_fixed_dupe'] = 'Исправлен и удален дубликат: ';
+$_['text_log_success_dupes'] = 'Успешно исправлено дубликатов: %s. Ссылки в БД обновлены.';
+$_['text_log_scan_png'] = 'Поиск тяжелых PNG файлов без прозрачности...';
+$_['text_log_no_png'] = 'Подходящих PNG файлов не найдено.';
+$_['text_log_total_png'] = 'Найдено PNG без прозрачности: %s. Нажмите "Исправить" для конвертации в JPG.';
+$_['text_log_converted_jpg'] = 'Сконвертировано в JPG: ';
+$_['text_log_success_png'] = 'Успешно сконвертировано %s файлов. Освобождено: %s';
+$_['text_log_ph_prod'] = 'Обновлено товаров заглушкой: ';
+$_['text_log_ph_cat'] = 'Обновлено категорий заглушкой: ';
+$_['text_log_ph_brand'] = 'Обновлено брендов заглушкой: ';
+$_['text_log_ph_empty'] = 'Заглушки не выбраны в настройках.';
+$_['text_log_scan_exif'] = 'Сканирование EXIF-данных (мета-теги камер)...';
+$_['text_log_stripped_exif'] = 'Очищен EXIF: ';
+$_['text_log_no_exif'] = 'Файлов с EXIF для очистки не найдено.';
+$_['text_log_success_exif'] = 'Очищен EXIF у %s файлов. Освобождено: %s';
+$_['text_log_scan_small'] = 'Поиск фото размером менее %sx%s px...';
+$_['text_log_too_small'] = 'Слишком маленькое (%sx%s): ';
+$_['text_log_no_small'] = 'Мелких фото не найдено.';
+$_['text_log_total_small'] = 'Найдено мелких фото: %s. Пожалуйста, замените их на качественные.';
+$_['text_log_html_broken'] = 'Битый IMG в HTML таблицы %s (ID: %s): ';
+$_['text_log_no_html_broken'] = 'Битых картинок в HTML-описаниях не найдено.';
+$_['text_log_total_html_broken'] = 'Найдено битых ссылок в HTML: %s. Исправьте их вручную в редакторе.';
+$_['text_log_scan_smart_cache'] = 'Поиск потерянных файлов в image/cache/...';
+$_['text_log_del_orph_cache'] = 'Удален потерянный кэш: ';
+$_['text_log_no_orph_cache'] = 'Потерянного кэша не найдено.';
+$_['text_log_success_smart_cache'] = 'Удалено файлов потерянного кэша: %s. Освобождено: %s';
+$_['text_log_scan_translit'] = 'Поиск файлов с кириллицей или пробелами...';
+$_['text_log_no_bad_names'] = 'Файлов с некорректными именами не найдено.';
+$_['text_log_total_bad_names'] = 'Найдено файлов с кириллицей/пробелами: %s. Нажмите "Исправить" для транслитерации и обновления БД.';
+$_['text_log_renamed'] = 'Переименовано: ';
+$_['text_log_success_translit'] = 'Успешно транслитерировано файлов: %s. БД обновлена.';
+$_['text_log_scan_restore'] = 'Сканирование image/cache/ для восстановления утерянных оригиналов...';
+$_['text_log_restored_cache'] = 'Восстановлено из кэша: ';
+$_['text_log_no_lost_orig'] = 'Отсутствующих оригиналов в кэше не найдено.';
+$_['text_log_success_restore'] = 'Успешно восстановлено оригиналов из кэша: %s.';
+$_['error_invalid_folder'] = 'Ошибка: Недопустимое имя папки!';
+$_['text_formats_title'] = 'Современные форматы (WebP & SVG)';
+$_['text_formats_desc'] = 'Настройки интеграции современных форматов в файловую систему и шаблоны OpenCart.';
+$_['entry_support_svg'] = 'Поддержка загрузки SVG';
+$_['help_support_svg'] = 'Разрешает загрузку векторных SVG-изображений через стандартный Filemanager и их безопасный вывод на витрине без искажений размеров.';
+$_['entry_support_webp'] = 'Поддержка загрузки WebP';
+$_['help_support_webp'] = 'Позволяет вручную загружать, подвязывать и выводить на сайте готовые файлы .webp через Filemanager.';
+$_['entry_truefilemanager_webp'] = 'WebP в TrueFileManager';
+$_['help_truefilemanager_webp'] = 'Добавляет поддержку загрузки, предпросмотра и эскизов WebP в TrueFileManager от sitecreator (если он установлен).';
+$_['entry_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load';
+$_['help_webp_on_fly'] = 'Сверхбыстрая подмена: автоматически меняет расширения .jpg/.png на .webp прямо в HTML-выводе витрины и добавляет атрибут loading="lazy". Не трогает базу данных!';
+$_['help_tool_broken_db'] = 'Ищет в базе данных картинки, физические файлы которых были удалены с сервера. Позволяет очистить битые ссылки.';
+$_['help_tool_empty_folders'] = 'Рекурсивно сканирует директорию image/catalog/ и безопасно удаляет все папки, в которых нет ни одного файла.';
+$_['help_tool_folder_tree'] = 'Строит дерево каталогов и показывает реальный вес каждой папки для поиска "пожирателей" места.';
+$_['help_tool_html_broken'] = 'Сканирует HTML-описания товаров и категорий на наличие тегов с несуществующими картинками.';
+$_['help_tool_small_photos'] = 'Находит картинки с разрешением ниже заданного (например, меньше 300x300 px). Добавлены ссылки на поиск качественных аналогов в Google Images и Яндекс Картинках.';
+$_['help_tool_duplicates'] = 'Ищет полные дубликаты файлов по MD5 хешу (разные имена, но одинаковая картинка). Позволяет склеить их в один файл и обновить БД.';
+$_['help_tool_watermark'] = 'Наложение водяных знаков в SMART-режиме (только на кэш витрины, оригиналы не изменяются).';
+$_['help_tool_placeholders'] = 'Массово устанавливает выбранную картинку-заглушку для товаров и категорий, у которых вообще нет фото.';
+$_['help_tool_png_jpg'] = 'Инструмент сканирует папку изображений и находит тяжелые PNG-файлы, которые не имеют прозрачного фона (альфа-канала). Поскольку прозрачность им не нужна, их можно безопасно конвертировать в формат JPG. Это значительно облегчает файлы (в среднем на 70-80%) и ускоряет загрузку сайта, а все ссылки в базе данных автоматически обновляются.';
+$_['help_tool_exif'] = 'Сканирует JPEG-файлы и удаляет из них скрытые EXIF-данные (геотеги, данные о камере), уменьшая вес на 10-15%.';
+$_['help_tool_smart_cache'] = 'Ищет в папке image/cache/ кэшированные файлы, оригиналы которых уже удалены из image/catalog/, и стирает этот мусор.';
+$_['help_tool_translit'] = 'Находит файлы с кириллицей или пробелами в названиях, переименовывает их транслитом (tovar_1.jpg) и обновляет пути в БД.';
+$_['help_tool_cache_restore'] = 'Если вы случайно удалили оригинал из catalog, инструмент попытается найти его копию в cache и восстановить обратно.';
+$_['entry_quarantine'] = 'Переместить оригиналы в Карантин (вместо удаления)';
+$_['entry_backup_db'] = 'Создать бэкап изменяемых таблиц в SQL перед запуском';
+$_['button_fix_selected'] = 'ИСПРАВИТЬ ВЫБРАННЫЕ';
+$_['button_export_log'] = 'Экспортировать в CSV';
+$_['text_th_before'] = 'Состояние ДО';
+$_['text_th_after'] = 'Ожидаемый результат ПОСЛЕ';
+$_['text_th_select'] = 'Действие';
+$_['text_th_preview'] = 'Предпросмотр';
+$_['text_th_name'] = 'Объект / Файл';
+$_['text_global_action'] = 'Групповое действие для выбранных:';
+$_['text_action_clear'] = 'Очистить ссылку в БД';
+$_['text_action_disable'] = 'Отключить (Статус = 0)';
+$_['text_action_stock'] = 'Установить "Нет в наличии"';
+$_['text_action_placeholder'] = 'Установить заглушку';
+$_['text_action_ignore'] = 'Ничего не делать';
+$_['text_action_merge'] = 'Склеить (удалить дубликат, оставить оригинал)';
+$_['text_action_convert'] = 'Конвертировать в JPG';
+$_['text_action_rename'] = 'Переименовать транслитом';
+$_['text_action_delete'] = 'Удалить';
+$_['text_action_restore'] = 'Восстановить';
+$_['text_db_backup_created'] = 'Создан бэкап БД: %s';
+$_['text_quarantine_created'] = 'Перенесено в карантин: %s';
+$_['text_fix_status_success'] = 'Успешно выполнено';
+$_['text_fix_status_error'] = 'Ошибка: %s';
+$_['text_log_exported'] = 'Лог успешно экспортирован!';
+$_['text_select_action'] = '-- Выберите действие --';
+$_['error_empty_selection'] = 'Ошибка: Не выбрано ни одной строки для исправления!';
+$_['text_link_admin'] = 'Админка';
+$_['text_link_catalog'] = 'Витрина';
+$_['entry_threads'] = 'Потоков обработки';
+$_['text_threads_optimal'] = 'оптимально';
+$_['button_download_zip'] = 'Скачать ZIP-архив и удалить папку';
+$_['confirm_download_and_delete'] = 'Архив успешно скачался? Можно удалить временную папку копий на сервере?';
+$_['button_clear_backups'] = 'Удалить бэкапы БД';
+$_['button_clear_backups_short'] = 'Очистить бэкапы';
+$_['confirm_clear_backups'] = 'Вы уверены, что хотите полностью удалить все SQL-бэкапы таблиц?';
+$_['text_compare_title'] = 'Сравнение изображений ДО / ПОСЛЕ';
+$_['text_compare_before'] = 'ДО';
+$_['text_compare_after'] = 'ПОСЛЕ';
+$_['text_backups_cleared'] = 'Все SQL-бэкапы таблиц успешно удалены!';
+$_['error_no_backups'] = 'Бэкапы не найдены или папка пуста.';
+$_['button_restore'] = 'Вернуть';
+$_['button_empty'] = 'Очистить';
+$_['entry_wm_targets'] = 'Применять водяной знак к:';
+$_['text_wm_target_product'] = 'Фото товаров';
+$_['text_wm_target_category'] = 'Фото категорий';
+$_['text_wm_target_brand'] = 'Логотипы брендов';
+$_['text_wm_target_banner'] = 'Баннеры и слайды';
+$_['text_wm_target_blog'] = 'Статьи / Блог';
+$_['error_no_scan_data'] = 'Ошибка: Данные сканирования не найдены. Сначала запустите сканирование!';
+$_['error_row_not_found'] = 'Ошибка: Объект не найден в результатах сканирования!';
+$_['error_file_not_found'] = 'Ошибка: Файл не найден на сервере!';
+$_['text_folder_rules_title'] = 'Индивидуальные правила сжатия для папок';
+$_['text_folder_rules_desc'] = 'Вы можете переопределить качество и максимальные размеры для конкретных папок. Если оставить поле пустым, будет использоваться глобальная настройка. Путь указывается относительно папки image (например, catalog/banners/).';
+$_['entry_folder_path'] = 'Путь к папке';
+$_['entry_jpg_quality'] = 'Качество JPG';
+$_['entry_webp_quality'] = 'Качество WebP';
+$_['button_add_rule'] = 'Добавить правило';
+$_['button_remove'] = 'Удалить';
+$_['entry_wm_angle'] = 'Угол наклона водяного знака (градусы)';
+$_['entry_wm_corner_radius'] = 'Скругление углов водяного знака (px)';
+$_['entry_wm_size_type'] = 'Масштабирование водяного знака';
+$_['entry_wm_size_percent'] = 'Размер водяного знака (% от ширины изображения)';
+$_['entry_wm_text_color'] = 'Цвет текста водяного знака';
+$_['entry_wm_text_font'] = 'Шрифт водяного знака (TTF)';
+$_['entry_wm_filter_mode'] = 'Режим фильтрации Категорий/Брендов';
+$_['text_wm_filter_include'] = 'Только для выбранных';
+$_['text_wm_filter_exclude'] = 'Для всех, кроме выбранных';
+$_['text_wm_size_original'] = 'Оригинальный размер';
+$_['text_wm_size_percent'] = 'Пропорционально ширине изображения (%)';
+$_['button_preview_watermark'] = 'Предпросмотр водяного знака';
+$_['text_wm_preview_title'] = 'Предпросмотр водяного знака';
+$_['text_cron_exclude_folders'] = 'Исключить папки из сканирования';
+$_['text_cron_entities'] = 'Какие типы фото обрабатывать?';
+$_['text_cron_quality_override'] = 'Переопределить параметры сжатия для Крона';
+$_['text_cron_recommendations'] = 'Рекомендации по расписанию запуска Cron';
+$_['text_cron_recommendations_desc'] = '
0 * * * *).0 */6 * * *).0 2 * * *).';
+$_['text_tool_broken_files'] = 'Сканер битых/пустых файлов на сервере';
+$_['help_tool_broken_files'] = 'Сканирует физические файлы в папке image/catalog/ на наличие поврежденных изображений или пустых файлов размером 0 байт.';
+$_['text_grid_broken_file'] = 'Битый или пустой файл на сервере';
+$_['text_grid_broken_file_after'] = 'Удаление или замена заглушкой';
+$_['text_description_broken_files'] = 'Результаты сканирования физических файлов в папке image/catalog/. Здесь отображаются битые (поврежденные) изображения или файлы размером 0 байт.';
+$_['text_action_strip'] = 'Очистить EXIF';
+$_['text_action_set_placeholder'] = 'Установить заглушку';
+$_['text_th_entity'] = 'Объект / Связанные ссылки';
+$_['text_cron_entity_product'] = 'Товары';
+$_['text_cron_entity_category'] = 'Категории';
+$_['text_cron_entity_manufacturer'] = 'Производители / Бренды';
+$_['text_cron_entity_banner'] = 'Баннеры';
+$_['text_cron_entity_blog'] = 'Блог / Статьи';
+$_['entry_cron_jpg_quality'] = 'Качество JPG (Крон)';
+$_['entry_cron_webp_quality'] = 'Качество WebP (Крон)';
+$_['entry_cron_max_width'] = 'Макс. ширина px (Крон)';
+$_['entry_cron_max_height'] = 'Макс. высота px (Крон)';
+$_['text_cron_folder_scan'] = 'Папки для сканирования';
+$_['text_no_fonts'] = 'TTF-шрифты не найдены в system/library/font/';
+$_['button_apply'] = 'Применить';
+$_['text_backup_will_be_created'] = 'Резервная копия базы данных будет автоматически создана перед выполнением этого действия.';
+$_['text_bulk_action'] = 'Групповое действие для выбранных';
+$_['text_grid_results'] = 'Результаты и детали сканирования';
+$_['text_quarantine_will_be_created'] = 'Оригинальные файлы будут перемещены в карантин (image/catalog_trash/) перед изменением.';
+$_['text_success_apply'] = 'Изменения успешно применены!';
+$_['text_description_broken'] = 'Результаты сканирования базы данных на наличие битых ссылок (записи в таблицах с несуществующими файлами).';
+$_['text_description_cache_restore'] = 'Результаты поиска файлов в кэше, оригиналы которых отсутствуют в каталоге.';
+$_['text_description_duplicates'] = 'Результаты поиска полных дубликатов изображений по MD5-хешу.';
+$_['text_description_empty_folders'] = 'Список пустых папок в директории image/catalog/ для удаления.';
+$_['text_description_exif'] = 'Результаты поиска изображений с метаданными EXIF, которые можно очистить.';
+$_['text_description_folder_tree'] = 'Статистика размера папок в каталоге image/catalog/.';
+$_['text_description_html_broken'] = 'Результаты поиска несуществующих файлов картинок, на которые ссылаются описания в HTML.';
+$_['text_description_placeholders'] = 'Результаты поиска сущностей без изображений для заполнения заглушками.';
+$_['text_description_png_jpg'] = 'Результаты поиска тяжелых изображений PNG без прозрачности, которые можно пережать в JPG.';
+$_['text_description_small_photos'] = 'Список изображений, размеры которых меньше минимально заданных (%sx%s px).';
+$_['text_description_smart_cache'] = 'Результаты поиска файлов кэша, оригиналы которых уже были удалены.';
+$_['text_description_translit'] = 'Результаты поиска файлов с кириллицей или пробелами в названиях.';
+$_['text_grid_broken_db_after'] = 'Очистить ссылку или установить заглушку';
+$_['text_grid_dupe_after'] = 'Оригинал (остаётся)';
+$_['text_grid_dupe_before'] = 'Дубликат (будет удалён)';
+$_['text_grid_empty_folder'] = 'Пустая папка';
+$_['text_grid_empty_folder_after'] = 'Удалить пустую папку';
+$_['text_grid_exif_after'] = 'Очистить метаданные';
+$_['text_grid_exif_before'] = 'Содержит EXIF';
+$_['text_grid_html_after'] = 'Исправить ссылку вручную';
+$_['text_grid_missing_file'] = 'Файл отсутствует';
+$_['text_grid_missing_html'] = 'Картинка не найдена в описании';
+$_['text_grid_missing_orig'] = 'Оригинал отсутствует';
+$_['text_grid_missing_orig_after'] = 'Восстановить оригинал из кэша';
+$_['text_grid_no_image'] = 'Нет изображения (пусто)';
+$_['text_grid_orph_cache'] = 'Осиротевший кэш';
+$_['text_grid_orph_cache_after'] = 'Удалить ненужный кэш';
+$_['text_grid_ph_brand_after'] = 'Установить заглушку бренда';
+$_['text_grid_ph_category_after'] = 'Установить заглушку категории';
+$_['text_grid_ph_product_after'] = 'Установить заглушку товара';
+$_['text_grid_small_photo_after'] = 'Рекомендуется заменить на более крупное';
+$_['text_grid_too_small'] = 'Слишком маленькое';
+$_['text_no_relations'] = 'Нет связей в БД';
+$_['text_action_archive'] = 'Архивировать (в ZIP)';
+$_['text_action_archive_delete'] = 'Архивировать + Удалить';
+$_['text_tool_sanitizer'] = 'Санитайзер имен файлов';
+$_['help_tool_sanitizer'] = 'Массовая очистка имен файлов и папок от кириллицы, пробелов и спецсимволов, приведение расширений к единому виду.';
+$_['text_description_sanitizer'] = 'Результаты анализа имен файлов для санитаризации по выбранным правилам.';
+$_['entry_sz_translit'] = 'Транслитерация (кириллица в латиницу)';
+$_['entry_sz_spaces'] = 'Заменять пробелы на символ "_"';
+$_['entry_sz_special'] = 'Удалять специальные символы';
+$_['entry_sz_lowercase_name'] = 'Имя файла в нижний регистр';
+$_['entry_sz_lowercase_ext'] = 'Расширение в нижний регистр';
+$_['entry_sz_normalize_ext'] = 'Нормализовать расширения (jpeg -> jpg)';
+$_['entry_cron_folder_mode'] = 'Режим фильтрации папок';
+$_['text_cron_folder_mode_exclude'] = 'Сканировать все, кроме выбранных (Исключения)';
+$_['text_cron_folder_mode_include'] = 'Сканировать только выбранные папки';
+$_['button_select_all'] = 'Выбрать все';
+$_['button_deselect_all'] = 'Снять все';
+$_['entry_wm_upload_font'] = 'Загрузить свой шрифт TTF';
+$_['button_upload_font'] = 'Загрузить шрифт';
+$_['text_success_font_upload'] = 'Шрифт успешно загружен!';
+$_['error_font_upload'] = 'Ошибка загрузки шрифта! Разрешены только файлы .ttf весом до 5 МБ.';
+$_['entry_lazy_load'] = 'Прогрессивная ленивая загрузка';
+$_['help_lazy_load'] = 'Включает премиальную прогрессивную отложенную загрузку на витрине. Изображения заменяются микро-превью и плавно размываются (blur-up), подгружаясь по мере прокрутки.';
+$_['confirm_replace_mode'] = 'ВНИМАНИЕ! Вы выбрали режим «Заменять оригиналы». Все изображения будут перезаписаны прямо на сервере. Настоятельно рекомендуем сделать резервную копию. Вы уверены, что хотите продолжить?';
+$_['text_compare'] = 'Сравнить';
+$_['text_home'] = 'Главная';
+$_['text_tech_gd'] = 'Библиотека GD';
+$_['text_tech_imagick'] = 'Imagick';
+$_['text_tech_webp'] = 'Поддержка WebP';
+$_['text_position_1'] = '1 - Слева вверху';
+$_['text_position_2'] = '2 - По центру вверху';
+$_['text_position_3'] = '3 - Справа вверху';
+$_['text_position_4'] = '4 - Слева посередине';
+$_['text_position_5'] = '5 - По центру';
+$_['text_position_6'] = '6 - Справа посередине';
+$_['text_position_7'] = '7 - Слева внизу';
+$_['text_position_8'] = '8 - По центру внизу';
+$_['text_position_9'] = '9 - Справа внизу';
+
+$_['entry_log_level'] = 'Уровень лога';
+$_['text_log_changed_only'] = 'Только изменённые';
+$_['text_log_all'] = 'Все файлы (подробно)';
+$_['text_skipped_no_gain'] = 'Пропущен (нет улучшения)';
+$_['text_reset_cache_hint'] = 'Сбросить кэш оптимизации (полный пересканирование)';
+$_['confirm_reset_cache'] = 'Сбросить кэш оптимизации? Следующее сканирование проверит ВСЕ файлы заново.';
+$_['text_cache_cleared'] = 'Кэш оптимизации очищен. Следующее сканирование проверит все файлы.';
+$_['error_wm_preview'] = 'Ошибка генерации превью водяного знака.';
+$_['error_wm_too_large'] = 'Изображение водяного знака слишком большое (%dx%d px)! Максимально допустимый размер: %dx%d px.';
+$_['text_apply_errors_warning'] = 'ВНИМАНИЕ: Некоторые файлы не удалось заменить (заблокированы или нет прав):';
+$_['text_action_compress'] = 'Сжать изображения';
+$_['text_estimated'] = 'Прогноз';
+$_['entry_min_savings_bytes'] = 'Мин. сжатие (байт)';
+$_['entry_min_savings_percent'] = 'Мин. сжатие (%)';
+$_['text_optimized'] = 'Оптимизировано';
+$_['entry_broken_fallback'] = 'Заглушка для битых фото';
+$_['help_broken_fallback'] = 'Автоматически заменяет отсутствующие или поврежденные изображения каталога на витрине на изображение-заглушку.';
+$_['entry_fallback_image'] = 'Изображение-заглушка';
+$_['help_fallback_image'] = 'Выберите изображение, которое будет показано, если оригинальный файл картинки товара или категории отсутствует. По умолчанию используется no_image.png.';
+$_['text_tool_cmyk'] = 'Конвертер CMYK в RGB';
+$_['help_tool_cmyk'] = 'Конвертация JPEG изображений из цветового профиля CMYK в sRGB для корректного отображения в браузерах Safari и на устройствах iOS.';
+$_['text_tool_heavy_files'] = 'Поиск тяжелых файлов';
+$_['help_tool_heavy_files'] = 'Поиск самых тяжелых оригинальных изображений в каталоге с возможностью их быстрого сжатия прямо здесь.';
+$_['entry_heavy_files_limit'] = 'Макс. количество найденных файлов (всего)';
+$_['text_action_convert_rgb'] = 'Конвертировать в RGB';
+$_['text_description_cmyk'] = 'Найдены файлы в цветовом пространстве CMYK. Конвертация в sRGB гарантирует корректное отображение на всех платформах, включая iOS/macOS.';
+$_['text_description_heavy'] = 'Список из %d самых тяжелых файлов в каталоге изображений. Вы можете оптимизировать их прямо здесь.';
+$_['text_cron_folder_hint_exclude_all'] = 'Статус: Будут сканироваться все папки (исключения не выбраны).';
+$_['text_cron_folder_hint_exclude_some'] = 'Статус: Будут сканироваться все папки, КРОМЕ %d отмеченных.';
+$_['text_cron_folder_hint_include_some'] = 'Статус: Будут сканироваться только %d отмеченных папок.';
+$_['text_cron_folder_hint_include_none'] = 'ВНИМАНИЕ: Ни одна папка не выбрана! Крон ничего не будет сканировать.';
+$_['text_action_apply_changes'] = 'Применить указанные изменения';
+$_['entry_wm_status'] = 'Статус водяного знака';
+$_['text_disabled'] = 'Отключено';
+$_['entry_rule_name'] = 'Название правила';
+$_['entry_rule_status'] = 'Статус правила';
+$_['entry_action'] = 'Действие';
+$_['entry_ph_status'] = 'Статус заглушек';
+$_['entry_sanitizer_limit'] = 'Макс. файлов для поиска (лимит)';
+$_['entry_heavy_files_min_size'] = 'Минимальный размер';
+$_['entry_png_jpg_limit'] = 'Макс. файлов для поиска (лимит)';
+$_['entry_png_jpg_min_size'] = 'Минимальный размер';
+$_['text_wm_rule_title'] = 'Правило водяного знака';
+$_['button_close'] = 'Закрыть';
+$_['text_ph_rule_title'] = 'Правило заглушки';
+$_['entry_ph_image'] = 'Изображение заглушки';
+$_['button_clear_cache_wm'] = 'Очистить кэш (применить водяной знак)';
+$_['text_active'] = 'Активна';
+$_['text_error'] = 'Не активна';
+$_['text_th_dupe_delete'] = 'Файл для удаления (Дубликат)';
+$_['text_th_dupe_keep'] = 'Файл для сохранения (Оригинал)';
+$_['text_dupe_original_label'] = 'Оригинал';
+$_['text_dupe_duplicate_label'] = 'Дубликат';
+
+$_['text_disk_usage'] = 'Использование диска';
+$_['text_cumulative_stats'] = 'Статистика оптимизации';
+$_['text_disk_catalog'] = 'Каталог изображений';
+$_['text_disk_cache'] = 'Кэш OC';
+$_['text_disk_trash'] = 'Корзина модуля';
+$_['text_disk_other'] = 'Прочее';
+$_['text_disk_free'] = 'Свободно';
+$_['text_total_files_opt'] = 'Файлов обработано';
+$_['text_total_saved'] = 'Всего сэкономлено';
+$_['text_avg_saving_pct'] = 'Средняя экономия';
+$_['text_stats_hint'] = 'Статистика накапливается по всем сессиям (хранится локально в браузере)';
+$_['text_reset_stats'] = 'Сбросить статистику';
+$_['text_reset_stats_confirm']= 'Сбросить накопленную статистику оптимизации?';
+$_['text_export_import'] = 'Экспорт / Импорт настроек';
+$_['button_export_settings'] = 'Экспорт настроек (JSON)';
+$_['button_import_settings'] = 'Импортировать настройки';
+$_['help_export_settings'] = 'Скачать все настройки модуля в JSON-файл';
+$_['help_import_settings'] = 'Загрузить ранее экспортированный JSON для восстановления настроек';
+$_['text_import_success'] = 'Настройки успешно импортированы. Перезагрузите страницу для применения.';
+$_['error_import_file'] = 'Файл не загружен или ошибка загрузки';
+$_['error_import_invalid'] = 'Некорректный файл настроек (неверный модуль или формат)';
+$_['text_click_to_load'] = 'Нажмите ↑ для загрузки данных диска';
+$_['tab_formats'] = 'Форматы изображений';
+
+$_['text_disk_quota_legend'] = 'Лимиты диска хостинга (вручную)';
+$_['text_disk_quota_desc'] = 'Если график дискового пространства показывает неверные данные (физический диск сервера вместо лимита вашего хостинга), вы можете указать лимиты вашего тарифа вручную. Укажите 0, чтобы использовать автоопределение.';
+$_['entry_disk_quota_total'] = 'Выделено диска на хостинге (ГБ)';
+$_['entry_disk_quota_used'] = 'Занято диска на хостинге всего (ГБ)';
+$_['entry_auto_disk'] = 'Автообновление при входе';
+
+// Cron Tasks
+$_['entry_cron_task_name'] = 'Название задачи';
+$_['entry_cron_task_summary'] = 'Параметры сжатия';
+$_['button_add_cron_task'] = 'Добавить задачу';
+$_['button_copy_cron_url'] = 'Копировать URL';
+$_['text_cron_recommended_url'] = 'URL для запуска задачи крона:';
+
+// Licensing
+$_['text_license_required'] = 'Требуется лицензионный ключ';
+$_['text_license_required_desc'] = 'Пожалуйста, введите лицензионный ключ для активации модуля. Все функции настроек и оптимизации заблокированы до ввода валидного ключа.';
+$_['entry_token_desc'] = 'Введите токен активации, выданный для вашего основного домена. Вы можете получить его в личном кабинете на форуме или обратившись в поддержку.';
+$_['button_activate'] = 'Активировать модуль';
+$_['text_support'] = 'Техническая поддержка';
+$_['text_support_desc'] = 'Если у вас ещё нет ключа или возникли проблемы, пожалуйста, сообщите номер вашего заказа и домен.';
+
+// New settings
+$_['entry_wm_max_width'] = 'Макс. ширина водяного знака (px)';
+$_['entry_wm_max_height'] = 'Макс. высота водяного знака (px)';
+$_['help_wm_max_width'] = 'Если исходное изображение водяного знака превышает эту ширину, оно будет автоматически масштабировано. Укажите 0 или оставьте пустым для значения по умолчанию (800px).';
+$_['help_wm_max_height'] = 'Если исходное изображение водяного знака превышает эту высоту, оно будет автоматически масштабировано. Укажите 0 или оставьте пустым для значения по умолчанию (800px).';
+$_['entry_disk_status'] = 'Включить сканирование диска и график';
+$_['help_disk_status'] = 'Если включено, модуль будет периодически сканировать объем папки картинок и отображать круговую диаграмму. Рекомендуется выключить на больших сайтах для ускорения загрузки панели.';
+$_['text_disk_settings_title'] = 'Настройка лимитов и сканирования диска';
diff --git a/upload/admin/language/uk-ua/module/img_opti.php b/upload/admin/language/uk-ua/module/img_opti.php
new file mode 100644
index 0000000..a563c9d
--- /dev/null
+++ b/upload/admin/language/uk-ua/module/img_opti.php
@@ -0,0 +1,567 @@
+Image Optimizer [NAT]: Стиснення оригіналів та видалення сміття';
+$_['text_extension'] = 'Розширення';
+$_['text_edit'] = 'Налаштування модуля';
+$_['text_success'] = 'Налаштування успішно збережено!';
+$_['entry_warning'] = 'УВАГА: Обов`язково зробіть бекап файлів сайту і особливо папки /image/catalog перед початком роботи!';
+$_['text_tech'] = 'Перевірка технологій сервера:';
+$_['text_author'] = 'Технічна підтримка та побажання: info@nat.od.ua';
+$_['tab_optimize'] = 'Оптимізація та Ресайз';
+$_['tab_cleaner'] = 'Очищення невикористовуваних зображень';
+$_['tab_info'] = 'Інфо / Екосистема [NAT]';
+$_['tab_settings'] = 'Налаштування';
+$_['tab_tools'] = 'Інструменти (pro)';
+$_['tab_cron'] = 'Крон-завдання';
+$_['entry_mode'] = 'Режим роботи';
+$_['text_copy'] = 'Створити копію (безпечно)';
+$_['text_replace'] = 'Замінювати оригінали (потребує бекап!)';
+$_['entry_folder'] = 'Ім`я папки для копії';
+$_['entry_batch'] = 'Файлів за прохід';
+$_['entry_log_limit'] = 'Рядків логу на екрані';
+$_['entry_max_width'] = 'Макс. ширина (px) [0 - без ліміту]';
+$_['entry_max_height'] = 'Макс. висота (px) [0 - без ліміту]';
+$_['entry_jpg'] = 'Якість JPG (0-100)';
+$_['entry_png'] = 'Стиснення PNG (0-9)';
+$_['entry_webp'] = 'Якість WebP (0-100)';
+$_['entry_targets'] = 'Що оптимізувати?';
+$_['text_all_catalog'] = '[ Весь каталог image/catalog ]';
+$_['text_root_files'] = 'Файли тільки в корені image/catalog';
+$_['entry_threshold'] = 'Поріг заміни (%)';
+$_['help_threshold'] = '0: замінювати тільки якщо вага стала меншою. 10: дозволити збільшення ваги до 10%.';
+$_['entry_token'] = 'Токен активації (на корінь домену)';
+$_['entry_status'] = 'Статус модуля';
+$_['entry_menu_position'] = 'Позиція меню [NAT]';
+$_['text_menu_module_only'] = 'Тільки в модулях';
+$_['text_menu_header'] = 'У шапці (Header)';
+$_['text_menu_sidebar'] = 'У бічному меню (Sidebar)';
+$_['text_menu_both'] = 'Скрізь (Шапка + Сайдбар)';
+$_['text_status_on'] = 'Увімкнено';
+$_['text_status_off'] = 'Вимкнено';
+$_['entry_engine'] = 'Рушій обробки зображень';
+$_['text_engine_gd'] = 'GD (Стандарт)';
+$_['text_engine_imagick'] = 'Imagick (Висока якість, збереження профілів sRGB)';
+$_['entry_license'] = 'Ліцензія';
+$_['text_cleaner_info'] = 'Цей інструмент шукає файли, які фізично існують на диску, але ніде не використовуються в базі даних або файлах шаблону.';
+$_['entry_clean_targets'] = 'В яких папках шукати сміття?';
+$_['entry_extended_log'] = 'Розширений лог (показати використовувані photo і де вони знайдені)';
+$_['entry_process_broken'] = 'Враховувати файли з битим кодуванням (імена з символом "?")';
+$_['button_scan_trash'] = 'ЗНАЙТИ СМІТТЯ';
+$_['button_quarantine'] = 'В Карантин (Безпечно)';
+$_['button_delete_trash'] = 'Видалити назавжди';
+$_['button_empty_quarantine'] = 'Очистити папку Карантину';
+$_['button_restore_quarantine'] = 'Повернути з Карантину';
+$_['text_scan_result'] = 'Результати сканування';
+$_['text_trash_files'] = 'Сміттєвих файлів: ';
+$_['text_trash_size'] = 'Потенційно звільниться: ';
+$_['button_start'] = 'СТАРТ ОПТИМІЗАЦІЇ';
+$_['button_pause'] = 'ПАУЗА';
+$_['button_stop'] = 'ЗУПИНИТИ';
+$_['button_continue'] = 'ПРОДОВЖИТИ';
+$_['button_start_new'] = 'СТАРТ (Новий запуск)';
+$_['button_download_log'] = 'Завантажити лог в TXT';
+$_['button_download'] = 'Завантажити';
+$_['button_save'] = 'Зберегти';
+$_['button_cancel'] = 'Скасувати';
+$_['text_progress'] = 'Прогрес:';
+$_['text_old_size'] = 'Було:';
+$_['text_new_size'] = 'Стало:';
+$_['text_savings'] = 'Економія:';
+$_['text_detailed_log'] = 'Детальний лог';
+$_['text_page'] = 'Стор.';
+$_['text_waiting'] = 'Очікування запуску...';
+$_['text_enabled'] = 'Увімкнено';
+$_['text_disabled_error'] = 'Вимкнено (ПОМИЛКА)';
+$_['text_supported'] = 'Підтримується';
+$_['text_no'] = 'Ні';
+$_['error_permission'] = 'У вас немає прав для керування модулем!';
+$_['error_license'] = 'Помилка доступу: Невірний токен активації для цього домену!';
+$_['error_domain'] = ' (Домен: ';
+$_['text_found'] = 'Знайдено файлів: ';
+$_['text_compressed'] = 'Стиснуто';
+$_['text_original'] = 'Оригінал';
+$_['text_error_process'] = 'Помилка обробки: ';
+$_['error_log_empty'] = 'Лог порожній! Спочатку запустіть процес.';
+$_['text_report_header'] = '=== ЗВІТ ===';
+$_['text_total_files'] = 'Всього файлів: ';
+$_['text_searching'] = 'Пошук файлів...';
+$_['error_ajax'] = 'Помилка: ';
+$_['text_resumed'] = '▶ ПРОЦЕС ВІДНОВЛЕНО...';
+$_['text_paused'] = '⏸ ПАУЗА. Процес призупинено.';
+$_['confirm_stop'] = 'Ви впевнені, що хочете повністю перервати процес?';
+$_['confirm_delete'] = 'УВАГА! Файли будуть видалені з сервера назавжди без можливості відновлення! Продовжити?';
+$_['confirm_empty_q'] = 'Ви впевнені, що хочете повністю очистити папку карантину?';
+$_['confirm_restore'] = 'Всі файли з папки карантину будуть повернуті на свої місця. Продовжити?';
+$_['button_stopping'] = 'Зупинка...';
+$_['text_stopped_user'] = '🛑 ПРОЦЕС ПЕРЕРВАНО КОРИСТУВАЧЕМ!';
+$_['text_increase'] = 'Збільшення';
+$_['text_done'] = '✅ ГОТОВО! Процес завершено.';
+$_['error_timeout'] = 'Таймаут сервера. Автоповтор через 3 секунди...';
+$_['text_building_index'] = 'Крок 1/2: Індексація БД та шаблонів (пошук фото, що використовуються)...';
+$_['text_comparing'] = 'Крок 2/2: Порівняння фізичних файлів з індексом...';
+$_['text_quarantine_done'] = '✅ Процес перенесення в image/catalog_trash/ завершено!';
+$_['text_deleted_done'] = '✅ Процес видалення сміття завершено!';
+$_['text_quarantine_empty'] = '✅ Папку карантину успішно видалено з сервера!';
+$_['text_restore_done'] = '✅ Файли успішно повернуто з карантину в робочу папку!';
+$_['text_quarantine_move'] = 'В карантині: ';
+$_['text_deleted_file'] = 'Видалено: ';
+$_['text_no_trash'] = 'У вибраних папках сміття не знайдено! Всі файли використовуються.';
+$_['text_scanning_dirs'] = 'Скануємо директорії: ';
+$_['text_log_used'] = '[ВИКОРИСТОВУЄТЬСЯ]';
+$_['text_log_trash'] = '[СМІТТЯ]';
+$_['text_log_deleted'] = '[ВИДАЛЕНО]';
+$_['text_log_del_err'] = '[ПОМИЛКА ВИДАЛЕННЯ]';
+$_['text_log_quarantine'] = '[В КАРАНТИН]';
+$_['text_log_q_err'] = '[ПОМИЛКА ПЕРЕНЕСЕННЯ]';
+$_['text_log_restored'] = '[ВІДНОВЛЕНО]';
+$_['text_log_rest_err'] = '[ПОМИЛКА ВІДНОВЛЕННЯ]';
+$_['text_used_in'] = 'у: ';
+$_['text_analyzing'] = 'Аналіз бази даних...';
+$_['text_clean_empty'] = 'Чисто! Сміття немає.';
+$_['text_processing'] = 'Обробка...';
+$_['text_restoring'] = 'Відновлення файлів...';
+$_['text_all_folders'] = 'image/catalog/ (Та всі вкладені)';
+$_['text_root_folder'] = 'image/catalog/ (Тільки корінь)';
+$_['text_q_empty_err'] = 'Папка карантину порожня або не існує.';
+$_['error_token'] = 'Невірний токен. Перезавантажте сторінку.';
+$_['text_apply_title'] = 'Оптимізацію завершено в папку: ';
+$_['text_apply_info'] = 'Перевірте результат. Якщо все влаштовує — застосуйте зміни до основного каталогу.';
+$_['entry_apply_backup'] = 'Перенести зі створенням бекапу (в Карантин)';
+$_['entry_apply_replace'] = 'Замінити оригінали безповоротно';
+$_['button_apply_main'] = 'ЗАСТОСУВАТИ ДО ОСНОВНОГО КАТАЛОГУ';
+$_['button_delete_copies'] = 'Видалити тимчасові копії (скасування)';
+$_['button_full_cleanup'] = 'Повне очищення всіх тимчасових папок та бекапів';
+$_['text_apply_success'] = '✅ Файли успішно перенесено. ';
+$_['text_apply_backup_ok'] = 'Оригінали збережено в папку: ';
+$_['text_apply_no_backup'] = 'Оригінали видалено.';
+$_['text_error_source'] = 'Помилка: Папку з оптимізованими файлами не знайдено.';
+$_['confirm_full_cleanup'] = 'УВАГА! Ця дія безповоротно видалить УСІ папки бекапів (backup_*) та тимчасові папки з копіями. Продовжити?';
+$_['text_tools_desc'] = 'Глобальні сканери та інструменти для роботи з файловою системою і БД зображень.';
+$_['text_tool_select'] = 'Виберіть інструмент:';
+$_['text_group_scanners'] = 'Сканери та Аналіз';
+$_['text_group_generators'] = 'Генератори та Обробка';
+$_['text_group_experimental'] = 'Експериментальні функції (BETA)';
+$_['text_experimental_warn'] = 'Увага: Експериментальні функції зачіпають системні файли або базу даних. Настійно рекомендується зробити бекап!';
+$_['text_tool_broken_db'] = 'Сканер битих/порожніх фото БД';
+$_['text_tool_watermark'] = 'Динамічний Вотермарк (Тільки на кеш)';
+$_['text_tool_duplicates'] = 'Поиск дубликатов файлов (за MD5)';
+$_['text_tool_png_jpg'] = 'Конвертер важких PNG у JPG (сканер файлів без прозорості)';
+$_['text_tool_placeholders'] = 'Генератор заглушок (Замість No Image)';
+$_['text_tool_exif'] = 'Очищення EXIF-даних (Геотеги/Мета)';
+$_['text_tool_small_photos'] = 'Сканер занадто дрібних фото';
+$_['text_tool_html_broken'] = 'Пошук битих картинок в HTML (Описах)';
+$_['text_tool_empty_folders'] = 'Очищення порожніх папок (image/catalog)';
+$_['text_tool_smart_cache'] = 'Розумне очищення кешу зображень';
+$_['text_tool_folder_tree'] = 'Дерево папок (Статистика ваги)';
+$_['text_tool_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load (Без БД)';
+$_['text_tool_translit'] = 'Транслітерація назв файлів';
+$_['text_tool_cache_restore'] = 'Відновлення оригіналів із Кешу';
+$_['entry_wm_type'] = 'Тип водяного знака';
+$_['text_wm_image'] = 'Зображення (PNG)';
+$_['text_wm_text'] = 'Текст';
+$_['entry_wm_image_path'] = 'Шлях до зображення';
+$_['entry_wm_text_val'] = 'Текст водяного знака';
+$_['entry_wm_position'] = 'Позиція (1-9)';
+$_['entry_wm_opacity'] = 'Прозорість (0-100)';
+$_['entry_wm_category'] = 'Тільки для Категорій';
+$_['entry_wm_brand'] = 'Тільки для Брендів';
+$_['entry_min_width'] = 'Мінімальна ширина (px)';
+$_['entry_min_height'] = 'Мінімальна висота (px)';
+$_['entry_ph_product'] = 'Заглушка для Товарів';
+$_['entry_ph_category'] = 'Заглушка для Категорій';
+$_['entry_ph_brand'] = 'Заглушка для Брендів';
+$_['button_run_tool'] = 'Запустити інструмент';
+$_['button_scan_tool'] = 'Сканувати';
+$_['button_fix_tool'] = 'Виправити знайдене';
+$_['text_th_path'] = 'Файл / Шлях';
+$_['text_th_size'] = 'Розмір';
+$_['text_th_action'] = 'Дія';
+$_['text_th_status'] = 'Статус';
+$_['text_th_problem'] = 'Проблема / Значення';
+$_['text_cron_title'] = 'Налаштування Cron';
+$_['text_cron_desc'] = 'Додайте команду в Cron хостингу для автоматичного фонового стиснення.';
+$_['entry_cron_new_only'] = 'Обробляти тільки нові (необроблені) файли';
+$_['entry_cron_folders'] = 'Папки для сканування (залиште порожнім для всього каталогу)';
+$_['text_promo_title'] = 'Інші модулі серії [NAT]';
+$_['text_promo_desc'] = 'Модулі серії [NAT] – це інструменти для глибокої оптимізації OpenCart. Ми фокусуємося на автоматизації рутини, прискоренні бази даних та очищенні серверів від сміття. Усі модулі мають відкритий вихідний код та єдину логіку управління.';
+$_['text_support'] = 'Технічна підтримка та побажання:';
+$_['button_more'] = 'Дивитися всі модулі на OpenCart.com';
+$_['button_more_forum'] = 'Opencartforum';
+$_['text_mod_cat_opt'] = 'Catalog Optimizer [NAT]';
+$_['text_mod_cat_opt_desc'] = 'Приховування порожніх категорій і брендів + SEO сортування товарів (у наявності зверху) + Очищення сміття БД.';
+$_['text_mod_new_arrivals'] = 'New Arrivals [NAT]: Новинки за датами';
+$_['text_mod_new_arrivals_desc'] = 'Автоматична SEO-сторінка новинок. Відображає новинки суворо за реальною датою додавання. Слайдери, сітка та розумний сайдбар архіву дат.';
+$_['text_mod_img_opt'] = 'Image Optimizer [NAT]';
+$_['text_mod_img_opt_desc'] = 'Поточний модуль. Стиснення та ресайз ОРИГІНАЛІВ фото (image/catalog) у WebP + Очищення хостингу від невикористаних зображень.';
+$_['text_mod_dom_scan'] = 'Domain Scanner [NAT]';
+$_['text_mod_dom_scan_desc'] = 'Пошук і чищення зовнішніх посилань і HTTP-картинок у БД та файлах теми. Ідеально для HTTPS або очищення після парсингу.';
+$_['text_mod_poll'] = 'Poll Widget [NAT]';
+$_['text_mod_poll_desc'] = 'Створення опитувань та голосувань із візуальним конструктором дизайну, глибокою статистикою та захистом від накруток.';
+$_['text_mod_db_opt'] = 'Database Optimizer [NAT]';
+$_['text_mod_db_opt_desc'] = 'Інтелектуальне очищення сміття в базі даних, управління системними логами та додавання критично важливих індексів для прискорення.';
+$_['text_mod_im_ex'] = 'Import/Export [NAT]';
+$_['text_mod_im_ex_desc'] = 'Швидкий імпорт/експорт цін і залишків з файлів xls/xlsx/csv/xml/json або за посиланням. Маппінг полів, робота по Cron, текстові функції.';
+$_['text_mod_admin_tools'] = 'Admin Tools [NAT]';
+$_['text_mod_admin_tools_desc'] = 'Зручні інструменти адміністратора: вхід під клієнтом, кастомізація адмінки, управління правами користувачів та приховування непотрібних пунктів меню.';
+$_['text_mod_utm_tracker'] = 'Order UTM Tracker [NAT]';
+$_['text_mod_utm_tracker_desc'] = 'Автоматичне відстеження UTM-міток при оформленні замовлення. Зберігає джерело переходу, кампанію, ключові слова та виводить ці дані в замовленні для аналітики реклами.';
+$_['text_info_title_promo_top_bar'] = 'Promo Top Bar [NAT]: Верхній рекламний інфо-баннер, інформаційна смуга над шапкою, рухомий рядок та топ-баннер';
+$_['text_info_desc_promo_top_bar'] = 'Створення інтерактивних промо-панелей та слайд-шоу в шапці сайту з конструктором блоків, таймерами, формою підписки та гнучким націлюванням.';
+$_['text_tool_no_image'] = 'Товари без головного фото (порожньо в БД)';
+$_['text_problem_main_broken'] = 'Бите головне фото (файл видалено)';
+$_['text_problem_add_broken'] = 'Бите дод. фото (файл видалено)';
+$_['text_problem_no_image'] = 'Відсутнє головне фото';
+$_['text_err_dir'] = 'Помилка: Директорію не знайдено або невірний шлях.';
+$_['text_err_no_queue'] = 'Помилка: Чергу не знайдено. Спочатку запустіть сканування.';
+$_['text_log_del_empty_folder'] = 'Видалено порожню папку: ';
+$_['text_log_no_empty_folders'] = 'Порожніх папок в image/catalog/ не знайдено.';
+$_['text_log_total_empty_folders'] = 'Всього видалено порожніх папок: ';
+$_['text_log_cache_cleared'] = 'Кеш зображень успішно очищено.';
+$_['text_log_wm_smart_mode'] = 'Вотермарки працюють в SMART-режимі: оригінали не зачіпаються, логотип накладається лише при генерації кешу.';
+$_['text_log_scan_dupes'] = 'Сканування image/catalog/ на дублікати (за хешем MD5)... Це може зайняти час.';
+$_['text_log_found_dupe'] = 'Знайдено дублікат: ';
+$_['text_log_no_dupes'] = 'Дублікатів не знайдено.';
+$_['text_log_total_dupes'] = 'Знайдено дублікатів: %s. Потенційна економія: %s';
+$_['text_log_fix_dupes_prompt'] = 'Натисніть "Виправити знайдене" для перелінкування БД на оригинал та видалення дублів.';
+$_['text_log_fixed_dupe'] = 'Виправлено та видалено дублікат: ';
+$_['text_log_success_dupes'] = 'Успішно виправлено дублікатів: %s. Посилання в БД оновлено.';
+$_['text_log_scan_png'] = 'Пошук важких PNG файлів без прозорості...';
+$_['text_log_no_png'] = 'Відповідних PNG файлів не знайдено.';
+$_['text_log_total_png'] = 'Знайдено PNG без прозорості: %s. Натисніть "Виправити" для конвертації в JPG.';
+$_['text_log_converted_jpg'] = 'Сконвертовано в JPG: ';
+$_['text_log_success_png'] = 'Успішно сконвертовано %s файлів. Звільнено: %s';
+$_['text_log_ph_prod'] = 'Оновлено товарів заглушкою: ';
+$_['text_log_ph_cat'] = 'Оновлено категорій заглушкою: ';
+$_['text_log_ph_brand'] = 'Оновлено брендів заглушкою: ';
+$_['text_log_ph_empty'] = 'Заглушки не вибрані в налаштуваннях.';
+$_['text_log_scan_exif'] = 'Сканування EXIF-даних (мета-теги камер)...';
+$_['text_log_stripped_exif'] = 'Очищено EXIF: ';
+$_['text_log_no_exif'] = 'Файлів з EXIF для очищення не знайдено.';
+$_['text_log_success_exif'] = 'Очищено EXIF у %s файлів. Звільнено: %s';
+$_['text_log_scan_small'] = 'Пошук фото розміром менше %sx%s px...';
+$_['text_log_too_small'] = 'Занадто мале (%sx%s): ';
+$_['text_log_no_small'] = 'Дрібних фото не знайдено.';
+$_['text_log_total_small'] = 'Знайдено дрібних фото: %s. Будь ласка, замініть їх на якісні.';
+$_['text_log_html_broken'] = 'Битией IMG в HTML таблиці %s (ID: %s): ';
+$_['text_log_no_html_broken'] = 'Битих картинок в HTML-описах не знайдено.';
+$_['text_log_total_html_broken'] = 'Знайдено битих посилань в HTML: %s. Виправте їх вручну.';
+$_['text_log_scan_smart_cache'] = 'Пошук втрачених файлів у image/cache/...';
+$_['text_log_del_orph_cache'] = 'Видалено втрачений кеш: ';
+$_['text_log_no_orph_cache'] = 'Втраченого кешу не знайдено.';
+$_['text_log_success_smart_cache'] = 'Видалено файлів втраченого кешу: %s. Звільнено: %s';
+$_['text_log_scan_translit'] = 'Пошук файлів з кирилицею або пробілами...';
+$_['text_log_no_bad_names'] = 'Файлів з некоректними іменами не знайдено.';
+$_['text_log_total_bad_names'] = 'Знайдено файлів з кирилицею/пробілами: %s. Натисніть "Виправити" для транслітерації та оновлення БД.';
+$_['text_log_renamed'] = 'Перейменовано: ';
+$_['text_log_success_translit'] = 'Успішно транслітеровано файлів: %s. БД оновлено.';
+$_['text_log_scan_restore'] = 'Сканирование image/cache/ для відновлення втрачених оригіналів...';
+$_['text_log_restored_cache'] = 'Відновлено з кешу: ';
+$_['text_log_no_lost_orig'] = 'Відсутніх оригіналів у кеші не знайдено.';
+$_['text_log_success_restore'] = 'Успішно відновлено оригіналів з кешу: %s.';
+$_['error_invalid_folder'] = 'Помилка: Неприпустиме ім\'я папки!';
+$_['text_formats_title'] = 'Сучасні формати (WebP & SVG)';
+$_['text_formats_desc'] = 'Налаштування інтеграції сучасних форматів у файлову систему та шаблони OpenCart.';
+$_['entry_support_svg'] = 'Підтримка завантаження SVG';
+$_['help_support_svg'] = 'Дозволяє завантаження векторних SVG-зображень через стандартний Filemanager та їх безпечне виведення на вітрині без спотворення розмірів.';
+$_['entry_support_webp'] = 'Підтримка завантаження WebP';
+$_['help_support_webp'] = 'Дозволяє вручну завантажувати, підв\'язувати та виводити на сайті готові файли .webp через Filemanager.';
+$_['entry_truefilemanager_webp'] = 'WebP у TrueFileManager';
+$_['help_truefilemanager_webp'] = 'Додає підтримку завантаження, попереднього перегляду та ескізів WebP у TrueFileManager від sitecreator (якщо його встановлено).';
+$_['entry_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load';
+$_['help_webp_on_fly'] = 'Надшвидка підміна: автоматично змінює розширення .jpg/.png на .webp прямо в HTML-виводі вітрини та додає атрибут loading="lazy". Не чіпає базу даних!';
+$_['help_tool_broken_db'] = 'Шукає в базі даних зображення, фізичні файли яких були видалені з сервера. Дозволяє очистити биті посилання.';
+$_['help_tool_empty_folders'] = 'Рекурсивно сканує директорію image/catalog/ та безпечно видаляє всі папки, в яких немає жодного файлу.';
+$_['help_tool_folder_tree'] = 'Будує дерево каталогів та показує реальну вагу кожної папки для пошуку "пожирачів" місця.';
+$_['help_tool_html_broken'] = 'Сканує HTML-описи товарів та категорій на наявність тегів з неіснуючими зображеннями.';
+$_['help_tool_small_photos'] = 'Знаходить зображення з роздільною здатністю нижче заданої. Додано посилання на пошук якісних аналогів у Google Images та Яндекс Зображеннях.';
+$_['help_tool_duplicates'] = 'Шукає повні дублікати файлів за MD5-хешем (різні назви, але однакова картинка). Дозволяє склеїти їх в один файл та оновити БД.';
+$_['help_tool_watermark'] = 'Накладення водяних знаків у SMART-режимі (тільки на кеш вітрини, оригінали не змінюються).';
+$_['help_tool_placeholders'] = 'Масово встановлює вибране зображення-заглушку для товарів та категорій, у яких взагалі немає фото.';
+$_['help_tool_png_jpg'] = 'Інструмент сканує папку зображень і знаходить важкі PNG-файли, які не мають прозорого фону (альфа-каналу). Оскільки прозорість їм не потрібна, їх можна безпечно конвертувати у формат JPG. Це значно полегшує файли (в середньому на 70-80%) та прискорює завантаження сайту, а всі посилання в базі даних автоматично оновлюються.';
+$_['help_tool_exif'] = 'Сканує EXIF-дані та видаляє з них приховані теги, зменшуючи вагу на 10-15%.';
+$_['help_tool_smart_cache'] = 'Шукає в папці image/cache/ кешовані файли, оригінали яких уже видалені, та очищає неактуальний кеш.';
+$_['help_tool_translit'] = 'Знаходить файли з кирилицею або пробілами, перейменовує їх транслітом та оновлює шляхи в БД.';
+$_['help_tool_cache_restore'] = 'Якщо ви випадково видалили оригінал з catalog, інструмент спробує знайти його копію в cache та відновити назад.';
+$_['entry_quarantine'] = 'Перемістити оригінали в Карантин (замість видалення)';
+$_['entry_backup_db'] = 'Створити бекап змінюваних таблиць у SQL перед запуском';
+$_['button_fix_selected'] = 'ВИПРАВИТИ ВИБРАНІ';
+$_['button_export_log'] = 'Експортувати в CSV';
+$_['text_th_before'] = 'Стан ДО';
+$_['text_th_after'] = 'Очікуваний результат ПІСЛЯ';
+$_['text_th_select'] = 'Дія';
+$_['text_th_preview'] = 'Передперегляд';
+$_['text_th_name'] = 'Об`єкт / Файл';
+$_['text_global_action'] = 'Групова дія для вибраних:';
+$_['text_action_clear'] = 'Очистити посилання в БД';
+$_['text_action_disable'] = 'Вимкнути (Статус = 0)';
+$_['text_action_stock'] = 'Установити "Немає в наявності"';
+$_['text_action_placeholder'] = 'Встановити заглушку';
+$_['text_action_ignore'] = 'Нічого не робити';
+$_['text_action_merge'] = 'Склеїти (видалити дублікат, залишити оригінал)';
+$_['text_action_convert'] = 'Конвертувати в JPG';
+$_['text_action_rename'] = 'Перейменувати транслітом';
+$_['text_action_delete'] = 'Видалити';
+$_['text_action_restore'] = 'Відновити';
+$_['text_db_backup_created'] = 'Створено бекап БД: %s';
+$_['text_quarantine_created'] = 'Перенесено в карантин: %s';
+$_['text_fix_status_success'] = 'Успішно виконано';
+$_['text_fix_status_error'] = 'Помилка: %s';
+$_['text_log_exported'] = 'Лог успішно експортовано!';
+$_['text_select_action'] = '-- Виберіть дію --';
+$_['error_empty_selection'] = 'Помилка: Не вибрано жодного рядка для виправлення!';
+$_['text_link_admin'] = 'Адмінка';
+$_['text_link_catalog'] = 'Вітрина';
+$_['entry_threads'] = 'Потоків обробки';
+$_['text_threads_optimal'] = 'оптимально';
+$_['button_download_zip'] = 'Завантажити ZIP-архів та видалити папку';
+$_['confirm_download_and_delete'] = 'Архів успішно завантажився? Можна видалити тимчасову папку копій на сервері?';
+$_['button_clear_backups'] = 'Видалити бекапи БД';
+$_['button_clear_backups_short'] = 'Очистити бекапи';
+$_['confirm_clear_backups'] = 'Ви впевнені, що хочете повністю видалити всі SQL-бекапи таблиць?';
+$_['text_compare_title'] = 'Порівняння зображень ДО / ПІСЛЯ';
+$_['text_compare_before'] = 'ДО';
+$_['text_compare_after'] = 'ПІСЛЯ';
+$_['text_backups_cleared'] = 'Усі SQL-бекапи таблиць успішно видалені!';
+$_['error_no_backups'] = 'Бекапи не знайдені або папка порожня.';
+$_['button_restore'] = 'Повернути';
+$_['button_empty'] = 'Очистити';
+$_['entry_wm_targets'] = 'Застосовувати водяний знак до:';
+$_['text_wm_target_product'] = 'Фото товарів';
+$_['text_wm_target_category'] = 'Фото категорій';
+$_['text_wm_target_brand'] = 'Логотипи брендів';
+$_['text_wm_target_banner'] = 'Банери та слайди';
+$_['text_wm_target_blog'] = 'Статті / Блог';
+$_['error_no_scan_data'] = 'Помилка: Дані сканування не знайдені. Спочатку запустіть сканування!';
+$_['error_row_not_found'] = 'Помилка: Об`єкт не знайдено в результатах сканування!';
+$_['error_file_not_found'] = 'Помилка: Файл не знайдено на сервері!';
+$_['text_folder_rules_title'] = 'Індивідуальні правила стиснення для папок';
+$_['text_folder_rules_desc'] = 'Ви можете перевизначити якість та максимальні розміри для конкретних папок. Якщо залишити поле порожнім, використовуватиметься глобальне налаштування. Шлях вказується відносно папки image (наприклад, catalog/banners/).';
+$_['entry_folder_path'] = 'Шлях до папки';
+$_['entry_jpg_quality'] = 'Якість JPG';
+$_['entry_webp_quality'] = 'Якість WebP';
+$_['button_add_rule'] = 'Додати правило';
+$_['button_remove'] = 'Вилучити';
+$_['entry_wm_angle'] = 'Кут нахилу водяного знака (градуси)';
+$_['entry_wm_corner_radius'] = 'Закруглення кутів водяного знака (px)';
+$_['entry_wm_size_type'] = 'Масштабування водяного знака';
+$_['entry_wm_size_percent'] = 'Розмір водяного знака (% від ширини зображення)';
+$_['entry_wm_text_color'] = 'Колір тексту водяного знака';
+$_['entry_wm_text_font'] = 'Шрифт водяного знака (TTF)';
+$_['entry_wm_filter_mode'] = 'Режим фільтрації Категорій/Брендів';
+$_['text_wm_filter_include'] = 'Тільки для вибраних';
+$_['text_wm_filter_exclude'] = 'Для всіх, крім вибраних';
+$_['text_wm_size_original'] = 'Оригінальний розмір';
+$_['text_wm_size_percent'] = 'Пропорційно ширині зображення (%)';
+$_['button_preview_watermark'] = 'Попередній перегляд';
+$_['text_wm_preview_title'] = 'Попередній перегляд водяного знака';
+$_['text_cron_exclude_folders'] = 'Виключити папки зі сканування';
+$_['text_cron_entities'] = 'Які типи фото обробляти?';
+$_['text_cron_quality_override'] = 'Перевизначити параметри стиснення для Крона';
+$_['text_cron_recommendations'] = 'Рекомендації щодо розкладу Cron';
+$_['text_cron_recommendations_desc'] = '
0 * * * *).0 */6 * * *).0 2 * * *).';
+$_['text_tool_broken_files'] = 'Сканер битих/порожніх файлів на сервері';
+$_['help_tool_broken_files'] = 'Сканує фізичні файли в папці image/catalog/ на наявність пошкоджених зображень або порожніх файлів розміром 0 байт.';
+$_['text_grid_broken_file'] = 'Битий або порожній файл на сервері';
+$_['text_grid_broken_file_after'] = 'Вилучення або заміна заглушкою';
+$_['text_description_broken_files'] = 'Результати сканування фізичних файлів у папці image/catalog/. Тут відображаються биті (пошкоджені) зображення або файли розміром 0 байт.';
+$_['text_action_strip'] = 'Очистити EXIF';
+$_['text_action_set_placeholder'] = 'Встановити заглушку';
+$_['text_th_entity'] = 'Об`єкт / Пов`язані посилання';
+$_['text_cron_entity_product'] = 'Товари';
+$_['text_cron_entity_category'] = 'Категорії';
+$_['text_cron_entity_manufacturer'] = 'Виробники / Бренди';
+$_['text_cron_entity_banner'] = 'Банери';
+$_['text_cron_entity_blog'] = 'Блог / Статті';
+$_['entry_cron_jpg_quality'] = 'Якість JPG (Крон)';
+$_['entry_cron_webp_quality'] = 'Якість WebP (Крон)';
+$_['entry_cron_max_width'] = 'Макс. ширина px (Крон)';
+$_['entry_cron_max_height'] = 'Макс. висота px (Крон)';
+$_['text_cron_folder_scan'] = 'Папки для сканування';
+$_['text_no_fonts'] = 'TTF-шрифти не знайдено в system/library/font/';
+$_['button_apply'] = 'Застосувати';
+$_['text_backup_will_be_created'] = 'Резервна копія бази даних буде автоматично створена перед виконанням цієї дії.';
+$_['text_bulk_action'] = 'Групова дія для вибраних';
+$_['text_grid_results'] = 'Результати та деталі сканування';
+$_['text_quarantine_will_be_created'] = 'Оригінальні файли будуть переміщені в карантин (image/catalog_trash/) перед зміною.';
+$_['text_success_apply'] = 'Зміни успішно застосовані!';
+$_['text_description_broken'] = 'Результати сканування бази даних на наявність битих посилань (записи в таблицях з неіснуючими файлами).';
+$_['text_description_cache_restore'] = 'Результати пошуку файлів у кеші, оригінали яких відсутні в каталозі.';
+$_['text_description_duplicates'] = 'Результати пошуку повних дублікатів зображень за MD5-хешем.';
+$_['text_description_empty_folders'] = 'Список порожніх папок в директорії image/catalog/ для видалення.';
+$_['text_description_exif'] = 'Результати пошуку зображень з метаданими EXIF, які можна очистити.';
+$_['text_description_folder_tree'] = 'Статистика розміру папок у каталозі image/catalog/.';
+$_['text_description_html_broken'] = 'Результати пошуку неіснуючих файлів картинок, на які посилаються описи в HTML.';
+$_['text_description_placeholders'] = 'Результати пошуку сутностей без зображень для заповнення заглушками.';
+$_['text_description_png_jpg'] = 'Результати пошуку важких зображень PNG без прозорості, які можна перетиснути в JPG.';
+$_['text_description_small_photos'] = 'Список зображень, розміри яких менше мінімально заданих (%sx%s px).';
+$_['text_description_smart_cache'] = 'Результати пошуку файлів кешу, оригінали яких уже було видалено.';
+$_['text_description_translit'] = 'Результати пошуку файлів з кирилицею або пробілами в назвах.';
+$_['text_grid_broken_db_after'] = 'Очистити посилання або встановити заглушку';
+$_['text_grid_dupe_after'] = 'Оригінал (залишається)';
+$_['text_grid_dupe_before'] = 'Дублікат (буде видалено)';
+$_['text_grid_empty_folder'] = 'Порожня папка';
+$_['text_grid_empty_folder_after'] = 'Вилучити порожню папку';
+$_['text_grid_exif_after'] = 'Очистити метадані';
+$_['text_grid_exif_before'] = 'Містить EXIF';
+$_['text_grid_html_after'] = 'Виправити посилання вручну';
+$_['text_grid_missing_file'] = 'Файл відсутній';
+$_['text_grid_missing_html'] = 'Картинка не знайдена в описі';
+$_['text_grid_missing_orig'] = 'Оригінал відсутній';
+$_['text_grid_missing_orig_after'] = 'Відновити оригінал з кешу';
+$_['text_grid_no_image'] = 'Немає зображення (порожньо)';
+$_['text_grid_orph_cache'] = 'Осиротілий кеш';
+$_['text_grid_orph_cache_after'] = 'Видалити непотрібний кеш';
+$_['text_grid_ph_brand_after'] = 'Встановити заглушку бренду';
+$_['text_grid_ph_category_after'] = 'Встановити заглушку категорії';
+$_['text_grid_ph_product_after'] = 'Встановити заглушку товару';
+$_['text_grid_small_photo_after'] = 'Рекомендується замінити на більше';
+$_['text_grid_too_small'] = 'Занадто мале';
+$_['text_no_relations'] = 'Немає зв’язків у БД';
+$_['text_action_archive'] = 'Архівувати (в ZIP)';
+$_['text_action_archive_delete'] = 'Архівувати + Видалити';
+$_['text_tool_sanitizer'] = 'Санитайзер імен файлів';
+$_['help_tool_sanitizer'] = 'Масове очищення імен файлів і папок від кирилиці, пробілів та спецсимволів, приведення розширень до єдиного вигляду.';
+$_['text_description_sanitizer'] = 'Результати аналізу імен файлів для санітаризації за вибраними правилами.';
+$_['entry_sz_translit'] = 'Транслітерація (кирилиця в латиницю)';
+$_['entry_sz_spaces'] = 'Замінювати пробіли на символ "_"';
+$_['entry_sz_special'] = 'Видаляти спеціальні символи';
+$_['entry_sz_lowercase_name'] = 'Ім`я файлу в нижній регістр';
+$_['entry_sz_lowercase_ext'] = 'Розширення в нижній регістр';
+$_['entry_sz_normalize_ext'] = 'Нормалізувати розширення (jpeg -> jpg)';
+$_['entry_cron_folder_mode'] = 'Режим фільтрації папок';
+$_['text_cron_folder_mode_exclude'] = 'Сканувати все, крім вибраних (Винятки)';
+$_['text_cron_folder_mode_include'] = 'Сканирувати тільки вибрані папки';
+$_['button_select_all'] = 'Вибрати всі';
+$_['button_deselect_all'] = 'Зняти всі';
+$_['entry_wm_upload_font'] = 'Завантажити свій шрифт TTF';
+$_['button_upload_font'] = 'Завантажити шрифт';
+$_['text_success_font_upload'] = 'Шрифт успішно завантажено!';
+$_['error_font_upload'] = 'Помилка завантаження шрифту! Дозволені тільки файли .ttf вагою до 5 МБ.';
+$_['entry_lazy_load'] = 'Прогресивне лениве завантаження';
+$_['help_lazy_load'] = 'Вмикає преміальне прогресивне відкладене завантаження на вітрині. Зображення замінюються на мікро-прев\'ю та плавно розмиваються (blur-up), підвантажуючись у міру прокручування.';
+$_['confirm_replace_mode'] = 'УВАГА! Ви обрали режим «Замінювати оригінали». Усі зображення будуть перезаписані безпосередньо на сервері. Наполегливо рекомендуємо зробити резервну копію. Ви впевнені, що хочете продовжити?';
+$_['text_compare'] = 'Порівняти';
+$_['text_home'] = 'Головна';
+$_['text_tech_gd'] = 'Бібліотека GD';
+$_['text_tech_imagick'] = 'Imagick';
+$_['text_tech_webp'] = 'Підтримка WebP';
+$_['text_position_1'] = '1 - Зліва вгорі';
+$_['text_position_2'] = '2 - По центру вгорі';
+$_['text_position_3'] = '3 - Справа вгорі';
+$_['text_position_4'] = '4 - Зліва посередині';
+$_['text_position_5'] = '5 - По центру';
+$_['text_position_6'] = '6 - Справа посередині';
+$_['text_position_7'] = '7 - Зліва внизу';
+$_['text_position_8'] = '8 - По центру внизу';
+$_['text_position_9'] = '9 - Справа внизу';
+
+$_['entry_log_level'] = 'Рівень логу';
+$_['text_log_changed_only'] = 'Тільки змінені';
+$_['text_log_all'] = 'Всі файли (детально)';
+$_['text_skipped_no_gain'] = 'Пропущено (без покращення)';
+$_['text_reset_cache_hint'] = 'Скинути кеш оптимізації (повне пересканування)';
+$_['confirm_reset_cache'] = 'Скинути кеш оптимізації? Наступне сканування перевірить ВСІ файли знову.';
+$_['text_cache_cleared'] = 'Кеш оптимізації очищено. Наступне сканування перевірить всі файли.';
+$_['error_wm_preview'] = 'Помилка генерації прев\'ю водяного знаку.';
+$_['error_wm_too_large'] = 'Зображення водяного знаку занадто велике (%dx%d px)! Максимально допустимий розмір: %dx%d px.';
+$_['text_apply_errors_warning'] = 'УВАГА: Деякі файли не вдалося замінити (заблоковані або немає прав):';
+$_['text_action_compress'] = 'Стиснути зображення';
+$_['text_estimated'] = 'Прогноз';
+$_['entry_min_savings_bytes'] = 'Мін. стиснення (байт)';
+$_['entry_min_savings_percent'] = 'Мін. стиснення (%)';
+$_['text_optimized'] = 'Оптимізовано';
+$_['entry_broken_fallback'] = 'Заглушка для битих фото';
+$_['help_broken_fallback'] = 'Автоматично замінює відсутні або пошкоджені зображення каталогу на вітрині на зображення-заглушку.';
+$_['entry_fallback_image'] = 'Зображення-заглушка';
+$_['help_fallback_image'] = 'Виберіть зображення, яке буде показано, якщо оригінальний файл картинки товару чи категорії відсутній. За замовчуванням використовується no_image.png.';
+$_['text_tool_cmyk'] = 'Конвертер CMYK в RGB';
+$_['help_tool_cmyk'] = 'Конвертація JPEG зображень з колірного профілю CMYK в sRGB для коректного відображення в браузерах Safari та на пристроях iOS.';
+$_['text_tool_heavy_files'] = 'Пошук важких файлів';
+$_['help_tool_heavy_files'] = 'Пошук найважчих оригінальних зображень у каталозі з можливістю їх швидкого стиснення безпосередньо тут.';
+$_['entry_heavy_files_limit'] = 'Макс. кількість знайдених файлів (всього)';
+$_['text_action_convert_rgb'] = 'Конвертувати в RGB';
+$_['text_description_cmyk'] = 'Знайдено файли в колірному просторі CMYK. Конвертація в sRGB гарантує колірне відображення на всіх платформах, включаючи iOS/macOS.';
+$_['text_description_heavy'] = 'Список із %d найважчих файлів у каталозі зображень. Ви можете оптимізувати їх безпосередньо тут.';
+$_['text_cron_folder_hint_exclude_all'] = 'Статус: Скануватимуться всі папки (винятки не обрані).';
+$_['text_cron_folder_hint_exclude_some'] = 'Статус: Скануватимуться всі папки, ОКРІМ %d позначених.';
+$_['text_cron_folder_hint_include_some'] = 'Статус: Скануватимуться тільки %d позначених папок.';
+$_['text_cron_folder_hint_include_none'] = 'УВАГА: Жодної папки не обрано! Крон нічого не буде сканувати.';
+$_['text_action_apply_changes'] = 'Застосувати вказані зміни';
+$_['entry_wm_status'] = 'Статус водяного знака';
+$_['text_disabled'] = 'Відключено';
+$_['entry_rule_name'] = 'Назва правила';
+$_['entry_rule_status'] = 'Статус правила';
+$_['entry_action'] = 'Дія';
+$_['entry_ph_status'] = 'Статус заглушок';
+$_['entry_sanitizer_limit'] = 'Макс. файлів для пошуку (ліміт)';
+$_['entry_heavy_files_min_size'] = 'Мінимальний розмір';
+$_['entry_png_jpg_limit'] = 'Макс. файлів для пошуку (ліміт)';
+$_['entry_png_jpg_min_size'] = 'Мінімальний розмір';
+$_['text_wm_rule_title'] = 'Правило водяного знака';
+$_['button_close'] = 'Закрити';
+$_['text_ph_rule_title'] = 'Правило заглушки';
+$_['entry_ph_image'] = 'Зображення заглушки';
+$_['button_clear_cache_wm'] = 'Очистити кеш (застосувати водяний знак)';
+$_['text_active'] = 'Активна';
+$_['text_error'] = 'Не активна';
+$_['text_th_dupe_delete'] = 'Файл для видалення (Дублікат)';
+$_['text_th_dupe_keep'] = 'Файл для збереження (Оригінал)';
+$_['text_dupe_original_label'] = 'Оригінал';
+$_['text_dupe_duplicate_label'] = 'Дублікат';
+
+$_['text_disk_usage'] = 'Використання диска';
+$_['text_cumulative_stats'] = 'Статистика оптимізації';
+$_['text_disk_catalog'] = 'Каталог зображень';
+$_['text_disk_cache'] = 'Кеш OC';
+$_['text_disk_trash'] = 'Кошик модуля';
+$_['text_disk_other'] = 'Інше';
+$_['text_disk_free'] = 'Вільно';
+$_['text_total_files_opt'] = 'Файлів оброблено';
+$_['text_total_saved'] = 'Всього зекономлено';
+$_['text_avg_saving_pct'] = 'Середня економія';
+$_['text_stats_hint'] = 'Статистика накопичується по всіх сесіях (зберігається локально в браузері)';
+$_['text_reset_stats'] = 'Скинути статистику';
+$_['text_reset_stats_confirm']= 'Скинути накопичену статистику оптимізації?';
+$_['text_export_import'] = 'Експорт / Імпорт налаштувань';
+$_['button_export_settings'] = 'Експорт налаштувань (JSON)';
+$_['button_import_settings'] = 'Імпортувати налаштування';
+$_['help_export_settings'] = 'Завантажити всі налаштування модуля в JSON-файл';
+$_['help_import_settings'] = 'Завантажити раніше експортований JSON для відновлення налаштувань';
+$_['text_import_success'] = 'Налаштування успішно імпортовані. Перезавантажте сторінку для застосування.';
+$_['error_import_file'] = 'Файл не завантажено або помилка завантаження';
+$_['error_import_invalid'] = 'Некоректний файл налаштувань (невірний модуль або формат)';
+$_['text_click_to_load'] = 'Натисніть ↑ для завантаження даних диска';
+
+
+$_['tab_formats'] = 'Формати зображень';
+
+$_['text_disk_quota_legend'] = 'Ліміти диска хостингу (вручну)';
+$_['text_disk_quota_desc'] = 'Якщо графік дискового простору показує некоректні дані (фізичний диск сервера замість ліміту вашого хостингу), ви можете вказати ліміти вашого тарифу вручну. Вкажіть 0, щоб використовувати автовизначення.';
+$_['entry_disk_quota_total'] = 'Виділено диска на хостингу (ГБ)';
+$_['entry_disk_quota_used'] = 'Зайнято диска на хостингу всього (ГБ)';
+$_['entry_auto_disk'] = 'Автооновлення при вході';
+
+// Cron Tasks
+$_['entry_cron_task_name'] = 'Назва задачі';
+$_['entry_cron_task_summary'] = 'Параметри стиснення';
+$_['button_add_cron_task'] = 'Додати задачу';
+$_['button_copy_cron_url'] = 'Копіювати URL';
+$_['text_cron_recommended_url'] = 'URL для запуску задачі крону:';
+
+// Licensing
+$_['text_license_required'] = 'Необхідний ліцензійний ключ';
+$_['text_license_required_desc'] = 'Будь ласка, введіть ліцензійний ключ для активації модуля. Усі функції налаштувань та оптимізації заблоковані до введення валідного ключа.';
+$_['entry_token_desc'] = 'Введіть токен активації, виданий для вашого основного домену. Ви можете отримати його в особистому кабінеті на форумі або звернувшись у підтримку.';
+$_['button_activate'] = 'Активувати модуль';
+$_['text_support'] = 'Технічна підтримка';
+$_['text_support_desc'] = 'Якщо у вас ще немає ключа або виникли проблеми, будь ласка, повідомте номер вашого замовлення та домен.';
+
+// New settings
+$_['entry_wm_max_width'] = 'Макс. ширина водяного знака (px)';
+$_['entry_wm_max_height'] = 'Макс. висота водяного знака (px)';
+$_['help_wm_max_width'] = 'Якщо вихідне зображення водяного знака перевищує цю ширину, воно буде автоматично масштабоване. Вкажіть 0 або залиште порожнім для значення за замовчуванням (800px).';
+$_['help_wm_max_height'] = 'Якщо вихідне зображення водяного знака перевищує цю висоту, воно буде автоматично масштабоване. Вкажіть 0 або залиште порожнім для значення за замовчуванням (800px).';
+$_['entry_disk_status'] = 'Увімкнути сканування диска та графік';
+$_['help_disk_status'] = 'Якщо увімкнено, модуль періодично скануватиме обсяг папки картинок та відображатиме круговую діаграму. Рекомендується вимкнути на великих сайтах для прискорення завантаження панелі.';
+$_['text_disk_settings_title'] = 'Налаштування лімітів та сканування диска';
+
diff --git a/upload/admin/language/ukrainian/module/img_opti.php b/upload/admin/language/ukrainian/module/img_opti.php
new file mode 100644
index 0000000..646d271
--- /dev/null
+++ b/upload/admin/language/ukrainian/module/img_opti.php
@@ -0,0 +1,564 @@
+Image Optimizer [NAT]: Стиснення оригіналів та видалення сміття';
+$_['text_extension'] = 'Розширення';
+$_['text_edit'] = 'Налаштування модуля';
+$_['text_success'] = 'Налаштування успішно збережено!';
+$_['entry_warning'] = 'УВАГА: Обов`язково зробіть бекап файлів сайту і особливо папки /image/catalog перед початком роботи!';
+$_['text_tech'] = 'Перевірка технологій сервера:';
+$_['text_author'] = 'Технічна підтримка та побажання: info@nat.od.ua';
+$_['tab_optimize'] = 'Оптимізація та Ресайз';
+$_['tab_cleaner'] = 'Очищення невикористовуваних зображень';
+$_['tab_info'] = 'Інфо / Екосистема [NAT]';
+$_['tab_settings'] = 'Налаштування';
+$_['tab_tools'] = 'Інструменти (pro)';
+$_['tab_cron'] = 'Крон-завдання';
+$_['entry_mode'] = 'Режим роботи';
+$_['text_copy'] = 'Створити копію (безпечно)';
+$_['text_replace'] = 'Замінювати оригінали (потребує бекап!)';
+$_['entry_folder'] = 'Ім`я папки для копії';
+$_['entry_batch'] = 'Файлів за прохід';
+$_['entry_log_limit'] = 'Рядків логу на екрані';
+$_['entry_max_width'] = 'Макс. ширина (px) [0 - без ліміту]';
+$_['entry_max_height'] = 'Макс. висота (px) [0 - без ліміту]';
+$_['entry_jpg'] = 'Якість JPG (0-100)';
+$_['entry_png'] = 'Стиснення PNG (0-9)';
+$_['entry_webp'] = 'Якість WebP (0-100)';
+$_['entry_targets'] = 'Що оптимізувати?';
+$_['text_all_catalog'] = '[ Весь каталог image/catalog ]';
+$_['text_root_files'] = 'Файли тільки в корені image/catalog';
+$_['entry_threshold'] = 'Поріг заміни (%)';
+$_['help_threshold'] = '0: замінювати тільки якщо вага стала меншою. 10: дозволити збільшення ваги до 10%.';
+$_['entry_token'] = 'Токен активації (на корінь домену)';
+$_['entry_status'] = 'Статус модуля';
+$_['entry_menu_position'] = 'Позиція меню [NAT]';
+$_['text_menu_module_only'] = 'Тільки в модулях';
+$_['text_menu_header'] = 'У шапці (Header)';
+$_['text_menu_sidebar'] = 'У бічному меню (Sidebar)';
+$_['text_menu_both'] = 'Скрізь (Шапка + Сайдбар)';
+$_['text_status_on'] = 'Увімкнено';
+$_['text_status_off'] = 'Вимкнено';
+$_['entry_engine'] = 'Рушій обробки зображень';
+$_['text_engine_gd'] = 'GD (Стандарт)';
+$_['text_engine_imagick'] = 'Imagick (Висока якість, збереження профілів sRGB)';
+$_['entry_license'] = 'Ліцензія';
+$_['text_cleaner_info'] = 'Цей інструмент шукає файли, які фізично існують на диску, але ніде не використовуються в базі даних або файлах шаблону.';
+$_['entry_clean_targets'] = 'В яких папках шукати сміття?';
+$_['entry_extended_log'] = 'Розширений лог (показати використовувані photo і де вони знайдені)';
+$_['entry_process_broken'] = 'Враховувати файли з битим кодуванням (імена з символом "?")';
+$_['button_scan_trash'] = 'ЗНАЙТИ СМІТТЯ';
+$_['button_quarantine'] = 'В Карантин (Безпечно)';
+$_['button_delete_trash'] = 'Видалити назавжди';
+$_['button_empty_quarantine'] = 'Очистити папку Карантину';
+$_['button_restore_quarantine'] = 'Повернути з Карантину';
+$_['text_scan_result'] = 'Результати сканування';
+$_['text_trash_files'] = 'Сміттєвих файлів: ';
+$_['text_trash_size'] = 'Потенційно звільниться: ';
+$_['button_start'] = 'СТАРТ ОПТИМІЗАЦІЇ';
+$_['button_pause'] = 'ПАУЗА';
+$_['button_stop'] = 'ЗУПИНИТИ';
+$_['button_continue'] = 'ПРОДОВЖИТИ';
+$_['button_start_new'] = 'СТАРТ (Новий запуск)';
+$_['button_download_log'] = 'Завантажити лог в TXT';
+$_['button_download'] = 'Завантажити';
+$_['button_save'] = 'Зберегти';
+$_['button_cancel'] = 'Скасувати';
+$_['text_progress'] = 'Прогрес:';
+$_['text_old_size'] = 'Було:';
+$_['text_new_size'] = 'Стало:';
+$_['text_savings'] = 'Економія:';
+$_['text_detailed_log'] = 'Детальний лог';
+$_['text_page'] = 'Стор.';
+$_['text_waiting'] = 'Очікування запуску...';
+$_['text_enabled'] = 'Увімкнено';
+$_['text_disabled_error'] = 'Вимкнено (ПОМИЛКА)';
+$_['text_supported'] = 'Підтримується';
+$_['text_no'] = 'Ні';
+$_['error_permission'] = 'У вас немає прав для керування модулем!';
+$_['error_license'] = 'Помилка доступу: Невірний токен активації для цього домену!';
+$_['error_domain'] = ' (Домен: ';
+$_['text_found'] = 'Знайдено файлів: ';
+$_['text_compressed'] = 'Стиснуто';
+$_['text_original'] = 'Оригінал';
+$_['text_error_process'] = 'Помилка обробки: ';
+$_['error_log_empty'] = 'Лог порожній! Спочатку запустіть процес.';
+$_['text_report_header'] = '=== ЗВІТ ===';
+$_['text_total_files'] = 'Всього файлів: ';
+$_['text_searching'] = 'Пошук файлів...';
+$_['error_ajax'] = 'Помилка: ';
+$_['text_resumed'] = '▶ ПРОЦЕС ВІДНОВЛЕНО...';
+$_['text_paused'] = '⏸ ПАУЗА. Процес призупинено.';
+$_['confirm_stop'] = 'Ви впевнені, що хочете повністю перервати процес?';
+$_['confirm_delete'] = 'УВАГА! Файли будуть видалені з сервера назавжди без можливості відновлення! Продовжити?';
+$_['confirm_empty_q'] = 'Ви впевнені, що хочете повністю очистити папку карантину?';
+$_['confirm_restore'] = 'Всі файли з папки карантину будуть повернуті на свої місця. Продовжити?';
+$_['button_stopping'] = 'Зупинка...';
+$_['text_stopped_user'] = '🛑 ПРОЦЕС ПЕРЕРВАНО КОРИСТУВАЧЕМ!';
+$_['text_increase'] = 'Збільшення';
+$_['text_done'] = '✅ ГОТОВО! Процес завершено.';
+$_['error_timeout'] = 'Таймаут сервера. Автоповтор через 3 секунди...';
+$_['text_building_index'] = 'Крок 1/2: Індексація БД та шаблонів (пошук фото, що використовуються)...';
+$_['text_comparing'] = 'Крок 2/2: Порівняння фізичних файлів з індексом...';
+$_['text_quarantine_done'] = '✅ Процес перенесення в image/catalog_trash/ завершено!';
+$_['text_deleted_done'] = '✅ Процес видалення сміття завершено!';
+$_['text_quarantine_empty'] = '✅ Папку карантину успішно видалено з сервера!';
+$_['text_restore_done'] = '✅ Файли успішно повернуто з карантину в робочу папку!';
+$_['text_quarantine_move'] = 'В карантині: ';
+$_['text_deleted_file'] = 'Видалено: ';
+$_['text_no_trash'] = 'У вибраних папках сміття не знайдено! Всі файли використовуються.';
+$_['text_scanning_dirs'] = 'Скануємо директорії: ';
+$_['text_log_used'] = '[ВИКОРИСТОВУЄТЬСЯ]';
+$_['text_log_trash'] = '[СМІТТЯ]';
+$_['text_log_deleted'] = '[ВИДАЛЕНО]';
+$_['text_log_del_err'] = '[ПОМИЛКА ВИДАЛЕННЯ]';
+$_['text_log_quarantine'] = '[В КАРАНТИН]';
+$_['text_log_q_err'] = '[ПОМИЛКА ПЕРЕНЕСЕННЯ]';
+$_['text_log_restored'] = '[ВІДНОВЛЕНО]';
+$_['text_log_rest_err'] = '[ПОМИЛКА ВІДНОВЛЕННЯ]';
+$_['text_used_in'] = 'у: ';
+$_['text_analyzing'] = 'Аналіз бази даних...';
+$_['text_clean_empty'] = 'Чисто! Сміття немає.';
+$_['text_processing'] = 'Обробка...';
+$_['text_restoring'] = 'Відновлення файлів...';
+$_['text_all_folders'] = 'image/catalog/ (Та всі вкладені)';
+$_['text_root_folder'] = 'image/catalog/ (Тільки корінь)';
+$_['text_q_empty_err'] = 'Папка карантину порожня або не існує.';
+$_['error_token'] = 'Невірний токен. Перезавантажте сторінку.';
+$_['text_apply_title'] = 'Оптимізацію завершено в папку: ';
+$_['text_apply_info'] = 'Перевірте результат. Якщо все влаштовує — застосуйте зміни до основного каталогу.';
+$_['entry_apply_backup'] = 'Перенести зі створенням бекапу (в Карантин)';
+$_['entry_apply_replace'] = 'Замінити оригінали безповоротно';
+$_['button_apply_main'] = 'ЗАСТОСУВАТИ ДО ОСНОВНОГО КАТАЛОГУ';
+$_['button_delete_copies'] = 'Видалити тимчасові копії (скасування)';
+$_['button_full_cleanup'] = 'Повне очищення всіх тимчасових папок та бекапів';
+$_['text_apply_success'] = '✅ Файли успішно перенесено. ';
+$_['text_apply_backup_ok'] = 'Оригінали збережено в папку: ';
+$_['text_apply_no_backup'] = 'Оригінали видалено.';
+$_['text_error_source'] = 'Помилка: Папку з оптимізованими файлами не знайдено.';
+$_['confirm_full_cleanup'] = 'УВАГА! Ця дія безповоротно видалить УСІ папки бекапів (backup_*) та тимчасові папки з копіями. Продовжити?';
+$_['text_tools_desc'] = 'Глобальні сканери та інструменти для роботи з файловою системою і БД зображень.';
+$_['text_tool_select'] = 'Виберіть інструмент:';
+$_['text_group_scanners'] = 'Сканери та Аналіз';
+$_['text_group_generators'] = 'Генератори та Обробка';
+$_['text_group_experimental'] = 'Експериментальні функції (BETA)';
+$_['text_experimental_warn'] = 'Увага: Експериментальні функції зачіпають системні файли або базу даних. Настійно рекомендується зробити бекап!';
+$_['text_tool_broken_db'] = 'Сканер битих/порожніх фото БД';
+$_['text_tool_watermark'] = 'Динамічний Вотермарк (Тільки на кеш)';
+$_['text_tool_duplicates'] = 'Поиск дубликатов файлов (за MD5)';
+$_['text_tool_png_jpg'] = 'Конвертер важких PNG у JPG (сканер файлів без прозорості)';
+$_['text_tool_placeholders'] = 'Генератор заглушок (Замість No Image)';
+$_['text_tool_exif'] = 'Очищення EXIF-даних (Геотеги/Мета)';
+$_['text_tool_small_photos'] = 'Сканер занадто дрібних фото';
+$_['text_tool_html_broken'] = 'Пошук битих картинок в HTML (Описах)';
+$_['text_tool_empty_folders'] = 'Очищення порожніх папок (image/catalog)';
+$_['text_tool_smart_cache'] = 'Розумне очищення кешу зображень';
+$_['text_tool_folder_tree'] = 'Дерево папок (Статистика ваги)';
+$_['text_tool_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load (Без БД)';
+$_['text_tool_translit'] = 'Транслітерація назв файлів';
+$_['text_tool_cache_restore'] = 'Відновлення оригіналів із Кешу';
+$_['entry_wm_type'] = 'Тип водяного знака';
+$_['text_wm_image'] = 'Зображення (PNG)';
+$_['text_wm_text'] = 'Текст';
+$_['entry_wm_image_path'] = 'Шлях до зображення';
+$_['entry_wm_text_val'] = 'Текст водяного знака';
+$_['entry_wm_position'] = 'Позиція (1-9)';
+$_['entry_wm_opacity'] = 'Прозорість (0-100)';
+$_['entry_wm_category'] = 'Тільки для Категорій';
+$_['entry_wm_brand'] = 'Тільки для Брендів';
+$_['entry_min_width'] = 'Мінімальна ширина (px)';
+$_['entry_min_height'] = 'Мінімальна висота (px)';
+$_['entry_ph_product'] = 'Заглушка для Товарів';
+$_['entry_ph_category'] = 'Заглушка для Категорій';
+$_['entry_ph_brand'] = 'Заглушка для Брендів';
+$_['button_run_tool'] = 'Запустити інструмент';
+$_['button_scan_tool'] = 'Сканувати';
+$_['button_fix_tool'] = 'Виправити знайдене';
+$_['text_th_path'] = 'Файл / Шлях';
+$_['text_th_size'] = 'Розмір';
+$_['text_th_action'] = 'Дія';
+$_['text_th_status'] = 'Статус';
+$_['text_th_problem'] = 'Проблема / Значення';
+$_['text_cron_title'] = 'Налаштування Cron';
+$_['text_cron_desc'] = 'Додайте команду в Cron хостингу для автоматичного фонового стиснення.';
+$_['entry_cron_new_only'] = 'Обробляти тільки нові (необроблені) файли';
+$_['entry_cron_folders'] = 'Папки для сканування (залиште порожнім для всього каталогу)';
+$_['text_promo_title'] = 'Інші модулі серії [NAT]';
+$_['text_promo_desc'] = 'Модулі серії [NAT] – це інструменти для глибокої оптимізації OpenCart. Ми фокусуємося на автоматизації рутини, прискоренні бази даних та очищенні серверів від сміття. Усі модулі мають відкритий вихідний код та єдину логіку управління.';
+$_['text_support'] = 'Технічна підтримка та побажання:';
+$_['button_more'] = 'Дивитися всі модулі на OpenCart.com';
+$_['button_more_forum'] = 'Opencartforum';
+$_['text_mod_cat_opt'] = 'Catalog Optimizer [NAT]';
+$_['text_mod_cat_opt_desc'] = 'Приховування порожніх категорій і брендів + SEO сортування товарів (у наявності зверху) + Очищення сміття БД.';
+$_['text_mod_new_arrivals'] = 'New Arrivals [NAT]: Новинки за датами';
+$_['text_mod_new_arrivals_desc'] = 'Автоматична SEO-сторінка новинок. Відображає новинки суворо за реальною датою додавання. Слайдери, сітка та розумний сайдбар архіву дат.';
+$_['text_mod_img_opt'] = 'Image Optimizer [NAT]';
+$_['text_mod_img_opt_desc'] = 'Поточний модуль. Стиснення та ресайз ОРИГІНАЛІВ фото (image/catalog) у WebP + Очищення хостингу від невикористаних зображень.';
+$_['text_mod_dom_scan'] = 'Domain Scanner [NAT]';
+$_['text_mod_dom_scan_desc'] = 'Пошук і чищення зовнішніх посилань і HTTP-картинок у БД та файлах теми. Ідеально для HTTPS або очищення після парсингу.';
+$_['text_mod_poll'] = 'Poll Widget [NAT]';
+$_['text_mod_poll_desc'] = 'Створення опитувань та голосувань із візуальним конструктором дизайну, глибокою статистикою та захистом від накруток.';
+$_['text_mod_db_opt'] = 'Database Optimizer [NAT]';
+$_['text_mod_db_opt_desc'] = 'Інтелектуальне очищення сміття в базі даних, управління системними логами та додавання критично важливих індексів для прискорення.';
+$_['text_mod_im_ex'] = 'Import/Export [NAT]';
+$_['text_mod_im_ex_desc'] = 'Швидкий імпорт/експорт цін і залишків з файлів xls/xlsx/csv/xml/json або за посиланням. Маппінг полів, робота по Cron, текстові функції.';
+$_['text_mod_admin_tools'] = 'Admin Tools [NAT]';
+$_['text_mod_admin_tools_desc'] = 'Зручні інструменти адміністратора: вхід під клієнтом, кастомізація адмінки, управління правами користувачів та приховування непотрібних пунктів меню.';
+$_['text_mod_utm_tracker'] = 'Order UTM Tracker [NAT]';
+$_['text_mod_utm_tracker_desc'] = 'Автоматичне відстеження UTM-міток при оформленні замовлення. Зберігає джерело переходу, кампанію, ключові слова та виводить ці дані в замовленні для аналітики реклами.';
+$_['text_info_title_promo_top_bar'] = 'Promo Top Bar [NAT]: Верхній рекламний інфо-баннер, інформаційна смуга над шапкою, рухомий рядок та топ-баннер';
+$_['text_info_desc_promo_top_bar'] = 'Створення інтерактивних промо-панелей та слайд-шоу в шапці сайту з конструктором блоків, таймерами, формою підписки та гнучким націлюванням.';
+$_['text_tool_no_image'] = 'Товари без головного фото (порожньо в БД)';
+$_['text_problem_main_broken'] = 'Бите головне фото (файл видалено)';
+$_['text_problem_add_broken'] = 'Бите дод. фото (файл видалено)';
+$_['text_problem_no_image'] = 'Відсутнє головне фото';
+$_['text_err_dir'] = 'Помилка: Директорію не знайдено або невірний шлях.';
+$_['text_err_no_queue'] = 'Помилка: Чергу не знайдено. Спочатку запустіть сканування.';
+$_['text_log_del_empty_folder'] = 'Видалено порожню папку: ';
+$_['text_log_no_empty_folders'] = 'Порожніх папок в image/catalog/ не знайдено.';
+$_['text_log_total_empty_folders'] = 'Всього видалено порожніх папок: ';
+$_['text_log_cache_cleared'] = 'Кеш зображень успішно очищено.';
+$_['text_log_wm_smart_mode'] = 'Вотермарки працюють в SMART-режимі: оригінали не зачіпаються, логотип накладається лише при генерації кешу.';
+$_['text_log_scan_dupes'] = 'Сканування image/catalog/ на дублікати (за хешем MD5)... Це може зайняти час.';
+$_['text_log_found_dupe'] = 'Знайдено дублікат: ';
+$_['text_log_no_dupes'] = 'Дублікатів не знайдено.';
+$_['text_log_total_dupes'] = 'Знайдено дублікатів: %s. Потенційна економія: %s';
+$_['text_log_fix_dupes_prompt'] = 'Натисніть "Виправити знайдене" для перелінкування БД на оригинал та видалення дублів.';
+$_['text_log_fixed_dupe'] = 'Виправлено та видалено дублікат: ';
+$_['text_log_success_dupes'] = 'Успішно виправлено дублікатів: %s. Посилання в БД оновлено.';
+$_['text_log_scan_png'] = 'Пошук важких PNG файлів без прозорості...';
+$_['text_log_no_png'] = 'Відповідних PNG файлів не знайдено.';
+$_['text_log_total_png'] = 'Знайдено PNG без прозорості: %s. Натисніть "Виправити" для конвертації в JPG.';
+$_['text_log_converted_jpg'] = 'Сконвертовано в JPG: ';
+$_['text_log_success_png'] = 'Успішно сконвертовано %s файлів. Звільнено: %s';
+$_['text_log_ph_prod'] = 'Оновлено товарів заглушкою: ';
+$_['text_log_ph_cat'] = 'Оновлено категорій заглушкою: ';
+$_['text_log_ph_brand'] = 'Оновлено брендів заглушкою: ';
+$_['text_log_ph_empty'] = 'Заглушки не вибрані в налаштуваннях.';
+$_['text_log_scan_exif'] = 'Сканування EXIF-даних (мета-теги камер)...';
+$_['text_log_stripped_exif'] = 'Очищено EXIF: ';
+$_['text_log_no_exif'] = 'Файлів з EXIF для очищення не знайдено.';
+$_['text_log_success_exif'] = 'Очищено EXIF у %s файлів. Звільнено: %s';
+$_['text_log_scan_small'] = 'Пошук фото розміром менше %sx%s px...';
+$_['text_log_too_small'] = 'Занадто мале (%sx%s): ';
+$_['text_log_no_small'] = 'Дрібних фото не знайдено.';
+$_['text_log_total_small'] = 'Знайдено дрібних фото: %s. Будь ласка, замініть їх на якісні.';
+$_['text_log_html_broken'] = 'Битией IMG в HTML таблиці %s (ID: %s): ';
+$_['text_log_no_html_broken'] = 'Битих картинок в HTML-описах не знайдено.';
+$_['text_log_total_html_broken'] = 'Знайдено битих посилань в HTML: %s. Виправте їх вручну.';
+$_['text_log_scan_smart_cache'] = 'Пошук втрачених файлів у image/cache/...';
+$_['text_log_del_orph_cache'] = 'Видалено втрачений кеш: ';
+$_['text_log_no_orph_cache'] = 'Втраченого кешу не знайдено.';
+$_['text_log_success_smart_cache'] = 'Видалено файлів втраченого кешу: %s. Звільнено: %s';
+$_['text_log_scan_translit'] = 'Пошук файлів з кирилицею або пробілами...';
+$_['text_log_no_bad_names'] = 'Файлів з некоректними іменами не знайдено.';
+$_['text_log_total_bad_names'] = 'Знайдено файлів з кирилицею/пробілами: %s. Натисніть "Виправити" для транслітерації та оновлення БД.';
+$_['text_log_renamed'] = 'Перейменовано: ';
+$_['text_log_success_translit'] = 'Успішно транслітеровано файлів: %s. БД оновлено.';
+$_['text_log_scan_restore'] = 'Сканирование image/cache/ для відновлення втрачених оригіналів...';
+$_['text_log_restored_cache'] = 'Відновлено з кешу: ';
+$_['text_log_no_lost_orig'] = 'Відсутніх оригіналів у кеші не знайдено.';
+$_['text_log_success_restore'] = 'Успішно відновлено оригіналів з кешу: %s.';
+$_['error_invalid_folder'] = 'Помилка: Неприпустиме ім\'я папки!';
+$_['text_formats_title'] = 'Сучасні формати (WebP & SVG)';
+$_['text_formats_desc'] = 'Налаштування інтеграції сучасних форматів у файлову систему та шаблони OpenCart.';
+$_['entry_support_svg'] = 'Підтримка завантаження SVG';
+$_['help_support_svg'] = 'Дозволяє завантаження векторних SVG-зображень через стандартний Filemanager та їх безпечне виведення на вітрині без спотворення розмірів.';
+$_['entry_support_webp'] = 'Підтримка завантаження WebP';
+$_['help_support_webp'] = 'Дозволяє вручну завантажувати, підв\'язувати та виводити на сайті готові файли .webp через Filemanager.';
+$_['entry_truefilemanager_webp'] = 'WebP у TrueFileManager';
+$_['help_truefilemanager_webp'] = 'Додає підтримку завантаження, попереднього перегляду та ескізів WebP у TrueFileManager від sitecreator (якщо його встановлено).';
+$_['entry_webp_on_fly'] = 'On-the-Fly WebP + Lazy Load';
+$_['help_webp_on_fly'] = 'Надшвидка підміна: автоматично змінює розширення .jpg/.png на .webp прямо в HTML-виводі вітрини та додає атрибут loading="lazy". Не чіпає базу даних!';
+$_['help_tool_broken_db'] = 'Шукає в базі даних зображення, фізичні файли яких були видалені з сервера. Дозволяє очистити биті посилання.';
+$_['help_tool_empty_folders'] = 'Рекурсивно сканує директорію image/catalog/ та безпечно видаляє всі папки, в яких немає жодного файлу.';
+$_['help_tool_folder_tree'] = 'Будує дерево каталогів та показує реальну вагу кожної папки для пошуку "пожирачів" місця.';
+$_['help_tool_html_broken'] = 'Сканує HTML-описи товарів та категорій на наявність тегів з неіснуючими зображеннями.';
+$_['help_tool_small_photos'] = 'Знаходить зображення з роздільною здатністю нижче заданої. Додано посилання на пошук якісних аналогів у Google Images та Яндекс Зображеннях.';
+$_['help_tool_duplicates'] = 'Шукає повні дублікати файлів за MD5-хешем (різні назви, але однакова картинка). Дозволяє склеїти їх в один файл та оновити БД.';
+$_['help_tool_watermark'] = 'Накладення водяних знаків у SMART-режимі (тільки на кеш вітрини, оригінали не змінюються).';
+$_['help_tool_placeholders'] = 'Масово встановлює вибране зображення-заглушку для товарів та категорій, у яких взагалі немає фото.';
+$_['help_tool_png_jpg'] = 'Інструмент сканує папку зображень і знаходить важкі PNG-файли, які не мають прозорого фону (альфа-каналу). Оскільки прозорість їм не потрібна, їх можна безпечно конвертувати у формат JPG. Це значно полегшує файли (в середньому на 70-80%) та прискорює завантаження сайту, а всі посилання в базі даних автоматично оновлюються.';
+$_['help_tool_exif'] = 'Сканує EXIF-дані та видаляє з них приховані теги, зменшуючи вагу на 10-15%.';
+$_['help_tool_smart_cache'] = 'Шукає в папці image/cache/ кешовані файли, оригінали яких уже видалені, та очищає неактуальний кеш.';
+$_['help_tool_translit'] = 'Знаходить файли з кирилицею або пробілами, перейменовує їх транслітом та оновлює шляхи в БД.';
+$_['help_tool_cache_restore'] = 'Якщо ви випадково видалили оригінал з catalog, інструмент спробує знайти його копію в cache та відновити назад.';
+$_['entry_quarantine'] = 'Перемістити оригінали в Карантин (замість видалення)';
+$_['entry_backup_db'] = 'Створити бекап змінюваних таблиць у SQL перед запуском';
+$_['button_fix_selected'] = 'ВИПРАВИТИ ВИБРАНІ';
+$_['button_export_log'] = 'Експортувати в CSV';
+$_['text_th_before'] = 'Стан ДО';
+$_['text_th_after'] = 'Очікуваний результат ПІСЛЯ';
+$_['text_th_select'] = 'Дія';
+$_['text_th_preview'] = 'Передперегляд';
+$_['text_th_name'] = 'Об`єкт / Файл';
+$_['text_global_action'] = 'Групова дія для вибраних:';
+$_['text_action_clear'] = 'Очистити посилання в БД';
+$_['text_action_disable'] = 'Вимкнути (Статус = 0)';
+$_['text_action_stock'] = 'Установити "Немає в наявності"';
+$_['text_action_placeholder'] = 'Встановити заглушку';
+$_['text_action_ignore'] = 'Нічого не робити';
+$_['text_action_merge'] = 'Склеїти (видалити дублікат, залишити оригінал)';
+$_['text_action_convert'] = 'Конвертувати в JPG';
+$_['text_action_rename'] = 'Перейменувати транслітом';
+$_['text_action_delete'] = 'Видалити';
+$_['text_action_restore'] = 'Відновити';
+$_['text_db_backup_created'] = 'Створено бекап БД: %s';
+$_['text_quarantine_created'] = 'Перенесено в карантин: %s';
+$_['text_fix_status_success'] = 'Успішно виконано';
+$_['text_fix_status_error'] = 'Помилка: %s';
+$_['text_log_exported'] = 'Лог успішно експортовано!';
+$_['text_select_action'] = '-- Виберіть дію --';
+$_['error_empty_selection'] = 'Помилка: Не вибрано жодного рядка для виправлення!';
+$_['text_link_admin'] = 'Адмінка';
+$_['text_link_catalog'] = 'Вітрина';
+$_['entry_threads'] = 'Потоків обробки';
+$_['text_threads_optimal'] = 'оптимально';
+$_['button_download_zip'] = 'Завантажити ZIP-архів та видалити папку';
+$_['confirm_download_and_delete'] = 'Архів успішно завантажився? Можна видалити тимчасову папку копій на сервері?';
+$_['button_clear_backups'] = 'Видалити бекапи БД';
+$_['button_clear_backups_short'] = 'Очистити бекапи';
+$_['confirm_clear_backups'] = 'Ви впевнені, що хочете повністю видалити всі SQL-бекапи таблиць?';
+$_['text_compare_title'] = 'Порівняння зображень ДО / ПІСЛЯ';
+$_['text_compare_before'] = 'ДО';
+$_['text_compare_after'] = 'ПІСЛЯ';
+$_['text_backups_cleared'] = 'Усі SQL-бекапи таблиць успішно видалені!';
+$_['error_no_backups'] = 'Бекапи не знайдені або папка порожня.';
+$_['button_restore'] = 'Повернути';
+$_['button_empty'] = 'Очистити';
+$_['entry_wm_targets'] = 'Застосовувати водяний знак до:';
+$_['text_wm_target_product'] = 'Фото товарів';
+$_['text_wm_target_category'] = 'Фото категорій';
+$_['text_wm_target_brand'] = 'Логотипи брендів';
+$_['text_wm_target_banner'] = 'Банери та слайди';
+$_['text_wm_target_blog'] = 'Статті / Блог';
+$_['error_no_scan_data'] = 'Помилка: Дані сканування не знайдені. Спочатку запустіть сканування!';
+$_['error_row_not_found'] = 'Помилка: Об`єкт не знайдено в результатах сканування!';
+$_['error_file_not_found'] = 'Помилка: Файл не знайдено на сервері!';
+$_['text_folder_rules_title'] = 'Індивідуальні правила стиснення для папок';
+$_['text_folder_rules_desc'] = 'Ви можете перевизначити якість та максимальні розміри для конкретних папок. Якщо залишити поле порожнім, використовуватиметься глобальне налаштування. Шлях вказується відносно папки image (наприклад, catalog/banners/).';
+$_['entry_folder_path'] = 'Шлях до папки';
+$_['entry_jpg_quality'] = 'Якість JPG';
+$_['entry_webp_quality'] = 'Якість WebP';
+$_['button_add_rule'] = 'Додати правило';
+$_['button_remove'] = 'Вилучити';
+$_['entry_wm_angle'] = 'Кут нахилу водяного знака (градуси)';
+$_['entry_wm_corner_radius'] = 'Закруглення кутів водяного знака (px)';
+$_['entry_wm_size_type'] = 'Масштабування водяного знака';
+$_['entry_wm_size_percent'] = 'Розмір водяного знака (% від ширини зображення)';
+$_['entry_wm_text_color'] = 'Колір тексту водяного знака';
+$_['entry_wm_text_font'] = 'Шрифт водяного знака (TTF)';
+$_['entry_wm_filter_mode'] = 'Режим фільтрації Категорій/Брендів';
+$_['text_wm_filter_include'] = 'Тільки для вибраних';
+$_['text_wm_filter_exclude'] = 'Для всіх, крім вибраних';
+$_['text_wm_size_original'] = 'Оригінальний розмір';
+$_['text_wm_size_percent'] = 'Пропорційно ширині зображення (%)';
+$_['button_preview_watermark'] = 'Попередній перегляд';
+$_['text_wm_preview_title'] = 'Попередній перегляд водяного знака';
+$_['text_cron_exclude_folders'] = 'Виключити папки зі сканування';
+$_['text_cron_entities'] = 'Які типи фото обробляти?';
+$_['text_cron_quality_override'] = 'Перевизначити параметри стиснення для Крона';
+$_['text_cron_recommendations'] = 'Рекомендації щодо розкладу Cron';
+$_['text_cron_recommendations_desc'] = '
0 * * * *).0 */6 * * *).0 2 * * *).';
+$_['text_tool_broken_files'] = 'Сканер битих/порожніх файлів на сервері';
+$_['help_tool_broken_files'] = 'Сканує фізичні файли в папці image/catalog/ на наявність пошкоджених зображень або порожніх файлів розміром 0 байт.';
+$_['text_grid_broken_file'] = 'Битий або порожній файл на сервері';
+$_['text_grid_broken_file_after'] = 'Вилучення або заміна заглушкою';
+$_['text_description_broken_files'] = 'Результати сканування фізичних файлів у папці image/catalog/. Тут відображаються биті (пошкоджені) зображення або файли розміром 0 байт.';
+$_['text_action_strip'] = 'Очистити EXIF';
+$_['text_action_set_placeholder'] = 'Встановити заглушку';
+$_['text_th_entity'] = 'Об`єкт / Пов`язані посилання';
+$_['text_cron_entity_product'] = 'Товари';
+$_['text_cron_entity_category'] = 'Категорії';
+$_['text_cron_entity_manufacturer'] = 'Виробники / Бренди';
+$_['text_cron_entity_banner'] = 'Банери';
+$_['text_cron_entity_blog'] = 'Блог / Статті';
+$_['entry_cron_jpg_quality'] = 'Якість JPG (Крон)';
+$_['entry_cron_webp_quality'] = 'Якість WebP (Крон)';
+$_['entry_cron_max_width'] = 'Макс. ширина px (Крон)';
+$_['entry_cron_max_height'] = 'Макс. висота px (Крон)';
+$_['text_cron_folder_scan'] = 'Папки для сканування';
+$_['text_no_fonts'] = 'TTF-шрифти не знайдено в system/library/font/';
+$_['button_apply'] = 'Застосувати';
+$_['text_backup_will_be_created'] = 'Резервна копія бази даних буде автоматично створена перед виконанням цієї дії.';
+$_['text_bulk_action'] = 'Групова дія для вибраних';
+$_['text_grid_results'] = 'Результати та деталі сканування';
+$_['text_quarantine_will_be_created'] = 'Оригінальні файли будуть переміщені в карантин (image/catalog_trash/) перед зміною.';
+$_['text_success_apply'] = 'Зміни успішно застосовані!';
+$_['text_description_broken'] = 'Результати сканування бази даних на наявність битих посилань (записи в таблицях з неіснуючими файлами).';
+$_['text_description_cache_restore'] = 'Результати пошуку файлів у кеші, оригінали яких відсутні в каталозі.';
+$_['text_description_duplicates'] = 'Результати пошуку повних дублікатів зображень за MD5-хешем.';
+$_['text_description_empty_folders'] = 'Список порожніх папок в директорії image/catalog/ для видалення.';
+$_['text_description_exif'] = 'Результати пошуку зображень з метаданими EXIF, які можна очистити.';
+$_['text_description_folder_tree'] = 'Статистика розміру папок у каталозі image/catalog/.';
+$_['text_description_html_broken'] = 'Результати пошуку неіснуючих файлів картинок, на які посилаються описи в HTML.';
+$_['text_description_placeholders'] = 'Результати пошуку сутностей без зображень для заповнення заглушками.';
+$_['text_description_png_jpg'] = 'Результати пошуку важких зображень PNG без прозорості, які можна перетиснути в JPG.';
+$_['text_description_small_photos'] = 'Список зображень, розміри яких менше мінімально заданих (%sx%s px).';
+$_['text_description_smart_cache'] = 'Результати пошуку файлів кешу, оригінали яких уже було видалено.';
+$_['text_description_translit'] = 'Результати пошуку файлів з кирилицею або пробілами в назвах.';
+$_['text_grid_broken_db_after'] = 'Очистити посилання або встановити заглушку';
+$_['text_grid_dupe_after'] = 'Оригінал (залишається)';
+$_['text_grid_dupe_before'] = 'Дублікат (буде видалено)';
+$_['text_grid_empty_folder'] = 'Порожня папка';
+$_['text_grid_empty_folder_after'] = 'Вилучити порожню папку';
+$_['text_grid_exif_after'] = 'Очистити метадані';
+$_['text_grid_exif_before'] = 'Містить EXIF';
+$_['text_grid_html_after'] = 'Виправити посилання вручну';
+$_['text_grid_missing_file'] = 'Файл відсутній';
+$_['text_grid_missing_html'] = 'Картинка не знайдена в описі';
+$_['text_grid_missing_orig'] = 'Оригінал відсутній';
+$_['text_grid_missing_orig_after'] = 'Відновити оригінал з кешу';
+$_['text_grid_no_image'] = 'Немає зображення (порожньо)';
+$_['text_grid_orph_cache'] = 'Осиротілий кеш';
+$_['text_grid_orph_cache_after'] = 'Видалити непотрібний кеш';
+$_['text_grid_ph_brand_after'] = 'Встановити заглушку бренду';
+$_['text_grid_ph_category_after'] = 'Встановити заглушку категорії';
+$_['text_grid_ph_product_after'] = 'Встановити заглушку товару';
+$_['text_grid_small_photo_after'] = 'Рекомендується замінити на більше';
+$_['text_grid_too_small'] = 'Занадто мале';
+$_['text_no_relations'] = 'Немає зв’язків у БД';
+$_['text_action_archive'] = 'Архівувати (в ZIP)';
+$_['text_action_archive_delete'] = 'Архівувати + Видалити';
+$_['text_tool_sanitizer'] = 'Санитайзер імен файлів';
+$_['help_tool_sanitizer'] = 'Масове очищення імен файлів і папок від кирилиці, пробілів та спецсимволів, приведення розширень до єдиного вигляду.';
+$_['text_description_sanitizer'] = 'Результати аналізу імен файлів для санітаризації за вибраними правилами.';
+$_['entry_sz_translit'] = 'Транслітерація (кирилиця в латиницю)';
+$_['entry_sz_spaces'] = 'Замінювати пробіли на символ "_"';
+$_['entry_sz_special'] = 'Видаляти спеціальні символи';
+$_['entry_sz_lowercase_name'] = 'Ім`я файлу в нижній регістр';
+$_['entry_sz_lowercase_ext'] = 'Розширення в нижній регістр';
+$_['entry_sz_normalize_ext'] = 'Нормалізувати розширення (jpeg -> jpg)';
+$_['entry_cron_folder_mode'] = 'Режим фільтрації папок';
+$_['text_cron_folder_mode_exclude'] = 'Сканувати все, крім вибраних (Винятки)';
+$_['text_cron_folder_mode_include'] = 'Сканирувати тільки вибрані папки';
+$_['button_select_all'] = 'Вибрати всі';
+$_['button_deselect_all'] = 'Зняти всі';
+$_['entry_wm_upload_font'] = 'Завантажити свій шрифт TTF';
+$_['button_upload_font'] = 'Завантажити шрифт';
+$_['text_success_font_upload'] = 'Шрифт успішно завантажено!';
+$_['error_font_upload'] = 'Помилка завантаження шрифту! Дозволені тільки файли .ttf вагою до 5 МБ.';
+$_['entry_lazy_load'] = 'Прогресивне лениве завантаження';
+$_['help_lazy_load'] = 'Вмикає преміальне прогресивне відкладене завантаження на вітрині. Зображення замінюються на мікро-прев\'ю та плавно розмиваються (blur-up), підвантажуючись у міру прокручування.';
+$_['confirm_replace_mode'] = 'УВАГА! Ви обрали режим «Замінювати оригінали». Усі зображення будуть перезаписані безпосередньо на сервері. Наполегливо рекомендуємо зробити резервну копію. Ви впевнені, що хочете продовжити?';
+$_['text_compare'] = 'Порівняти';
+$_['text_home'] = 'Головна';
+$_['text_tech_gd'] = 'Бібліотека GD';
+$_['text_tech_imagick'] = 'Imagick';
+$_['text_tech_webp'] = 'Підтримка WebP';
+$_['text_position_1'] = '1 - Зліва вгорі';
+$_['text_position_2'] = '2 - По центру вгорі';
+$_['text_position_3'] = '3 - Справа вгорі';
+$_['text_position_4'] = '4 - Зліва посередині';
+$_['text_position_5'] = '5 - По центру';
+$_['text_position_6'] = '6 - Справа посередині';
+$_['text_position_7'] = '7 - Зліва внизу';
+$_['text_position_8'] = '8 - По центру внизу';
+$_['text_position_9'] = '9 - Справа внизу';
+
+$_['entry_log_level'] = 'Рівень логу';
+$_['text_log_changed_only'] = 'Тільки змінені';
+$_['text_log_all'] = 'Всі файли (детально)';
+$_['text_skipped_no_gain'] = 'Пропущено (без покращення)';
+$_['text_reset_cache_hint'] = 'Скинути кеш оптимізації (повне пересканування)';
+$_['confirm_reset_cache'] = 'Скинути кеш оптимізації? Наступне сканування перевірить ВСІ файли знову.';
+$_['text_cache_cleared'] = 'Кеш оптимізації очищено. Наступне сканування перевірить всі файли.';
+$_['error_wm_preview'] = 'Помилка генерації прев\'ю водяного знаку.';
+$_['error_wm_too_large'] = 'Зображення водяного знаку занадто велике (%dx%d px)! Максимально допустимий розмір: %dx%d px.';
+$_['text_apply_errors_warning'] = 'УВАГА: Деякі файли не вдалося замінити (заблоковані або немає прав):';
+$_['text_action_compress'] = 'Стиснути зображення';
+$_['text_estimated'] = 'Прогноз';
+$_['entry_min_savings_bytes'] = 'Мін. стиснення (байт)';
+$_['entry_min_savings_percent'] = 'Мін. стиснення (%)';
+$_['text_optimized'] = 'Оптимізовано';
+$_['entry_broken_fallback'] = 'Заглушка для битих фото';
+$_['help_broken_fallback'] = 'Автоматично замінює відсутні або пошкоджені зображення каталогу на вітрині на зображення-заглушку.';
+$_['entry_fallback_image'] = 'Зображення-заглушка';
+$_['help_fallback_image'] = 'Виберіть зображення, яке буде показано, якщо оригінальний файл картинки товару чи категорії відсутній. За замовчуванням використовується no_image.png.';
+$_['text_tool_cmyk'] = 'Конвертер CMYK в RGB';
+$_['help_tool_cmyk'] = 'Конвертація JPEG зображень з колірного профілю CMYK в sRGB для коректного відображення в браузерах Safari та на пристроях iOS.';
+$_['text_tool_heavy_files'] = 'Пошук важких файлів';
+$_['help_tool_heavy_files'] = 'Пошук найважчих оригінальних зображень у каталозі з можливістю їх швидкого стиснення безпосередньо тут.';
+$_['entry_heavy_files_limit'] = 'Макс. кількість знайдених файлів (всього)';
+$_['text_action_convert_rgb'] = 'Конвертувати в RGB';
+$_['text_description_cmyk'] = 'Знайдено файли в колірному просторі CMYK. Конвертація в sRGB гарантує колірне відображення на всіх платформах, включаючи iOS/macOS.';
+$_['text_description_heavy'] = 'Список із %d найважчих файлів у каталозі зображень. Ви можете оптимізувати їх безпосередньо тут.';
+$_['text_cron_folder_hint_exclude_all'] = 'Статус: Скануватимуться всі папки (винятки не обрані).';
+$_['text_cron_folder_hint_exclude_some'] = 'Статус: Скануватимуться всі папки, ОКРІМ %d позначених.';
+$_['text_cron_folder_hint_include_some'] = 'Статус: Скануватимуться тільки %d позначених папок.';
+$_['text_cron_folder_hint_include_none'] = 'УВАГА: Жодної папки не обрано! Крон нічого не буде сканувати.';
+$_['text_action_apply_changes'] = 'Застосувати вказані зміни';
+$_['entry_wm_status'] = 'Статус водяного знака';
+$_['text_disabled'] = 'Відключено';
+$_['entry_rule_name'] = 'Назва правила';
+$_['entry_rule_status'] = 'Статус правила';
+$_['entry_action'] = 'Дія';
+$_['entry_ph_status'] = 'Статус заглушок';
+$_['entry_sanitizer_limit'] = 'Макс. файлів для пошуку (ліміт)';
+$_['entry_heavy_files_min_size'] = 'Мінімальний розмір';
+$_['entry_png_jpg_limit'] = 'Макс. файлів для пошуку (ліміт)';
+$_['entry_png_jpg_min_size'] = 'Мінімальний розмір';
+$_['text_wm_rule_title'] = 'Правило водяного знака';
+$_['button_close'] = 'Закрити';
+$_['text_ph_rule_title'] = 'Правило заглушки';
+$_['entry_ph_image'] = 'Зображення заглушки';
+$_['button_clear_cache_wm'] = 'Очистити кеш (застосувати водяний знак)';
+$_['text_active'] = 'Активна';
+$_['text_error'] = 'Не активна';
+$_['text_th_dupe_delete'] = 'Файл для видалення (Дублікат)';
+$_['text_th_dupe_keep'] = 'Файл для збереження (Оригінал)';
+$_['text_dupe_original_label'] = 'Оригінал';
+$_['text_dupe_duplicate_label'] = 'Дублікат';
+
+$_['text_disk_usage'] = 'Використання диска';
+$_['text_cumulative_stats'] = 'Статистика оптимізації';
+$_['text_disk_catalog'] = 'Каталог зображень';
+$_['text_disk_cache'] = 'Кеш OC';
+$_['text_disk_trash'] = 'Кошик модуля';
+$_['text_disk_other'] = 'Інше';
+$_['text_disk_free'] = 'Вільно';
+$_['text_total_files_opt'] = 'Файлів оброблено';
+$_['text_total_saved'] = 'Всього зекономлено';
+$_['text_avg_saving_pct'] = 'Середня економія';
+$_['text_stats_hint'] = 'Статистика накопичується по всіх сесіях (зберігається локально в браузері)';
+$_['text_reset_stats'] = 'Скинути статистику';
+$_['text_reset_stats_confirm']= 'Скинути накопичену статистику оптимізації?';
+$_['text_export_import'] = 'Експорт / Імпорт налаштувань';
+$_['button_export_settings'] = 'Експорт налаштувань (JSON)';
+$_['button_import_settings'] = 'Імпортувати налаштування';
+$_['help_export_settings'] = 'Завантажити всі налаштування модуля в JSON-файл';
+$_['help_import_settings'] = 'Завантажити раніше експортований JSON для відновлення налаштувань';
+$_['text_import_success'] = 'Налаштування успішно імпортовані. Перезавантажте сторінку для застосування.';
+$_['error_import_file'] = 'Файл не завантажено або помилка завантаження';
+$_['error_import_invalid'] = 'Некоректний файл налаштувань (невірний модуль або формат)';
+$_['text_click_to_load'] = 'Натисніть ↑ для завантаження даних диска';
+$_['tab_formats'] = 'Формати зображень';
+
+$_['text_disk_quota_legend'] = 'Ліміти диска хостингу (вручну)';
+$_['text_disk_quota_desc'] = 'Якщо графік дискового простору показує некоректні дані (фізичний диск сервера замість ліміту вашого хостингу), ви можете вказати ліміти вашого тарифу вручну. Вкажіть 0, щоб використовувати автовизначення.';
+$_['entry_disk_quota_total'] = 'Виділено диска на хостингу (ГБ)';
+$_['entry_disk_quota_used'] = 'Зайнято диска на хостингу всього (ГБ)';
+$_['entry_auto_disk'] = 'Автооновлення при вході';
+
+// Cron Tasks
+$_['entry_cron_task_name'] = 'Назва задачі';
+$_['entry_cron_task_summary'] = 'Параметри стиснення';
+$_['button_add_cron_task'] = 'Додати задачу';
+$_['button_copy_cron_url'] = 'Копіювати URL';
+$_['text_cron_recommended_url'] = 'URL для запуску задачі крону:';
+
+// Licensing
+$_['text_license_required'] = 'Необхідний ліцензійний ключ';
+$_['text_license_required_desc'] = 'Будь ласка, введіть ліцензійний ключ для активації модуля. Усі функції налаштувань та оптимізації заблоковані до введення валідного ключа.';
+$_['entry_token_desc'] = 'Введіть токен активації, виданий для вашого основного домену. Ви можете отримати його в особистому кабінеті на форуме або звернувшись у підтримку.';
+$_['button_activate'] = 'Активувати модуль';
+$_['text_support'] = 'Технічна підтримка';
+$_['text_support_desc'] = 'Якщо у вас ще немає ключа або виникли проблеми, будь ласка, повідомте номер вашого замовлення та домен.';
+
+// New settings
+$_['entry_wm_max_width'] = 'Макс. ширина водяного знака (px)';
+$_['entry_wm_max_height'] = 'Макс. висота водяного знака (px)';
+$_['help_wm_max_width'] = 'Якщо вихідне зображення водяного знака перевищує цю ширину, воно буде автоматично масштабоване. Вкажіть 0 або залиште порожнім для значення за замовчуванням (800px).';
+$_['help_wm_max_height'] = 'Якщо вихідне зображення водяного знака перевищує цю висоту, воно буде автоматично масштабоване. Вкажіть 0 або залиште порожнім для значення за замовчуванням (800px).';
+$_['entry_disk_status'] = 'Увімкнути сканування диска та графік';
+$_['help_disk_status'] = 'Якщо увімкнено, модуль періодично скануватиме обсяг папки картинок та відображатиме круговую діаграму. Рекомендується вимкнути на великих сайтах для прискорення завантаження панелі.';
+$_['text_disk_settings_title'] = 'Налаштування лімітів та сканування диска';
diff --git a/upload/admin/model/module/img_opti.php b/upload/admin/model/module/img_opti.php
new file mode 100644
index 0000000..8f64f7f
--- /dev/null
+++ b/upload/admin/model/module/img_opti.php
@@ -0,0 +1,3644 @@
+registerModuleFolder('catalog_trash/backup');
+ return $file_name;
+ }
+ fwrite($fp, "-- Database Backup for Tool: " . $tool_name . "\n");
+ fwrite($fp, "-- Date: " . date('Y-m-d H:i:s') . "\n\n");
+ foreach ($tables as $table) {
+ fwrite($fp, "DROP TABLE IF EXISTS `" . $table . "`;\n");
+ $query = $this->db->query("SHOW CREATE TABLE `" . $table . "`");
+ if ($query->num_rows) {
+ fwrite($fp, $query->row['Create Table'] . ";\n\n");
+ }
+ $chunk_size = 200;
+ $offset = 0;
+ do {
+ $query_rows = $this->db->query("SELECT * FROM `" . $table . "` LIMIT " . (int)$chunk_size . " OFFSET " . (int)$offset);
+ foreach ($query_rows->rows as $row) {
+ $fields = array();
+ $values = array();
+ foreach ($row as $key => $value) {
+ $fields[] = "`" . $key . "`";
+ if ($value === null) {
+ $values[] = "NULL";
+ } else {
+ $values[] = "'" . $this->db->escape($value) . "'";
+ }
+ }
+ fwrite($fp, "INSERT INTO `" . $table . "` (" . implode(', ', $fields) . ") VALUES (" . implode(', ', $values) . ");\n");
+ }
+ $offset += $chunk_size;
+ } while ($query_rows->num_rows === $chunk_size);
+ fwrite($fp, "\n");
+ }
+ fclose($fp);
+ $this->registerModuleFolder('catalog_trash/backup');
+ return $file_name;
+ }
+
+ public function quarantineFile($relative_path, $tool_name) {
+ $clean_rel = str_replace(array('../', '..\\', "\0"), '', (string)$relative_path);
+ $src = DIR_IMAGE . $clean_rel;
+ if (!is_file($src)) return false;
+ $dest = DIR_IMAGE . 'catalog_trash/' . $tool_name . '/' . ltrim($clean_rel, '/');
+ $dest_dir = $this->mb_dirname($dest);
+ if (!is_dir($dest_dir)) {
+ @mkdir($dest_dir, 0755, true);
+ }
+ $this->registerModuleFolder('catalog_trash/' . $tool_name);
+ if (file_exists($dest)) {
+ @unlink($dest);
+ }
+ if (!@rename($src, $dest)) {
+ if (@copy($src, $dest)) {
+ @unlink($src);
+ return true;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ public function registerModuleFolder($folder_name) {
+ $folder_name = preg_replace('/[^a-zA-Z0-9_\-\/]/', '', (string)$folder_name);
+ if (empty($folder_name)) return;
+ $query = $this->db->query("SELECT `value` FROM `" . DB_PREFIX . "setting` WHERE `code` = 'module_img_opti' AND `key` = 'module_img_opti_folders'");
+ $folders = ($query->num_rows && $query->row['value']) ? json_decode($query->row['value'], true) : array();
+ if (!in_array($folder_name, $folders)) {
+ $folders[] = $folder_name;
+ $this->db->query("DELETE FROM `" . DB_PREFIX . "setting` WHERE `code` = 'module_img_opti' AND `key` = 'module_img_opti_folders'");
+ $this->db->query("INSERT INTO `" . DB_PREFIX . "setting` SET `store_id` = '0', `code` = 'module_img_opti', `key` = 'module_img_opti_folders', `value` = '" . $this->db->escape(json_encode($folders)) . "'");
+ }
+ }
+
+ private function getSearchLinks($name, $model = '') {
+ $query_parts = array();
+ if ($name) $query_parts[] = trim($name);
+ if ($model) $query_parts[] = trim($model);
+ $search_query = implode(' ', $query_parts);
+ if (!$search_query) return '';
+
+ $google_url = 'https://www.google.com/search?tbm=isch&q=' . urlencode($search_query);
+ $yandex_url = 'https://yandex.ru/images/search?text=' . urlencode($search_query);
+
+ $html = ' ';
+ $html .= ' ';
+
+ return $html;
+ }
+
+ private function getEntitiesByImagePath($image_path) {
+ $entities = array();
+ $escaped = $this->db->escape((string)$image_path);
+ $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '');
+ $query = $this->db->query("SELECT p.product_id, p.model, p.status, p.date_available, (SELECT p2s.store_id FROM `" . DB_PREFIX . "product_to_store` p2s WHERE p2s.product_id = p.product_id AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "product_description` pd WHERE pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "product` p WHERE p.image = '" . $escaped . "'");
+ foreach ($query->rows as $row) {
+ $admin_link = $this->url->link('catalog/product/edit', 'user_token=' . $this->session->data['user_token'] . '&product_id=' . (int)$row['product_id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (empty($row['date_available']) || strtotime($row['date_available']) <= time()) && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/product&product_id=' . (int)$row['product_id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name'], $row['model']);
+ $entities[] = 'Product ' . htmlspecialchars($row['name'] ? $row['name'] : 'Product ID ' . $row['product_id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links;
+ }
+ $query = $this->db->query("SELECT pi.product_id, p.model, p.status, p.date_available, (SELECT p2s.store_id FROM `" . DB_PREFIX . "product_to_store` p2s WHERE p2s.product_id = pi.product_id AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "product_description` pd WHERE pd.product_id = pi.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "product_image` pi LEFT JOIN `" . DB_PREFIX . "product` p ON (pi.product_id = p.product_id) WHERE pi.image = '" . $escaped . "'");
+ foreach ($query->rows as $row) {
+ $admin_link = $this->url->link('catalog/product/edit', 'user_token=' . $this->session->data['user_token'] . '&product_id=' . (int)$row['product_id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (empty($row['date_available']) || strtotime($row['date_available']) <= time()) && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/product&product_id=' . (int)$row['product_id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name'], $row['model']);
+ $entities[] = 'Product (Add.) ' . htmlspecialchars($row['name'] ? $row['name'] : 'Product ID ' . $row['product_id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links;
+ }
+ $query = $this->db->query("SELECT c.category_id, c.status, (SELECT c2s.store_id FROM `" . DB_PREFIX . "category_to_store` c2s WHERE c2s.category_id = c.category_id AND c2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "category_description` cd WHERE cd.category_id = c.category_id AND cd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "category` c WHERE image = '" . $escaped . "'");
+ foreach ($query->rows as $row) {
+ $admin_link = $this->url->link('catalog/category/edit', 'user_token=' . $this->session->data['user_token'] . '&category_id=' . (int)$row['category_id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/category&path=' . (int)$row['category_id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name']);
+ $entities[] = 'Category ' . htmlspecialchars($row['name'] ? $row['name'] : 'Category ID ' . $row['category_id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links;
+ }
+ $query = $this->db->query("SELECT m.manufacturer_id, m.name, (SELECT m2s.store_id FROM `" . DB_PREFIX . "manufacturer_to_store` m2s WHERE m2s.manufacturer_id = m.manufacturer_id AND m2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id FROM `" . DB_PREFIX . "manufacturer` m WHERE image = '" . $escaped . "'");
+ foreach ($query->rows as $row) {
+ $admin_link = $this->url->link('catalog/manufacturer/edit', 'user_token=' . $this->session->data['user_token'] . '&manufacturer_id=' . (int)$row['manufacturer_id'], true);
+ $catalog_link_html = '';
+ if ((isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/manufacturer/info&manufacturer_id=' . (int)$row['manufacturer_id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name']);
+ $entities[] = 'Manufacturer ' . htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links;
+ }
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'");
+ if ($table_query->num_rows) {
+ $query_banner = $this->db->query("SELECT bi.banner_id, b.name FROM `" . DB_PREFIX . "banner_image` bi LEFT JOIN `" . DB_PREFIX . "banner` b ON (bi.banner_id = b.banner_id) WHERE bi.image = '" . $escaped . "'");
+ foreach ($query_banner->rows as $row) {
+ $admin_link = $this->url->link('design/banner/edit', 'user_token=' . $this->session->data['user_token'] . '&banner_id=' . (int)$row['banner_id'], true);
+ $search_links = $this->getSearchLinks($row['name']);
+ $entities[] = 'Banner ' . htmlspecialchars($row['name'] ? $row['name'] : 'Banner ID ' . $row['banner_id'], ENT_QUOTES, 'UTF-8') . '' . $search_links;
+ }
+ }
+ $blog_tables = array(
+ 'oct_blog_article' => array('id' => 'blog_article_id', 'route' => 'extension/news', 'name' => 'title', 'type' => 'OCT Blog'),
+ 'simple_blog_article' => array('id' => 'simple_blog_article_id', 'route' => 'simple_blog/article/edit', 'name' => 'article_title', 'type' => 'Simple Blog'),
+ 'newsblog_article' => array('id' => 'article_id', 'route' => 'extension/newsblog/article/edit', 'name' => 'name', 'type' => 'NewsBlog'),
+ 'information' => array('id' => 'information_id', 'route' => 'catalog/information/edit', 'name' => 'title', 'type' => 'Information')
+ );
+ foreach ($blog_tables as $table => $cfg) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $table . "'");
+ if ($table_query->num_rows) {
+ $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $table . "` LIKE 'image'");
+ if ($column_query->num_rows) {
+ $desc_table = $table . '_description';
+ $desc_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $desc_table . "'");
+ if ($desc_query->num_rows) {
+ $sql = "SELECT t.`" . $cfg['id'] . "`, td.`" . $cfg['name'] . "` as name FROM `" . DB_PREFIX . $table . "` t LEFT JOIN `" . DB_PREFIX . $desc_table . "` td ON (t.`" . $cfg['id'] . "` = td.`" . $cfg['id'] . "`) WHERE t.image = '" . $escaped . "' AND td.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 5";
+ } else {
+ $sql = "SELECT `" . $cfg['id'] . "`, `" . $cfg['name'] . "` as name FROM `" . DB_PREFIX . $table . "` WHERE image = '" . $escaped . "' LIMIT 5";
+ }
+ try {
+ $query_blog = $this->db->query($sql);
+ foreach ($query_blog->rows as $row) {
+ $admin_link = $this->url->link($cfg['route'], 'user_token=' . $this->session->data['user_token'] . '&' . $cfg['id'] . '=' . (int)$row[$cfg['id']], true);
+ $search_links = $this->getSearchLinks($row['name']);
+ $entities[] = '' . $cfg['type'] . ' ' . htmlspecialchars($row['name'] ? $row['name'] : $cfg['type'] . ' ID ' . $row[$cfg['id']], ENT_QUOTES, 'UTF-8') . '' . $search_links;
+ }
+ } catch (Exception $e) {}
+ }
+ }
+ }
+ return $entities;
+ }
+
+ public function scan_broken_db($data = array()) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $realImageDir = rtrim(str_replace('\\', '/', realpath(DIR_IMAGE)), '/');
+ $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '');
+
+ $query = $this->db->query("SELECT p.product_id as id, p.model, p.image, p.status, p.date_available, (SELECT p2s.store_id FROM `" . DB_PREFIX . "product_to_store` p2s WHERE p2s.product_id = p.product_id AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "product_description` pd WHERE pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "product` p WHERE image != '' AND image IS NOT NULL");
+ foreach ($query->rows as $row) {
+ $image_path = str_replace(array('../', '..\\', "\0"), '', (string)$row['image']);
+ $fullPath = DIR_IMAGE . $image_path;
+ $realPath = realpath($fullPath);
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), $realImageDir) !== 0 || !is_file($realPath)) {
+ $admin_link = $this->url->link('catalog/product/edit', 'user_token=' . $this->session->data['user_token'] . '&product_id=' . (int)$row['id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (empty($row['date_available']) || strtotime($row['date_available']) <= time()) && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/product&product_id=' . (int)$row['id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name'], $row['model']);
+ $rows[] = array(
+ 'id' => 'product_main-' . (int)$row['id'],
+ 'preview' => '',
+ 'name' => 'Product ' . htmlspecialchars($row['name'] ? $row['name'] : 'Product ID ' . (int)$row['id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => '' . $this->language->get('text_grid_missing_file') . ': ' . htmlspecialchars($image_path, ENT_QUOTES, 'UTF-8'),
+ 'after' => $this->language->get('text_grid_broken_db_after'),
+ 'actions' => array(
+ 'clear' => $this->language->get('text_action_clear'),
+ 'disable' => $this->language->get('text_action_disable'),
+ 'stock' => $this->language->get('text_action_stock'),
+ 'placeholder' => $this->language->get('text_action_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'clear'
+ );
+ }
+ }
+
+ $query_add = $this->db->query("SELECT pi.product_image_id as id, pi.product_id, pi.image, p.model, p.status, p.date_available, (SELECT p2s.store_id FROM `" . DB_PREFIX . "product_to_store` p2s WHERE p2s.product_id = pi.product_id AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "product_description` pd WHERE pd.product_id = pi.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "product_image` pi LEFT JOIN `" . DB_PREFIX . "product` p ON (pi.product_id = p.product_id) WHERE pi.image != '' AND pi.image IS NOT NULL");
+ foreach ($query_add->rows as $row) {
+ $image_path = str_replace(array('../', '..\\', "\0"), '', (string)$row['image']);
+ $fullPath = DIR_IMAGE . $image_path;
+ $realPath = realpath($fullPath);
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), $realImageDir) !== 0 || !is_file($realPath)) {
+ $admin_link = $this->url->link('catalog/product/edit', 'user_token=' . $this->session->data['user_token'] . '&product_id=' . (int)$row['product_id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (empty($row['date_available']) || strtotime($row['date_available']) <= time()) && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/product&product_id=' . (int)$row['product_id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name'], $row['model']);
+ $rows[] = array(
+ 'id' => 'product_additional-' . (int)$row['id'],
+ 'preview' => '',
+ 'name' => 'Product (Add.) ' . htmlspecialchars($row['name'] ? $row['name'] : 'Product ID ' . (int)$row['product_id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => '' . $this->language->get('text_grid_missing_file') . ': ' . htmlspecialchars($image_path, ENT_QUOTES, 'UTF-8'),
+ 'after' => $this->language->get('text_grid_broken_db_after'),
+ 'actions' => array(
+ 'clear' => $this->language->get('text_action_clear'),
+ 'placeholder' => $this->language->get('text_action_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'clear'
+ );
+ }
+ }
+
+ $query_cat = $this->db->query("SELECT c.category_id as id, c.image, c.status, (SELECT c2s.store_id FROM `" . DB_PREFIX . "category_to_store` c2s WHERE c2s.category_id = c.category_id AND c2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "category_description` cd WHERE cd.category_id = c.category_id AND cd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "category` c WHERE image != '' AND image IS NOT NULL");
+ foreach ($query_cat->rows as $row) {
+ $image_path = str_replace(array('../', '..\\', "\0"), '', (string)$row['image']);
+ $fullPath = DIR_IMAGE . $image_path;
+ $realPath = realpath($fullPath);
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), $realImageDir) !== 0 || !is_file($realPath)) {
+ $admin_link = $this->url->link('catalog/category/edit', 'user_token=' . $this->session->data['user_token'] . '&category_id=' . (int)$row['id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/category&path=' . (int)$row['id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name']);
+ $rows[] = array(
+ 'id' => 'category-' . (int)$row['id'],
+ 'preview' => '',
+ 'name' => 'Category ' . htmlspecialchars($row['name'] ? $row['name'] : 'Category ID ' . (int)$row['id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => '' . $this->language->get('text_grid_missing_file') . ': ' . htmlspecialchars($image_path, ENT_QUOTES, 'UTF-8'),
+ 'after' => $this->language->get('text_grid_broken_db_after'),
+ 'actions' => array(
+ 'clear' => $this->language->get('text_action_clear'),
+ 'disable' => $this->language->get('text_action_disable'),
+ 'placeholder' => $this->language->get('text_action_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'clear'
+ );
+ }
+ }
+
+ $query_man = $this->db->query("SELECT m.manufacturer_id as id, m.name, m.image, (SELECT m2s.store_id FROM `" . DB_PREFIX . "manufacturer_to_store` m2s WHERE m2s.manufacturer_id = m.manufacturer_id AND m2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id FROM `" . DB_PREFIX . "manufacturer` m WHERE image != '' AND image IS NOT NULL");
+ foreach ($query_man->rows as $row) {
+ $image_path = str_replace(array('../', '..\\', "\0"), '', (string)$row['image']);
+ $fullPath = DIR_IMAGE . $image_path;
+ $realPath = realpath($fullPath);
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), $realImageDir) !== 0 || !is_file($realPath)) {
+ $admin_link = $this->url->link('catalog/manufacturer/edit', 'user_token=' . $this->session->data['user_token'] . '&manufacturer_id=' . (int)$row['id'], true);
+ $catalog_link_html = '';
+ if ((isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/manufacturer/info&manufacturer_id=' . (int)$row['id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name']);
+ $rows[] = array(
+ 'id' => 'manufacturer-' . (int)$row['id'],
+ 'preview' => '',
+ 'name' => 'Manufacturer ' . htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => '' . $this->language->get('text_grid_missing_file') . ': ' . htmlspecialchars($image_path, ENT_QUOTES, 'UTF-8'),
+ 'after' => $this->language->get('text_grid_broken_db_after'),
+ 'actions' => array(
+ 'clear' => $this->language->get('text_action_clear'),
+ 'placeholder' => $this->language->get('text_action_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'clear'
+ );
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_broken_db_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => false,
+ 'description' => $this->language->get('text_description_broken')
+ );
+ }
+
+ public function fix_broken_db($data) {
+ $this->backupCatalogTables('broken');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ if ($fix_all === 1 || empty($selected)) {
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_broken_db_grid.json'), true);
+ foreach ($grid as $row) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $ph_product = (string)$this->config->get('module_img_opti_ph_product');
+ $ph_category = (string)$this->config->get('module_img_opti_ph_category');
+ $ph_brand = (string)$this->config->get('module_img_opti_ph_brand');
+
+ foreach ($selected as $row_id => $act) {
+ if ($act === 'ignore') continue;
+ $parts = explode('-', (string)$row_id, 2);
+ if (count($parts) !== 2) continue;
+ $type = preg_replace('/[^a-zA-Z_]/', '', $parts[0]);
+ $id = (int)$parts[1];
+
+ if ($act === 'clear') {
+ if ($type === 'product_main') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '' WHERE product_id = '" . $id . "'");
+ } elseif ($type === 'product_additional') {
+ $this->db->query("DELETE FROM `" . DB_PREFIX . "product_image` WHERE product_image_id = '" . $id . "'");
+ } elseif ($type === 'category') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '' WHERE category_id = '" . $id . "'");
+ } elseif ($type === 'manufacturer') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '' WHERE manufacturer_id = '" . $id . "'");
+ }
+ } elseif ($act === 'disable') {
+ if ($type === 'product_main') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET status = '0' WHERE product_id = '" . $id . "'");
+ } elseif ($type === 'category') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET status = '0' WHERE category_id = '" . $id . "'");
+ }
+ } elseif ($act === 'stock') {
+ if ($type === 'product_main') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET quantity = '0' WHERE product_id = '" . $id . "'");
+ }
+ } elseif ($act === 'placeholder') {
+ if ($type === 'product_main') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '" . $this->db->escape($ph_product) . "' WHERE product_id = '" . $id . "'");
+ } elseif ($type === 'product_additional') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "product_image` SET image = '" . $this->db->escape($ph_product) . "' WHERE product_image_id = '" . $id . "'");
+ } elseif ($type === 'category') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '" . $this->db->escape($ph_category) . "' WHERE category_id = '" . $id . "'");
+ } elseif ($type === 'manufacturer') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '" . $this->db->escape($ph_brand) . "' WHERE manufacturer_id = '" . $id . "'");
+ }
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_broken_db_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_broken_files($data = array()) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $ext = strtolower($file->getExtension());
+ if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp', 'gif'))) {
+ $realPath = realpath($file->getPathname());
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) !== 0) continue;
+
+ $size = filesize($realPath);
+ $is_broken = false;
+
+ if ($size === 0) {
+ $is_broken = true;
+ } elseif ($size > 52428800) {
+ // Файл > 50MB — пропускаем GD-проверку во избежание OOM
+ $is_broken = false;
+ } else {
+ if (extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($realPath);
+ $img->clear(); $img->destroy();
+ } catch (Exception $e) {
+ $is_broken = true;
+ }
+ } else {
+ $img = @imagecreatefromstring(@file_get_contents($realPath, false, null, 0, 52428800));
+ if (!$img) {
+ $is_broken = true;
+ } else {
+ imagedestroy($img);
+ }
+ }
+ }
+
+ if ($is_broken) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $rows[] = array(
+ 'id' => 'broken_file-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $this->language->get('text_grid_broken_file') . ' (' . $this->formatBytes($size) . ')',
+ 'after' => $this->language->get('text_grid_broken_file_after'),
+ 'actions' => array(
+ 'delete' => $this->language->get('text_action_delete'),
+ 'placeholder' => $this->language->get('text_action_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'delete',
+ 'meta' => array(
+ 'path' => $relPath
+ )
+ );
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_broken_files_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_broken_files')
+ );
+ }
+
+ public function fix_broken_files($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_broken_files_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $ph_product_path = DIR_IMAGE . (string)$this->config->get('module_img_opti_ph_product');
+ if (!is_file($ph_product_path)) {
+ $ph_product_path = DIR_IMAGE . 'no_image.png';
+ }
+
+ foreach ($selected as $row_id => $act) {
+ if ($act === 'ignore' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $relPath = $row['meta']['path'];
+ $fullPath = DIR_IMAGE . $relPath;
+
+ if ($act === 'delete') {
+ $this->quarantineFile($relPath, 'broken_files');
+ } elseif ($act === 'placeholder') {
+ $this->quarantineFile($relPath, 'broken_files');
+ if (is_file($ph_product_path)) {
+ @copy($ph_product_path, $fullPath);
+ }
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_broken_files_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_duplicates($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $hashes = array();
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $ext = strtolower($file->getExtension());
+ if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'))) {
+ $realPath = realpath($file->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $hash = md5_file($realPath);
+ $relPath = $this->getRelativeImagePath($realPath);
+ if (!isset($hashes[$hash])) {
+ $hashes[$hash] = array();
+ }
+ $hashes[$hash][] = $relPath;
+ }
+ }
+ }
+ }
+
+ $this->load->model('tool/image');
+
+ foreach ($hashes as $hash => $files) {
+ if (count($files) > 1) {
+ $original = array_shift($files);
+ $original_size = filesize(DIR_IMAGE . $original);
+
+ $orig_ext = strtolower($this->mb_pathinfo($original, PATHINFO_EXTENSION));
+ if ($orig_ext === 'svg') {
+ $thumb_orig = '../image/' . $original;
+ } else {
+ $thumb_orig = $this->model_tool_image->resize($original, 100, 100);
+ }
+
+ foreach ($files as $duplicate) {
+ $dupe_ext = strtolower($this->mb_pathinfo($duplicate, PATHINFO_EXTENSION));
+ if ($dupe_ext === 'svg') {
+ $thumb_dupe = '../image/' . $duplicate;
+ } else {
+ $thumb_dupe = $this->model_tool_image->resize($duplicate, 100, 100);
+ }
+
+ $entities = $this->getEntitiesByImagePath($duplicate);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $preview_html = '
';
+
+ $name_html = 'Duplicate path:
' . htmlspecialchars($duplicate, ENT_QUOTES, 'UTF-8') . '
';
+ $name_html .= 'Relations:
' . $entities_html;
+
+ $rows[] = array(
+ 'id' => 'duplicate-' . md5($duplicate),
+ 'preview' => $preview_html,
+ 'name' => $name_html,
+ 'before' => '' . $this->language->get('text_grid_dupe_before') . '
' . htmlspecialchars($duplicate, ENT_QUOTES, 'UTF-8') . '
' . $this->formatBytes($original_size) . '',
+ 'after' => ' ' . $this->language->get('text_grid_dupe_after') . '
' . htmlspecialchars($original, ENT_QUOTES, 'UTF-8') . '
' . $this->formatBytes($original_size) . '',
+ 'actions' => array(
+ 'merge' => $this->language->get('text_action_merge'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'merge',
+ 'meta' => array(
+ 'original' => $original,
+ 'duplicate' => $duplicate
+ )
+ );
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_duplicates_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_dupe_delete'),
+ $this->language->get('text_th_dupe_keep'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_duplicates')
+ );
+ }
+
+ public function fix_duplicates($data) {
+ $this->backupCatalogTables('duplicates');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_duplicates_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'merge' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $orig = $this->db->escape($row['meta']['original']);
+ $fake = $this->db->escape($row['meta']['duplicate']);
+
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '" . $orig . "' WHERE image = '" . $fake . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "product_image` SET image = '" . $orig . "' WHERE image = '" . $fake . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '" . $orig . "' WHERE image = '" . $fake . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '" . $orig . "' WHERE image = '" . $fake . "'");
+
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'");
+ if ($table_query->num_rows) {
+ $this->db->query("UPDATE `" . DB_PREFIX . "banner_image` SET image = '" . $orig . "' WHERE image = '" . $fake . "'");
+ }
+
+ $blog_tables = array(
+ 'oct_blog_article' => 'image',
+ 'simple_blog_article' => 'image',
+ 'newsblog_article' => 'image',
+ 'information' => 'image'
+ );
+ foreach ($blog_tables as $table => $column) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $table . "'");
+ if ($table_query->num_rows) {
+ $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $table . "` LIKE '" . $column . "'");
+ if ($column_query->num_rows) {
+ try {
+ $this->db->query("UPDATE `" . DB_PREFIX . $table . "` SET `" . $column . "` = '" . $orig . "' WHERE `" . $column . "` = '" . $fake . "'");
+ } catch (Exception $e) {}
+ }
+ }
+ }
+
+ $this->quarantineFile($row['meta']['duplicate'], 'duplicates');
+ }
+ @unlink(DIR_CACHE . 'img_opti_duplicates_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_png_jpg($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $limit = isset($data['module_img_opti_png_jpg_limit']) ? (int)$data['module_img_opti_png_jpg_limit'] : 100;
+ if ($limit <= 0) $limit = 100;
+
+ $min_size = isset($data['module_img_opti_png_jpg_min_size']) ? (int)$data['module_img_opti_png_jpg_min_size'] : 500;
+ if ($min_size < 0) $min_size = 500;
+ $min_size_bytes = $min_size * 1024;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && strtolower($file->getExtension()) === 'png') {
+ $realPath = realpath($file->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $size = filesize($realPath);
+ if ($size < $min_size_bytes) continue;
+
+ $w = 0;
+ $h = 0;
+ $imgSize = @getimagesize($realPath);
+ if ($imgSize) {
+ $w = $imgSize[0];
+ $h = $imgSize[1];
+ }
+ $is_alpha = false;
+ if (extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($realPath);
+ if ($img->getImageAlphaChannel()) {
+ $is_alpha = true;
+ }
+ $img->clear(); $img->destroy();
+ } catch (Exception $e) {}
+ } else {
+ $img = @imagecreatefrompng($realPath);
+ if ($img) {
+ $w_gd = imagesx($img);
+ $h_gd = imagesy($img);
+ for($x = 0; $x < $w_gd; $x++) {
+ for($y = 0; $y < $h_gd; $y++) {
+ $rgba = imagecolorat($img, $x, $y);
+ $alpha = ($rgba & 0x7F000000) >> 24;
+ if($alpha > 0) {
+ $is_alpha = true;
+ break 2;
+ }
+ }
+ }
+ imagedestroy($img);
+ }
+ }
+
+ if (!$is_alpha) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+ $rows[] = array(
+ 'id' => 'png_jpg-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => 'PNG: ' . $this->formatBytes($size),
+ 'after' => 'JPG: ~' . $this->formatBytes(round($size * 0.3)),
+ 'actions' => array(
+ 'convert' => $this->language->get('text_action_convert'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'convert',
+ 'meta' => array(
+ 'path' => $relPath,
+ 'width' => $w,
+ 'height' => $h
+ )
+ );
+ if (count($rows) >= $limit) {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_png_jpg_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_png_jpg')
+ );
+ }
+
+ public function fix_png_jpg($data) {
+ $this->backupCatalogTables('png_jpg');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_png_jpg_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'convert' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $pngClean = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $pngClean);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) continue;
+
+ $jpgPathStr = preg_replace('/\.png$/i', '.jpg', DIR_IMAGE . $pngClean);
+ $jpgDir = realpath($this->mb_dirname($jpgPathStr));
+ if (!$jpgDir || strpos(str_replace('\\', '/', $jpgDir), str_replace('\\', '/', $realImageDir)) !== 0) continue;
+ $relJpg = ltrim(str_replace(str_replace('\\', '/', $realImageDir), '', str_replace('\\', '/', $jpgPathStr)), '/');
+
+ $img = @imagecreatefrompng($fullPath);
+ if ($img) {
+ if (function_exists('imagepalettetotruecolor') && !imageistruecolor($img)) {
+ imagepalettetotruecolor($img);
+ }
+ $bg = imagecreatetruecolor(imagesx($img), imagesy($img));
+ imagefill($bg, 0, 0, imagecolorallocate($bg, 255, 255, 255));
+ imagecopy($bg, $img, 0, 0, 0, 0, imagesx($img), imagesy($img));
+ if (imagejpeg($bg, $jpgPathStr, 85)) {
+ $dbOld = $this->db->escape($pngClean);
+ $dbNew = $this->db->escape($relJpg);
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "product_image` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->quarantineFile($pngClean, 'png_jpg');
+ }
+ imagedestroy($img);
+ imagedestroy($bg);
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_png_jpg_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_translit($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $limit = isset($data['module_img_opti_translit_limit']) ? (int)$data['module_img_opti_translit_limit'] : 100;
+ if ($limit <= 0) $limit = 100;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ $assigned_paths = array();
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $realPath = realpath($file->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $filename = $this->mb_basename($relPath);
+ if (preg_match('/[^a-zA-Z0-9_\\-\\.]/', $filename)) {
+ $dirPart = $this->mb_dirname($relPath);
+ $ext = $this->mb_pathinfo($filename, PATHINFO_EXTENSION);
+ $nameOnly = $this->mb_pathinfo($filename, PATHINFO_FILENAME);
+ $cleanName = $this->transliterate($nameOnly) . '.' . $ext;
+ $proposedRel = ($dirPart === '.' ? '' : $dirPart . '/') . $cleanName;
+
+ if ($relPath !== $proposedRel) {
+ $proposedFullPath = DIR_IMAGE . $proposedRel;
+ if (file_exists($proposedFullPath) || isset($assigned_paths[$proposedRel]) || $this->isPathUsedInDb($proposedRel)) {
+ $transName = $this->transliterate($nameOnly);
+ $counter = 1;
+ while (true) {
+ $candidate = ($dirPart === '.' ? '' : $dirPart . '/') . $transName . '_' . $counter . ($ext !== '' ? '.' . $ext : '');
+ if (!file_exists(DIR_IMAGE . $candidate) && !isset($assigned_paths[$candidate]) && !$this->isPathUsedInDb($candidate)) {
+ $proposedRel = $candidate;
+ break;
+ }
+ $counter++;
+ }
+ }
+
+ $assigned_paths[$proposedRel] = true;
+
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $rows[] = array(
+ 'id' => 'translit-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $filename,
+ 'after' => $this->mb_basename($proposedRel),
+ 'actions' => array(
+ 'rename' => $this->language->get('text_action_rename'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'rename',
+ 'meta' => array(
+ 'path' => $relPath,
+ 'new_path' => $proposedRel
+ )
+ );
+
+ if (count($rows) >= $limit) {
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_translit_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_translit')
+ );
+ }
+
+ public function fix_translit($data) {
+ $this->backupCatalogTables('translit');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_translit_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'rename' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $cleanBadFile = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $cleanBadFile);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) continue;
+
+ $newRel = (string)$row['meta']['new_path'];
+ $newFullPathStr = DIR_IMAGE . $newRel;
+ $newDirStr = $this->mb_dirname($newFullPathStr);
+ if (!is_dir($newDirStr)) {
+ @mkdir($newDirStr, 0755, true);
+ }
+ $realNewDir = realpath($newDirStr);
+ if (!$realNewDir || strpos(str_replace('\\', '/', $realNewDir), str_replace('\\', '/', $realImageDir)) !== 0) continue;
+ $newFullPath = $realNewDir . DIRECTORY_SEPARATOR . $this->mb_basename($newFullPathStr);
+
+ if (@copy($fullPath, $newFullPath)) {
+ $dbOld = $this->db->escape($cleanBadFile);
+ $dbNew = $this->db->escape($newRel);
+
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "product_image` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'");
+ if ($table_query->num_rows) {
+ $this->db->query("UPDATE `" . DB_PREFIX . "banner_image` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ }
+
+ $blog_tables = array(
+ 'oct_blog_article' => 'image',
+ 'simple_blog_article' => 'image',
+ 'newsblog_article' => 'image',
+ 'information' => 'image'
+ );
+ foreach ($blog_tables as $table => $column) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $table . "'");
+ if ($table_query->num_rows) {
+ $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $table . "` LIKE '" . $column . "'");
+ if ($column_query->num_rows) {
+ try {
+ $this->db->query("UPDATE `" . DB_PREFIX . $table . "` SET `" . $column . "` = '" . $dbNew . "' WHERE `" . $column . "` = '" . $dbOld . "'");
+ } catch (Exception $e) {}
+ }
+ }
+ }
+
+ $this->quarantineFile($cleanBadFile, 'translit');
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_translit_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_empty_folders($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($iterator as $path) {
+ if ($path->isDir()) {
+ $realPath = realpath($path->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $files = array_diff(scandir($realPath), array('.', '..'));
+ if (count($files) === 0) {
+ $relPath = str_replace(str_replace('\\', '/', DIR_IMAGE), '', str_replace('\\', '/', $realPath));
+ $rows[] = array(
+ 'id' => 'empty_folder-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '',
+ 'before' => $this->language->get('text_grid_empty_folder'),
+ 'after' => $this->language->get('text_grid_empty_folder_after'),
+ 'actions' => array(
+ 'delete' => $this->language->get('text_action_delete'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'delete',
+ 'meta' => array(
+ 'path' => $relPath
+ )
+ );
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_empty_folders_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => false,
+ 'description' => $this->language->get('text_description_empty_folders')
+ );
+ }
+
+ public function fix_empty_folders($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_empty_folders_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'delete' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $cleanPath = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $cleanPath);
+ if ($fullPath && strpos(str_replace('\\', '/', $fullPath), $realImageDir) === 0 && is_dir($fullPath)) {
+ $files = array_diff(scandir($fullPath), array('.', '..'));
+ if (count($files) === 0) {
+ @rmdir($fullPath);
+ }
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_empty_folders_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_smart_cache($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $cache_dir = realpath(DIR_IMAGE . 'cache/catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$cache_dir || strpos(str_replace('\\', '/', $cache_dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($cache_dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cache_dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $realPath = realpath($file->getPathname());
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $cache_dir)) !== 0) continue;
+ $rel_cache = ltrim(str_replace(str_replace('\\', '/', DIR_IMAGE . 'cache/'), '', str_replace('\\', '/', $realPath)), '/');
+ $orig_rel = preg_replace('/-\d+x\d+\.(jpg|jpeg|png|webp|gif)$/i', '.$1', $rel_cache);
+ $orig_path = realpath(DIR_IMAGE . $orig_rel);
+
+ if (!$orig_path || !is_file($orig_path) || strpos(str_replace('\\', '/', $orig_path), str_replace('\\', '/', $realImageDir)) !== 0) {
+ $thumb = (defined('HTTPS_CATALOG') && HTTPS_CATALOG ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '')) . 'image/cache/' . $rel_cache;
+ $size = filesize($realPath);
+
+ $rows[] = array(
+ 'id' => 'smart_cache-' . md5($rel_cache),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($rel_cache, ENT_QUOTES, 'UTF-8') . '',
+ 'before' => $this->language->get('text_grid_orph_cache') . ' (' . $this->formatBytes($size) . ')',
+ 'after' => $this->language->get('text_grid_orph_cache_after'),
+ 'actions' => array(
+ 'delete' => $this->language->get('text_action_delete'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'delete',
+ 'meta' => array(
+ 'path' => $rel_cache
+ )
+ );
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_smart_cache_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_smart_cache')
+ );
+ }
+
+ public function fix_smart_cache($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_smart_cache_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'delete' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $this->quarantineFile('cache/' . $row['meta']['path'], 'smart_cache');
+ }
+ @unlink(DIR_CACHE . 'img_opti_smart_cache_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_cache_restore($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $cache_dir = realpath(DIR_IMAGE . 'cache/catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$cache_dir || strpos(str_replace('\\', '/', $cache_dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($cache_dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $restore_folder = isset($data['module_img_opti_restore_folder']) ? trim((string)$data['module_img_opti_restore_folder']) : '';
+ $restore_products = isset($data['module_img_opti_restore_target_product']) ? (int)$data['module_img_opti_restore_target_product'] : 1;
+ $restore_categories = isset($data['module_img_opti_restore_target_category']) ? (int)$data['module_img_opti_restore_target_category'] : 1;
+ $restore_brands = isset($data['module_img_opti_restore_target_brand']) ? (int)$data['module_img_opti_restore_target_brand'] : 1;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cache_dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $realPath = realpath($file->getPathname());
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $cache_dir)) !== 0) continue;
+ $rel_cache = ltrim(str_replace(str_replace('\\', '/', DIR_IMAGE . 'cache/'), '', str_replace('\\', '/', $realPath)), '/');
+ $orig_rel = preg_replace('/-\d+x\d+\.(jpg|jpeg|png|webp|gif)$/i', '.$1', $rel_cache);
+ $orig_path = realpath(DIR_IMAGE . $orig_rel);
+
+ if (!$orig_path || !is_file($orig_path)) {
+ if (!empty($restore_folder)) {
+ $clean_restore_folder = str_replace(array('../', '..\\', "\0"), '', $restore_folder);
+ if (strpos($rel_cache, $clean_restore_folder) === false) continue;
+ }
+
+ $entities = $this->getEntitiesByImagePath($orig_rel);
+ $has_product = false;
+ $has_category = false;
+ $has_brand = false;
+ foreach ($entities as $entity_html) {
+ if (strpos($entity_html, 'Product') !== false) $has_product = true;
+ if (strpos($entity_html, 'Category') !== false) $has_category = true;
+ if (strpos($entity_html, 'Manufacturer') !== false) $has_brand = true;
+ }
+
+ $is_allowed = false;
+ if (empty($entities)) {
+ $is_allowed = true;
+ } else {
+ if ($has_product && $restore_products) $is_allowed = true;
+ if ($has_category && $restore_categories) $is_allowed = true;
+ if ($has_brand && $restore_brands) $is_allowed = true;
+ }
+
+ if (!$is_allowed) continue;
+
+ $thumb = (defined('HTTPS_CATALOG') && HTTPS_CATALOG ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '')) . 'image/cache/' . $rel_cache;
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $rows[] = array(
+ 'id' => 'cache_restore-' . md5($rel_cache),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($orig_rel, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $this->language->get('text_grid_missing_orig'),
+ 'after' => $this->language->get('text_grid_missing_orig_after'),
+ 'actions' => array(
+ 'restore' => $this->language->get('text_action_restore'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'restore',
+ 'meta' => array(
+ 'cache_path' => $rel_cache,
+ 'orig_path' => $orig_rel
+ )
+ );
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_cache_restore_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => false,
+ 'description' => $this->language->get('text_description_cache_restore')
+ );
+ }
+
+ public function fix_cache_restore($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_cache_restore_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'restore' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $cacheFullPath = realpath(DIR_IMAGE . 'cache/' . $row['meta']['cache_path']);
+ if (!$cacheFullPath || !is_file($cacheFullPath)) continue;
+
+ $origRel = (string)$row['meta']['orig_path'];
+ $destPath = DIR_IMAGE . $origRel;
+ if (is_file($destPath)) continue;
+ $destDir = $this->mb_dirname($destPath);
+ if (!is_dir($destDir)) {
+ @mkdir($destDir, 0755, true);
+ }
+ $realDestDir = realpath($destDir);
+ if ($realDestDir && strpos(str_replace('\\', '/', $realDestDir), $realImageDir) === 0) {
+ @copy($cacheFullPath, $realDestDir . DIRECTORY_SEPARATOR . $this->mb_basename($origRel));
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_cache_restore_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_exif($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && in_array(strtolower($file->getExtension()), array('jpg', 'jpeg'))) {
+ $realPath = realpath($file->getPathname());
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) !== 0) continue;
+ $exif = @exif_read_data($realPath);
+ if ($exif && (isset($exif['Make']) || isset($exif['Model']) || isset($exif['GPSLatitude']) || isset($exif['GPSLongitude']) || isset($exif['Software']) || isset($exif['DateTime']) || isset($exif['GPSInfo']) || isset($exif['GPSLatitudeRef']) || isset($exif['GPSLongitudeRef']) || isset($exif['DateTimeOriginal']) || isset($exif['Artist']) || isset($exif['Copyright']) || isset($exif['UserComment']))) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+ $size = filesize($realPath);
+
+ $exif_details = array();
+ if (isset($exif['Make'])) $exif_details[] = 'Make: ' . htmlspecialchars($exif['Make'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['Model'])) $exif_details[] = 'Model: ' . htmlspecialchars($exif['Model'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['Software'])) $exif_details[] = 'Software: ' . htmlspecialchars($exif['Software'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['GPSLatitude']) || isset($exif['GPSLongitude']) || isset($exif['GPSInfo']) || isset($exif['GPSLatitudeRef']) || isset($exif['GPSLongitudeRef'])) $exif_details[] = 'GPS: Yes';
+ if (isset($exif['DateTime'])) $exif_details[] = 'Date: ' . htmlspecialchars($exif['DateTime'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['DateTimeOriginal'])) $exif_details[] = 'DateOriginal: ' . htmlspecialchars($exif['DateTimeOriginal'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['Artist'])) $exif_details[] = 'Artist: ' . htmlspecialchars($exif['Artist'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['Copyright'])) $exif_details[] = 'Copyright: ' . htmlspecialchars($exif['Copyright'], ENT_QUOTES, 'UTF-8');
+ if (isset($exif['UserComment'])) $exif_details[] = 'Comment: ' . htmlspecialchars($exif['UserComment'], ENT_QUOTES, 'UTF-8');
+
+ $exif_info = implode(', ', $exif_details);
+ if (!$exif_info) $exif_info = 'EXIF data present';
+
+ $rows[] = array(
+ 'id' => 'exif-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $exif_info . ' (' . $this->formatBytes($size) . ')',
+ 'after' => $this->language->get('text_grid_exif_after'),
+ 'actions' => array(
+ 'strip' => $this->language->get('text_action_strip'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'strip',
+ 'meta' => array(
+ 'path' => $relPath
+ )
+ );
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_exif_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_exif')
+ );
+ }
+
+ public function fix_exif($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_exif_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'strip' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $cleanPath = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $cleanPath);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) continue;
+
+ $this->quarantineFile($cleanPath, 'exif');
+
+ $restoredFullPath = DIR_IMAGE . 'catalog_trash/exif/' . ltrim($cleanPath, '/');
+ if (is_file($restoredFullPath)) {
+ if (extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($restoredFullPath);
+ $img->stripImage();
+ $img->writeImage($fullPath);
+ $img->clear(); $img->destroy();
+ } catch (Exception $e) {}
+ } else {
+ $img = @imagecreatefromjpeg($restoredFullPath);
+ if ($img) {
+ imagejpeg($img, $fullPath, 90);
+ imagedestroy($img);
+ }
+ }
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_exif_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_placeholders($data = array()) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '');
+
+ $scan_products = !empty($data) ? (isset($data['module_img_opti_ph_target_product']) ? (int)$data['module_img_opti_ph_target_product'] : 0) : ($this->config->get('module_img_opti_ph_target_product') !== null ? (int)$this->config->get('module_img_opti_ph_target_product') : 1);
+ $scan_categories = !empty($data) ? (isset($data['module_img_opti_ph_target_category']) ? (int)$data['module_img_opti_ph_target_category'] : 0) : ($this->config->get('module_img_opti_ph_target_category') !== null ? (int)$this->config->get('module_img_opti_ph_target_category') : 1);
+ $scan_brands = !empty($data) ? (isset($data['module_img_opti_ph_target_brand']) ? (int)$data['module_img_opti_ph_target_brand'] : 0) : ($this->config->get('module_img_opti_ph_target_brand') !== null ? (int)$this->config->get('module_img_opti_ph_target_brand') : 1);
+
+ $this->load->model('tool/image');
+
+ $rules_json = $this->config->get('module_img_opti_ph_rules');
+ $rules = json_decode(html_entity_decode($rules_json, ENT_QUOTES, 'UTF-8'), true) ?: array();
+
+ if ($scan_products) {
+ $query = $this->db->query("SELECT p.product_id as id, p.model, p.status, p.date_available, (SELECT p2s.store_id FROM `" . DB_PREFIX . "product_to_store` p2s WHERE p2s.product_id = p.product_id AND p2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "product_description` pd WHERE pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "product` p WHERE image = '' OR image IS NULL");
+ foreach ($query->rows as $row) {
+ $matched_image = '';
+ foreach ($rules as $rule) {
+ if (empty($rule['status']) || empty($rule['target_product'])) continue;
+
+ $categories = array();
+ if (!empty($rule['categories'])) {
+ foreach ($rule['categories'] as $c) {
+ $categories[] = is_array($c) ? (int)$c['id'] : (int)$c;
+ }
+ }
+ $brands = array();
+ if (!empty($rule['brands'])) {
+ foreach ($rule['brands'] as $b) {
+ $brands[] = is_array($b) ? (int)$b['id'] : (int)$b;
+ }
+ }
+
+ $product_id = (int)$row['id'];
+ $query_p_info = $this->db->query("SELECT manufacturer_id FROM `" . DB_PREFIX . "product` WHERE product_id = '" . $product_id . "' LIMIT 1");
+ $manufacturer_id = $query_p_info->num_rows ? (int)$query_p_info->row['manufacturer_id'] : 0;
+
+ $prod_categories = array();
+ $query_p_cats = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "product_to_category` WHERE product_id = '" . $product_id . "'");
+ foreach ($query_p_cats->rows as $cat_row) {
+ $prod_categories[] = (int)$cat_row['category_id'];
+ }
+
+ $in_selected_cats = false;
+ if (!empty($categories)) {
+ foreach ($prod_categories as $c_id) {
+ if (in_array($c_id, $categories)) {
+ $in_selected_cats = true;
+ break;
+ }
+ }
+ }
+ $in_selected_brands = false;
+ if (!empty($brands) && in_array($manufacturer_id, $brands)) {
+ $in_selected_brands = true;
+ }
+
+ $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'exclude';
+ $match = true;
+ if ($filter_mode === 'include') {
+ if (!empty($categories) || !empty($brands)) {
+ if (!$in_selected_cats && !$in_selected_brands) {
+ $match = false;
+ }
+ }
+ } else {
+ if ($in_selected_cats || $in_selected_brands) {
+ $match = false;
+ }
+ }
+
+ if ($match && !empty($rule['image'])) {
+ $matched_image = $rule['image'];
+ break;
+ }
+ }
+
+ if (!$matched_image) continue;
+
+ $admin_link = $this->url->link('catalog/product/edit', 'user_token=' . $this->session->data['user_token'] . '&product_id=' . (int)$row['id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (empty($row['date_available']) || strtotime($row['date_available']) <= time()) && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/product&product_id=' . (int)$row['id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name'], $row['model']);
+ $rows[] = array(
+ 'id' => 'placeholder_product-' . (int)$row['id'],
+ 'preview' => is_file(DIR_IMAGE . $matched_image) ? '' : '',
+ 'name' => 'Product ' . htmlspecialchars($row['name'] ? $row['name'] : 'Product ID ' . (int)$row['id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => $this->language->get('text_grid_no_image'),
+ 'after' => $this->mb_basename($matched_image),
+ 'actions' => array(
+ 'set_placeholder' => $this->language->get('text_action_set_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'set_placeholder',
+ 'meta' => array(
+ 'image' => $matched_image
+ )
+ );
+ }
+ }
+
+ if ($scan_categories) {
+ $query_cat = $this->db->query("SELECT c.category_id as id, c.status, (SELECT c2s.store_id FROM `" . DB_PREFIX . "category_to_store` c2s WHERE c2s.category_id = c.category_id AND c2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id, (SELECT name FROM `" . DB_PREFIX . "category_description` cd WHERE cd.category_id = c.category_id AND cd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "category` c WHERE image = '' OR image IS NULL");
+ foreach ($query_cat->rows as $row) {
+ $matched_image = '';
+ foreach ($rules as $rule) {
+ if (empty($rule['status']) || empty($rule['target_category'])) continue;
+ $categories = array();
+ if (!empty($rule['categories'])) {
+ foreach ($rule['categories'] as $c) {
+ $categories[] = is_array($c) ? (int)$c['id'] : (int)$c;
+ }
+ }
+ $category_id = (int)$row['id'];
+ $match = true;
+ if (!empty($categories)) {
+ $in_selected = in_array($category_id, $categories);
+ $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include';
+ if ($filter_mode === 'include' && !$in_selected) {
+ $match = false;
+ } elseif ($filter_mode === 'exclude' && $in_selected) {
+ $match = false;
+ }
+ }
+ if ($match && !empty($rule['image'])) {
+ $matched_image = $rule['image'];
+ break;
+ }
+ }
+
+ if (!$matched_image) continue;
+
+ $admin_link = $this->url->link('catalog/category/edit', 'user_token=' . $this->session->data['user_token'] . '&category_id=' . (int)$row['id'], true);
+ $catalog_link_html = '';
+ if (isset($row['status']) && (int)$row['status'] === 1 && (isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/category&path=' . (int)$row['id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name']);
+ $rows[] = array(
+ 'id' => 'placeholder_category-' . (int)$row['id'],
+ 'preview' => is_file(DIR_IMAGE . $matched_image) ? '
' : '',
+ 'name' => 'Category ' . htmlspecialchars($row['name'] ? $row['name'] : 'Category ID ' . (int)$row['id'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => $this->language->get('text_grid_no_image'),
+ 'after' => $this->mb_basename($matched_image),
+ 'actions' => array(
+ 'set_placeholder' => $this->language->get('text_action_set_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'set_placeholder',
+ 'meta' => array(
+ 'image' => $matched_image
+ )
+ );
+ }
+ }
+
+ if ($scan_brands) {
+ $query_man = $this->db->query("SELECT m.manufacturer_id as id, m.name, (SELECT m2s.store_id FROM `" . DB_PREFIX . "manufacturer_to_store` m2s WHERE m2s.manufacturer_id = m.manufacturer_id AND m2s.store_id = '" . (int)$this->config->get('config_store_id') . "' LIMIT 1) AS store_id FROM `" . DB_PREFIX . "manufacturer` m WHERE image = '' OR image IS NULL");
+ foreach ($query_man->rows as $row) {
+ $matched_image = '';
+ foreach ($rules as $rule) {
+ if (empty($rule['status']) || empty($rule['target_brand'])) continue;
+ $brands = array();
+ if (!empty($rule['brands'])) {
+ foreach ($rule['brands'] as $b) {
+ $brands[] = is_array($b) ? (int)$b['id'] : (int)$b;
+ }
+ }
+ $manufacturer_id = (int)$row['id'];
+ $match = true;
+ if (!empty($brands)) {
+ $in_selected = in_array($manufacturer_id, $brands);
+ $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include';
+ if ($filter_mode === 'include' && !$in_selected) {
+ $match = false;
+ } elseif ($filter_mode === 'exclude' && $in_selected) {
+ $match = false;
+ }
+ }
+ if ($match && !empty($rule['image'])) {
+ $matched_image = $rule['image'];
+ break;
+ }
+ }
+
+ if (!$matched_image) continue;
+
+ $admin_link = $this->url->link('catalog/manufacturer/edit', 'user_token=' . $this->session->data['user_token'] . '&manufacturer_id=' . (int)$row['id'], true);
+ $catalog_link_html = '';
+ if ((isset($row['store_id']) && $row['store_id'] !== null)) {
+ $catalog_link = $catalog_url . 'index.php?route=product/manufacturer/info&manufacturer_id=' . (int)$row['id'];
+ $catalog_link_html = ' ';
+ }
+ $search_links = $this->getSearchLinks($row['name']);
+ $rows[] = array(
+ 'id' => 'placeholder_manufacturer-' . (int)$row['id'],
+ 'preview' => is_file(DIR_IMAGE . $matched_image) ? '
' : '',
+ 'name' => 'Manufacturer ' . htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') . '' . $catalog_link_html . $search_links,
+ 'before' => $this->language->get('text_grid_no_image'),
+ 'after' => $this->mb_basename($matched_image),
+ 'actions' => array(
+ 'set_placeholder' => $this->language->get('text_action_set_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'set_placeholder',
+ 'meta' => array(
+ 'image' => $matched_image
+ )
+ );
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_placeholders_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => false,
+ 'description' => $this->language->get('text_description_placeholders')
+ );
+ }
+
+ public function fix_placeholders($data) {
+ $this->backupCatalogTables('placeholders');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_placeholders_grid.json'), true);
+ foreach ($grid as $row) {
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'set_placeholder') continue;
+
+ $image_path = '';
+ foreach ($grid as $row) {
+ if ($row['id'] === $row_id) {
+ $image_path = isset($row['meta']['image']) ? (string)$row['meta']['image'] : '';
+ break;
+ }
+ }
+ if (!$image_path) continue;
+
+ $parts = explode('-', (string)$row_id, 2);
+ if (count($parts) !== 2) continue;
+ $type = preg_replace('/[^a-zA-Z_]/', '', $parts[0]);
+ $id = (int)$parts[1];
+
+ if ($type === 'placeholder_product') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '" . $this->db->escape($image_path) . "' WHERE product_id = '" . $id . "'");
+ } elseif ($type === 'placeholder_category') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '" . $this->db->escape($image_path) . "' WHERE category_id = '" . $id . "'");
+ } elseif ($type === 'placeholder_manufacturer') {
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '" . $this->db->escape($image_path) . "' WHERE manufacturer_id = '" . $id . "'");
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_placeholders_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_watermark($data) {
+ $this->loadLanguageSafe();
+ $cache_dir = realpath(DIR_IMAGE . 'cache/catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if ($cache_dir && strpos(str_replace('\\', '/', $cache_dir), str_replace('\\', '/', $realImageDir)) !== 0 && is_dir($cache_dir)) {
+ $this->cleanDir($cache_dir);
+ }
+ return array(
+ 'success' => true,
+ 'message' => $this->language->get('text_log_cache_cleared') . '
' . $this->language->get('text_log_wm_smart_mode')
+ );
+ }
+
+ private function cleanDir($dir) {
+ $realDir = realpath($dir);
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$realDir || strpos(str_replace('\\', '/', $realDir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($realDir)) return;
+ $files = array_diff(scandir($realDir), array('.','..'));
+ foreach ($files as $file) {
+ $currentPath = $realDir . DIRECTORY_SEPARATOR . $this->mb_basename($file);
+ (is_dir($currentPath)) ? $this->cleanDir($currentPath) : @unlink($currentPath);
+ }
+ @rmdir($realDir);
+ }
+
+ public function applyDynamicWatermark($cached_file, $original_file, $is_preview = false) {
+ $realCachedFile = realpath($cached_file);
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$realCachedFile || strpos(str_replace('\\', '/', $realCachedFile), str_replace('\\', '/', $realImageDir)) !== 0) return false;
+
+ $escaped_original = $this->db->escape((string)$original_file);
+ $matched_rules = array();
+
+ if ($is_preview) {
+ $matched_rules[] = array(
+ 'status' => 1,
+ 'type' => $this->config->get('module_img_opti_wm_type'),
+ 'image' => $this->config->get('module_img_opti_wm_image'),
+ 'text_val' => $this->config->get('module_img_opti_wm_text_val'),
+ 'position' => $this->config->get('module_img_opti_wm_position'),
+ 'opacity' => $this->config->get('module_img_opti_wm_opacity'),
+ 'angle' => $this->config->get('module_img_opti_wm_angle'),
+ 'corner_radius' => $this->config->get('module_img_opti_wm_corner_radius'),
+ 'size_type' => $this->config->get('module_img_opti_wm_size_type'),
+ 'size_percent' => $this->config->get('module_img_opti_wm_size_percent'),
+ 'text_color' => $this->config->get('module_img_opti_wm_text_color'),
+ 'text_font' => $this->config->get('module_img_opti_wm_text_font')
+ );
+ } else {
+ $rules_json = $this->config->get('module_img_opti_wm_rules');
+ $rules = json_decode(html_entity_decode($rules_json, ENT_QUOTES, 'UTF-8'), true) ?: array();
+
+ foreach ($rules as $rule) {
+ if (empty($rule['status'])) continue;
+
+ $targets = array();
+ if (!empty($rule['target_product'])) $targets[] = 'product';
+ if (!empty($rule['target_category'])) $targets[] = 'category';
+ if (!empty($rule['target_brand'])) $targets[] = 'manufacturer';
+ if (!empty($rule['target_banner'])) $targets[] = 'banner';
+ if (!empty($rule['target_blog'])) $targets[] = 'blog';
+
+ $is_match = false;
+ $product_id = 0;
+ $manufacturer_id = 0;
+
+ if (in_array('product', $targets)) {
+ $query_prod = $this->db->query("SELECT product_id, manufacturer_id FROM `" . DB_PREFIX . "product` WHERE image = '" . $escaped_original . "' LIMIT 1");
+ if ($query_prod->num_rows) {
+ $is_match = true;
+ $product_id = (int)$query_prod->row['product_id'];
+ $manufacturer_id = (int)$query_prod->row['manufacturer_id'];
+ } else {
+ $query_prod_add = $this->db->query("SELECT product_id FROM `" . DB_PREFIX . "product_image` WHERE image = '" . $escaped_original . "' LIMIT 1");
+ if ($query_prod_add->num_rows) {
+ $is_match = true;
+ $product_id = (int)$query_prod_add->row['product_id'];
+ $query_p_info = $this->db->query("SELECT manufacturer_id FROM `" . DB_PREFIX . "product` WHERE product_id = '" . $product_id . "' LIMIT 1");
+ $manufacturer_id = $query_p_info->num_rows ? (int)$query_p_info->row['manufacturer_id'] : 0;
+ }
+ }
+
+ if ($is_match) {
+ $categories = array();
+ if (!empty($rule['categories'])) {
+ foreach ($rule['categories'] as $c) {
+ $categories[] = is_array($c) ? (int)$c['id'] : (int)$c;
+ }
+ }
+ $brands = array();
+ if (!empty($rule['brands'])) {
+ foreach ($rule['brands'] as $b) {
+ $brands[] = is_array($b) ? (int)$b['id'] : (int)$b;
+ }
+ }
+
+ $prod_categories = array();
+ $query_p_cats = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "product_to_category` WHERE product_id = '" . $product_id . "'");
+ foreach ($query_p_cats->rows as $row) {
+ $prod_categories[] = (int)$row['category_id'];
+ }
+
+ $in_selected_cats = false;
+ if (!empty($categories)) {
+ foreach ($prod_categories as $c_id) {
+ if (in_array($c_id, $categories)) {
+ $in_selected_cats = true;
+ break;
+ }
+ }
+ }
+
+ $in_selected_brands = false;
+ if (!empty($brands) && in_array($manufacturer_id, $brands)) {
+ $in_selected_brands = true;
+ }
+
+ $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include';
+ if ($filter_mode === 'include') {
+ if (!empty($categories) || !empty($brands)) {
+ if (!$in_selected_cats && !$in_selected_brands) {
+ $is_match = false;
+ }
+ }
+ } else {
+ if ($in_selected_cats || $in_selected_brands) {
+ $is_match = false;
+ }
+ }
+ }
+ }
+
+ if (!$is_match && in_array('category', $targets)) {
+ $query_cat = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "category` WHERE image = '" . $escaped_original . "' LIMIT 1");
+ if ($query_cat->num_rows) {
+ $is_match = true;
+ $category_id = (int)$query_cat->row['category_id'];
+ $categories = array();
+ if (!empty($rule['categories'])) {
+ foreach ($rule['categories'] as $c) {
+ $categories[] = is_array($c) ? (int)$c['id'] : (int)$c;
+ }
+ }
+ if (!empty($categories)) {
+ $in_selected = in_array($category_id, $categories);
+ $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include';
+ if ($filter_mode === 'include' && !$in_selected) {
+ $is_match = false;
+ } elseif ($filter_mode === 'exclude' && $in_selected) {
+ $is_match = false;
+ }
+ }
+ }
+ }
+
+ if (!$is_match && in_array('manufacturer', $targets)) {
+ $query_brand = $this->db->query("SELECT manufacturer_id FROM `" . DB_PREFIX . "manufacturer` WHERE image = '" . $escaped_original . "' LIMIT 1");
+ if ($query_brand->num_rows) {
+ $is_match = true;
+ $manufacturer_id = (int)$query_brand->row['manufacturer_id'];
+ $brands = array();
+ if (!empty($rule['brands'])) {
+ foreach ($rule['brands'] as $b) {
+ $brands[] = is_array($b) ? (int)$b['id'] : (int)$b;
+ }
+ }
+ if (!empty($brands)) {
+ $in_selected = in_array($manufacturer_id, $brands);
+ $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include';
+ if ($filter_mode === 'include' && !$in_selected) {
+ $is_match = false;
+ } elseif ($filter_mode === 'exclude' && $in_selected) {
+ $is_match = false;
+ }
+ }
+ }
+ }
+
+ if (!$is_match && in_array('banner', $targets)) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'");
+ if ($table_query->num_rows) {
+ $query_banner = $this->db->query("SELECT banner_image_id FROM `" . DB_PREFIX . "banner_image` WHERE image = '" . $escaped_original . "' LIMIT 1");
+ if ($query_banner->num_rows) {
+ $is_match = true;
+ }
+ }
+ }
+
+ if (!$is_match && in_array('blog', $targets)) {
+ $blog_tables = array(
+ 'oct_blog_article',
+ 'simple_blog_article',
+ 'newsblog_article',
+ 'information'
+ );
+ foreach ($blog_tables as $b_table) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $b_table . "'");
+ if ($table_query->num_rows) {
+ $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $b_table . "` LIKE 'image'");
+ if ($column_query->num_rows) {
+ $query_blog = $this->db->query("SELECT * FROM `" . DB_PREFIX . $b_table . "` WHERE image = '" . $escaped_original . "' LIMIT 1");
+ if ($query_blog->num_rows) {
+ $is_match = true;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if ($is_match) {
+ $matched_rules[] = $rule;
+ }
+ }
+ }
+
+ if (empty($matched_rules)) {
+ return false;
+ }
+
+ $engine = preg_replace('/[^a-z]/', '', strtolower($this->config->get('module_img_opti_engine')));
+ $hasImagick = extension_loaded('imagick');
+
+ if ($engine === 'imagick' && $hasImagick) {
+ try {
+ $img = new Imagick($realCachedFile);
+ foreach ($matched_rules as $rule) {
+ $type = preg_replace('/[^a-z]/', '', $rule['type']);
+ $imagePathStr = str_replace(array('../', '..\\', ' '), '', (string)$rule['image']);
+ $imagePath = realpath(DIR_IMAGE . $imagePathStr);
+ if ($imagePath && strpos(str_replace('\\', '/', $imagePath), str_replace('\\', '/', $realImageDir)) !== 0) {
+ $imagePath = false;
+ }
+ $textVal = (string)$rule['text_val'];
+ $position = (int)$rule['position'];
+ $opacity = (int)$rule['opacity'];
+ $size_type = isset($rule['size_type']) ? $rule['size_type'] : 'original';
+ $size_percent = isset($rule['size_percent']) ? (int)$rule['size_percent'] : 20;
+ $angle = isset($rule['angle']) ? (int)$rule['angle'] : 0;
+ $corner_radius = isset($rule['corner_radius']) ? (int)$rule['corner_radius'] : 0;
+ $text_font = isset($rule['text_font']) ? (string)$rule['text_font'] : '';
+ $text_color = isset($rule['text_color']) ? (string)$rule['text_color'] : '#ffffff';
+
+ if ($type === 'image') {
+ if (!$imagePath || !is_file($imagePath)) continue;
+ }
+ if ($type === 'text' && empty($textVal)) continue;
+
+ $imgW = $img->getImageWidth();
+ $imgH = $img->getImageHeight();
+
+ if ($type === 'image') {
+ $wm = new Imagick($imagePath);
+
+ $max_wm_w = (int)$this->config->get('module_img_opti_wm_max_width');
+ if ($max_wm_w <= 0) $max_wm_w = 800;
+ $max_wm_h = (int)$this->config->get('module_img_opti_wm_max_height');
+ if ($max_wm_h <= 0) $max_wm_h = 800;
+
+ $wmW = $wm->getImageWidth();
+ $wmH = $wm->getImageHeight();
+ if ($wmW > $max_wm_w || $wmH > $max_wm_h) {
+ $ratio = min($max_wm_w / $wmW, $max_wm_h / $wmH);
+ $new_wm_w = intval($wmW * $ratio);
+ $new_wm_h = intval($wmH * $ratio);
+ if ($new_wm_w > 0 && $new_wm_h > 0) {
+ $wm->resizeImage($new_wm_w, $new_wm_h, Imagick::FILTER_LANCZOS, 1);
+ }
+ }
+
+ if ($size_type === 'percent' && $size_percent > 0) {
+ $targetW = intval(($imgW * $size_percent) / 100);
+ $targetH = intval(($wm->getImageHeight() / $wm->getImageWidth()) * $targetW);
+ if ($targetW > 0 && $targetH > 0) {
+ $wm->resizeImage($targetW, $targetH, Imagick::FILTER_LANCZOS, 1);
+ }
+ }
+ if ($corner_radius > 0) {
+ $wm->roundCorners($corner_radius, $corner_radius);
+ }
+ if ($angle != 0) {
+ $wm->rotateImage(new ImagickPixel('none'), $angle);
+ }
+ $wm->evaluateImage(Imagick::EVALUATE_MULTIPLY, $opacity / 100, Imagick::CHANNEL_ALPHA);
+ $wmW = $wm->getImageWidth();
+ $wmH = $wm->getImageHeight();
+ } else {
+ $wm = new Imagick();
+ $draw = new ImagickDraw();
+ $fontFile = $text_font;
+ if (!empty($fontFile) && !is_file($fontFile)) {
+ $base_font = basename($fontFile);
+ if (is_file(DIR_SYSTEM . 'library/font/' . $base_font)) {
+ $fontFile = DIR_SYSTEM . 'library/font/' . $base_font;
+ } elseif (is_file(DIR_SYSTEM . 'fonts/' . $base_font)) {
+ $fontFile = DIR_SYSTEM . 'fonts/' . $base_font;
+ } elseif (is_file(DIR_SYSTEM . 'font/' . $base_font)) {
+ $fontFile = DIR_SYSTEM . 'font/' . $base_font;
+ }
+ }
+ if (empty($fontFile) || !is_file($fontFile)) {
+ $fontFile = DIR_SYSTEM . 'library/font/Roboto-Regular.ttf';
+ }
+ if (is_file($fontFile)) {
+ $draw->setFont($fontFile);
+ }
+ $fontSize = max(12, $imgW * 0.04);
+ $draw->setFontSize($fontSize);
+ $colorHex = $text_color;
+ list($r, $g, $b) = $this->hex2rgb($colorHex);
+ $draw->setFillColor(new ImagickPixel('rgba(' . $r . ',' . $g . ',' . $b . ',' . ($opacity / 100) . ')'));
+ $draw->setGravity(Imagick::GRAVITY_CENTER);
+ $wm->newImage($imgW, $imgH, new ImagickPixel('transparent'));
+ $wm->annotateImage($draw, 0, 0, 0, $textVal);
+ if ($angle != 0) {
+ $wm->rotateImage(new ImagickPixel('none'), $angle);
+ }
+ $wmW = $wm->getImageWidth();
+ $wmH = $wm->getImageHeight();
+ }
+ $posX = 0; $posY = 0; $pad = 10;
+ if (in_array($position, array(1, 4, 7))) $posX = $pad;
+ elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $wmW) / 2;
+ elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $wmW - $pad;
+
+ if (in_array($position, array(1, 2, 3))) $posY = $pad;
+ elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $wmH) / 2;
+ elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $wmH - $pad;
+
+ $img->compositeImage($wm, Imagick::COMPOSITE_OVER, $posX, $posY);
+ $wm->clear(); $wm->destroy();
+ }
+ $img->writeImage($realCachedFile);
+ $img->clear(); $img->destroy();
+ } catch (Exception $e) {}
+ } else {
+ $ext = strtolower($this->mb_pathinfo($realCachedFile, PATHINFO_EXTENSION));
+ if ($ext === 'jpg' || $ext === 'jpeg') {
+ $img = @imagecreatefromjpeg($realCachedFile);
+ } elseif ($ext === 'png') {
+ $img = @imagecreatefrompng($realCachedFile);
+ } elseif ($ext === 'webp' && function_exists('imagecreatefromwebp')) {
+ $img = @imagecreatefromwebp($realCachedFile);
+ } else {
+ $img = false;
+ }
+
+ if ($img) {
+ if (function_exists('imagepalettetotruecolor') && !imageistruecolor($img)) {
+ imagepalettetotruecolor($img);
+ }
+ imagealphablending($img, true);
+
+ $imgW = imagesx($img);
+ $imgH = imagesy($img);
+
+ foreach ($matched_rules as $rule) {
+ $type = preg_replace('/[^a-z]/', '', $rule['type']);
+ $imagePathStr = str_replace(array('../', '..\\', ' '), '', (string)$rule['image']);
+ $imagePath = realpath(DIR_IMAGE . $imagePathStr);
+ if ($imagePath && strpos(str_replace('\\', '/', $imagePath), str_replace('\\', '/', $realImageDir)) !== 0) {
+ $imagePath = false;
+ }
+ $textVal = (string)$rule['text_val'];
+ $position = (int)$rule['position'];
+ $opacity = (int)$rule['opacity'];
+ $size_type = isset($rule['size_type']) ? $rule['size_type'] : 'original';
+ $size_percent = isset($rule['size_percent']) ? (int)$rule['size_percent'] : 20;
+ $angle = isset($rule['angle']) ? (int)$rule['angle'] : 0;
+ $corner_radius = isset($rule['corner_radius']) ? (int)$rule['corner_radius'] : 0;
+ $text_font = isset($rule['text_font']) ? (string)$rule['text_font'] : '';
+ $text_color = isset($rule['text_color']) ? (string)$rule['text_color'] : '#ffffff';
+
+ if ($type === 'image') {
+ if (!$imagePath || !is_file($imagePath)) continue;
+ }
+ if ($type === 'text' && empty($textVal)) continue;
+
+ if ($type === 'image') {
+ $wm = @imagecreatefrompng($imagePath);
+ if ($wm) {
+ if (function_exists('imagepalettetotruecolor') && !imageistruecolor($wm)) {
+ imagepalettetotruecolor($wm);
+ }
+ imagealphablending($wm, false);
+ imagesavealpha($wm, true);
+
+ $wmW = imagesx($wm);
+ $wmH = imagesy($wm);
+
+ $max_wm_w = (int)$this->config->get('module_img_opti_wm_max_width');
+ if ($max_wm_w <= 0) $max_wm_w = 800;
+ $max_wm_h = (int)$this->config->get('module_img_opti_wm_max_height');
+ if ($max_wm_h <= 0) $max_wm_h = 800;
+
+ if ($wmW > $max_wm_w || $wmH > $max_wm_h) {
+ $ratio = min($max_wm_w / $wmW, $max_wm_h / $wmH);
+ $new_wm_w = intval($wmW * $ratio);
+ $new_wm_h = intval($wmH * $ratio);
+ if ($new_wm_w > 0 && $new_wm_h > 0) {
+ $resized_wm = imagecreatetruecolor($new_wm_w, $new_wm_h);
+ imagealphablending($resized_wm, false);
+ imagesavealpha($resized_wm, true);
+ $transparent = imagecolorallocatealpha($resized_wm, 255, 255, 255, 127);
+ imagefill($resized_wm, 0, 0, $transparent);
+ imagecopyresampled($resized_wm, $wm, 0, 0, 0, 0, $new_wm_w, $new_wm_h, $wmW, $wmH);
+ imagedestroy($wm);
+ $wm = $resized_wm;
+ $wmW = $new_wm_w;
+ $wmH = $new_wm_h;
+ }
+ }
+ if ($size_type === 'percent' && $size_percent > 0) {
+ $targetW = intval(($imgW * $size_percent) / 100);
+ $targetH = intval(($wmH / $wmW) * $targetW);
+ if ($targetW > 0 && $targetH > 0) {
+ $resized_wm = imagecreatetruecolor($targetW, $targetH);
+ imagealphablending($resized_wm, false);
+ imagesavealpha($resized_wm, true);
+ $transparent = imagecolorallocatealpha($resized_wm, 255, 255, 255, 127);
+ imagefill($resized_wm, 0, 0, $transparent);
+ imagecopyresampled($resized_wm, $wm, 0, 0, 0, 0, $targetW, $targetH, $wmW, $wmH);
+ imagedestroy($wm);
+ $wm = $resized_wm;
+ $wmW = $targetW;
+ $wmH = $targetH;
+ }
+ }
+ if ($corner_radius > 0) {
+ $wm = $this->roundCornersGD($wm, $corner_radius);
+ }
+ if ($angle != 0) {
+ $gd_angle = -1 * $angle;
+ $transparent = imagecolorallocatealpha($wm, 255, 255, 255, 127);
+ $rotated = imagerotate($wm, $gd_angle, $transparent);
+ if ($rotated) {
+ imagealphablending($rotated, false);
+ imagesavealpha($rotated, true);
+ imagedestroy($wm);
+ $wm = $rotated;
+ $wmW = imagesx($wm);
+ $wmH = imagesy($wm);
+ }
+ }
+ $posX = 0; $posY = 0; $pad = 10;
+ if (in_array($position, array(1, 4, 7))) $posX = $pad;
+ elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $wmW) / 2;
+ elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $wmW - $pad;
+
+ if (in_array($position, array(1, 2, 3))) $posY = $pad;
+ elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $wmH) / 2;
+ elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $wmH - $pad;
+
+ $this->imagecopymerge_alpha($img, $wm, $posX, $posY, 0, 0, $wmW, $wmH, $opacity);
+ imagedestroy($wm);
+ }
+ } else {
+ $fontFile = $text_font;
+ if (!empty($fontFile) && !is_file($fontFile)) {
+ $base_font = basename($fontFile);
+ if (is_file(DIR_SYSTEM . 'library/font/' . $base_font)) {
+ $fontFile = DIR_SYSTEM . 'library/font/' . $base_font;
+ } elseif (is_file(DIR_SYSTEM . 'fonts/' . $base_font)) {
+ $fontFile = DIR_SYSTEM . 'fonts/' . $base_font;
+ } elseif (is_file(DIR_SYSTEM . 'font/' . $base_font)) {
+ $fontFile = DIR_SYSTEM . 'font/' . $base_font;
+ }
+ }
+ if (empty($fontFile) || !is_file($fontFile)) {
+ $fontFile = DIR_SYSTEM . 'library/font/Roboto-Regular.ttf';
+ }
+ if (!is_file($fontFile)) {
+ $open_basedir = ini_get('open_basedir');
+ $fallback_dirs = array(
+ DIR_SYSTEM . 'library/font/',
+ DIR_SYSTEM . 'fonts/',
+ DIR_SYSTEM . 'font/',
+ DIR_CATALOG . 'view/theme/default/fonts/'
+ );
+ if (DIRECTORY_SEPARATOR === '/') {
+ if (!$open_basedir) {
+ $fallback_dirs[] = '/usr/share/fonts/truetype/dejavu/';
+ $fallback_dirs[] = '/usr/share/fonts/truetype/liberation/';
+ $fallback_dirs[] = '/usr/share/fonts/truetype/freefont/';
+ }
+ } else {
+ if (!$open_basedir) {
+ $fallback_dirs[] = 'C:/Windows/Fonts/';
+ }
+ }
+ foreach ($fallback_dirs as $f_dir) {
+ if (@is_dir($f_dir)) {
+ $glob = @glob($f_dir . '*.ttf');
+ if ($glob) {
+ $fontFile = $glob[0];
+ break;
+ }
+ }
+ }
+ }
+
+ if (!is_file($fontFile)) {
+ $fontSize = max(3, intval($imgW / 50));
+ $fontWidth = imagefontwidth($fontSize) * strlen($textVal);
+ $fontHeight = imagefontheight($fontSize);
+ $posX = 0; $posY = 0; $pad = 10;
+ if (in_array($position, array(1, 4, 7))) $posX = $pad;
+ elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $fontWidth) / 2;
+ elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $fontWidth - $pad;
+
+ if (in_array($position, array(1, 2, 3))) $posY = $pad;
+ elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $fontHeight) / 2;
+ elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $fontHeight - $pad;
+
+ $alpha = intval(127 - (127 * ($opacity / 100)));
+ $color = imagecolorallocatealpha($img, 255, 255, 255, $alpha);
+ imagestring($img, $fontSize, $posX, $posY, $textVal, $color);
+ } else {
+ $fontSize = max(10, intval($imgW / 25));
+ $colorHex = $text_color;
+ list($r, $g, $b) = $this->hex2rgb($colorHex);
+ $alpha = intval(127 - (127 * ($opacity / 100)));
+ $color = imagecolorallocatealpha($img, $r, $g, $b, $alpha);
+
+ $bbox = imagettfbbox($fontSize, $angle, $fontFile, $textVal);
+ $textW = abs($bbox[4] - $bbox[0]);
+ $textH = abs($bbox[5] - $bbox[1]);
+
+ $posX = 0; $posY = 0; $pad = 10;
+ if (in_array($position, array(1, 4, 7))) $posX = $pad;
+ elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $textW) / 2;
+ elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $textW - $pad;
+
+ if (in_array($position, array(1, 2, 3))) $posY = $pad + $textH;
+ elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $textH) / 2 + $textH;
+ elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $pad;
+
+ imagettftext($img, $fontSize, $angle, $posX, $posY, $color, $fontFile, $textVal);
+ }
+ }
+ }
+
+ if ($ext === 'png' || $ext === 'webp') {
+ imagealphablending($img, false);
+ imagesavealpha($img, true);
+ }
+ if ($ext === 'jpg' || $ext === 'jpeg') { imagejpeg($img, $realCachedFile, 90); }
+ elseif ($ext === 'png') { imagepng($img, $realCachedFile, 9); }
+ elseif ($ext === 'webp') { imagewebp($img, $realCachedFile, 90); }
+ imagedestroy($img);
+ }
+ }
+ return true;
+ }
+
+private function roundCornersGD($im, $radius) {
+ $w = imagesx($im);
+ $h = imagesy($im);
+ $new_im = imagecreatetruecolor($w, $h);
+ imagealphablending($new_im, false);
+ imagesavealpha($new_im, true);
+ $transparent = imagecolorallocatealpha($new_im, 255, 255, 255, 127);
+ imagefill($new_im, 0, 0, $transparent);
+ for ($x = 0; $x < $w; $x++) {
+ for ($y = 0; $y < $h; $y++) {
+ $rgba = imagecolorat($im, $x, $y);
+ $in_corner = false;
+ if ($x < $radius && $y < $radius) {
+ if (pow($radius - $x, 2) + pow($radius - $y, 2) > pow($radius, 2)) $in_corner = true;
+ } elseif ($x >= $w - $radius && $y < $radius) {
+ if (pow($x - ($w - $radius - 1), 2) + pow($radius - $y, 2) > pow($radius, 2)) $in_corner = true;
+ } elseif ($x < $radius && $y >= $h - $radius) {
+ if (pow($radius - $x, 2) + pow($y - ($h - $radius - 1), 2) > pow($radius, 2)) $in_corner = true;
+ } elseif ($x >= $w - $radius && $y >= $h - $radius) {
+ if (pow($x - ($w - $radius - 1), 2) + pow($y - ($h - $radius - 1), 2) > pow($radius, 2)) $in_corner = true;
+ }
+ if ($in_corner) {
+ imagesetpixel($new_im, $x, $y, $transparent);
+ } else {
+ imagesetpixel($new_im, $x, $y, $rgba);
+ }
+ }
+ }
+ imagedestroy($im);
+ return $new_im;
+ }
+
+ private function hex2rgb($hex) {
+ $hex = str_replace('#', '', $hex);
+ if (strlen($hex) == 3) {
+ $r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1));
+ $g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1));
+ $b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1));
+ } else {
+ $r = hexdec(substr($hex, 0, 2));
+ $g = hexdec(substr($hex, 2, 2));
+ $b = hexdec(substr($hex, 4, 2));
+ }
+ return array($r, $g, $b);
+ }
+
+ private function imagecopymerge_alpha($dst_im, $src_im, $dst_x, $dst_y, $src_x, $src_y, $src_w, $src_h, $pct) {
+ $opacity = $pct / 100;
+ for ($x = 0; $x < $src_w; $x++) {
+ for ($y = 0; $y < $src_h; $y++) {
+ $src_color_idx = imagecolorat($src_im, $src_x + $x, $src_y + $y);
+ $src_color = imagecolorsforindex($src_im, $src_color_idx);
+ $src_alpha = (127 - $src_color['alpha']) / 127;
+ $src_alpha = $src_alpha * $opacity;
+ if ($src_alpha <= 0) {
+ continue;
+ }
+ $dst_x_pos = $dst_x + $x;
+ $dst_y_pos = $dst_y + $y;
+ if ($dst_x_pos < 0 || $dst_x_pos >= imagesx($dst_im) || $dst_y_pos < 0 || $dst_y_pos >= imagesy($dst_im)) {
+ continue;
+ }
+ $dst_color_idx = imagecolorat($dst_im, $dst_x_pos, $dst_y_pos);
+ $dst_color = imagecolorsforindex($dst_im, $dst_color_idx);
+ $dst_alpha = (127 - $dst_color['alpha']) / 127;
+ $out_alpha = $src_alpha + $dst_alpha * (1 - $src_alpha);
+ if ($out_alpha > 0) {
+ $out_r = ($src_color['red'] * $src_alpha + $dst_color['red'] * $dst_alpha * (1 - $src_alpha)) / $out_alpha;
+ $out_g = ($src_color['green'] * $src_alpha + $dst_color['green'] * $dst_alpha * (1 - $src_alpha)) / $out_alpha;
+ $out_b = ($src_color['blue'] * $src_alpha + $dst_color['blue'] * $dst_alpha * (1 - $src_alpha)) / $out_alpha;
+ $out_alpha_val = 127 - round($out_alpha * 127);
+ } else {
+ $out_r = 0;
+ $out_g = 0;
+ $out_b = 0;
+ $out_alpha_val = 127;
+ }
+ $out_r = max(0, min(255, round($out_r)));
+ $out_g = max(0, min(255, round($out_g)));
+ $out_b = max(0, min(255, round($out_b)));
+ $out_alpha_val = max(0, min(127, $out_alpha_val));
+ $new_color = imagecolorallocatealpha($dst_im, $out_r, $out_g, $out_b, $out_alpha_val);
+ if ($new_color === false) {
+ $new_color = imagecolorresolvealpha($dst_im, $out_r, $out_g, $out_b, $out_alpha_val);
+ }
+ imagesetpixel($dst_im, $dst_x_pos, $dst_y_pos, $new_color);
+ }
+ }
+ }
+
+ public function scan_small_photos($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ $minW = isset($data['module_img_opti_min_width']) ? (int)$data['module_img_opti_min_width'] : 300;
+ $minH = isset($data['module_img_opti_min_height']) ? (int)$data['module_img_opti_min_height'] : 300;
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && in_array(strtolower($file->getExtension()), array('jpg', 'jpeg', 'png', 'webp'))) {
+ $realPath = realpath($file->getPathname());
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) !== 0) continue;
+ $size = @getimagesize($realPath);
+ if ($size && ($size[0] < $minW || $size[1] < $minH)) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $rows[] = array(
+ 'id' => 'small_photo-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $this->language->get('text_grid_too_small') . ': ' . $size[0] . 'x' . $size[1] . ' px',
+ 'after' => $this->language->get('text_grid_small_photo_after'),
+ 'actions' => array(
+ 'delete' => $this->language->get('text_action_delete'),
+ 'placeholder' => $this->language->get('text_action_placeholder'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'ignore',
+ 'meta' => array(
+ 'path' => $relPath
+ )
+ );
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_small_photos_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => true,
+ 'description' => sprintf($this->language->get('text_description_small_photos'), $minW, $minH)
+ );
+ }
+
+ public function fix_small_photos($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $cache_file = DIR_CACHE . 'img_opti_small_photos_grid.json';
+ if (!file_exists($cache_file)) {
+ return array('error' => $this->language->get('error_no_scan_data'));
+ }
+
+ $grid = json_decode(file_get_contents($cache_file), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $ph_product_path = DIR_IMAGE . (string)$this->config->get('module_img_opti_ph_product');
+ if (!is_file($ph_product_path)) {
+ $ph_product_path = DIR_IMAGE . 'no_image.png';
+ }
+
+ foreach ($selected as $row_id => $act) {
+ if ($act === 'ignore' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $relPath = $row['meta']['path'];
+ $fullPath = DIR_IMAGE . $relPath;
+
+ if ($act === 'delete') {
+ $this->quarantineFile($relPath, 'small_photos');
+ } elseif ($act === 'placeholder') {
+ $this->quarantineFile($relPath, 'small_photos');
+ if (is_file($ph_product_path)) {
+ @copy($ph_product_path, $fullPath);
+ }
+ }
+ }
+ @unlink($cache_file);
+ return array('success' => true);
+ }
+
+ public function scan_html_broken($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $realImageDir = realpath(DIR_IMAGE);
+ $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '');
+
+ $tables = array(
+ 'product_description' => array('id' => 'product_id', 'field' => 'description', 'name_field' => 'name', 'route' => 'catalog/product/edit', 'param' => 'product_id', 'type' => 'Product'),
+ 'category_description' => array('id' => 'category_id', 'field' => 'description', 'name_field' => 'name', 'route' => 'catalog/category/edit', 'param' => 'category_id', 'type' => 'Category'),
+ 'information_description' => array('id' => 'information_id', 'field' => 'description', 'name_field' => 'title', 'route' => 'catalog/information/edit', 'param' => 'information_id', 'type' => 'Information')
+ );
+
+ foreach ($tables as $table => $cfg) {
+ if ($table === 'product_description') {
+ $query = $this->db->query("SELECT pd.`" . $cfg['id'] . "`, pd.`" . $cfg['field'] . "`, pd.`" . $cfg['name_field'] . "`, p.model FROM `" . DB_PREFIX . $table . "` pd LEFT JOIN `" . DB_PREFIX . "product` p ON (pd.product_id = p.product_id) WHERE pd.language_id = '" . (int)$this->config->get('config_language_id') . "'");
+ } else {
+ $query = $this->db->query("SELECT `" . $cfg['id'] . "`, `" . $cfg['field'] . "`, `" . $cfg['name_field'] . "` FROM `" . DB_PREFIX . $table . "` WHERE language_id = '" . (int)$this->config->get('config_language_id') . "'");
+ }
+ foreach ($query->rows as $row) {
+ $desc = html_entity_decode($row[$cfg['field']], ENT_QUOTES, 'UTF-8');
+ if (preg_match_all('/]+src=(?:\"|\')([^"\']+)(?:\"|\')[^>]*>/i', $desc, $matches)) {
+ foreach ($matches[1] as $src) {
+ if (strpos($src, 'image/catalog') !== false) {
+ $rel_path = preg_replace('/^.*image\/(catalog\/.*)$/i', '$1', $src);
+ $rel_path = urldecode($rel_path);
+ $cleanRelPath = str_replace(array('../', '..\\', "\0"), '', $rel_path);
+ $fullPath = realpath(DIR_IMAGE . $cleanRelPath);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) {
+ $admin_link = $this->url->link($cfg['route'], 'user_token=' . $this->session->data['user_token'] . '&' . $cfg['param'] . '=' . (int)$row[$cfg['id']], true);
+ $search_links = $this->getSearchLinks($row[$cfg['name_field']], ($table === 'product_description' ? $row['model'] : ''));
+ $rows[] = array(
+ 'id' => 'html_broken-' . $table . '-' . (int)$row[$cfg['id']] . '-' . md5($cleanRelPath),
+ 'preview' => '',
+ 'name' => '' . $cfg['type'] . ' ' . htmlspecialchars($row[$cfg['name_field']] ? $row[$cfg['name_field']] : $cfg['type'] . ' ID ' . (int)$row[$cfg['id']], ENT_QUOTES, 'UTF-8') . '' . $search_links,
+ 'before' => $this->language->get('text_grid_missing_html') . ': ' . htmlspecialchars($cleanRelPath, ENT_QUOTES, 'UTF-8'),
+ 'after' => $this->language->get('text_grid_html_after'),
+ 'actions' => array(
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'ignore'
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => false,
+ 'description' => $this->language->get('text_description_html_broken')
+ );
+ }
+
+ public function scan_folder_tree($data) {
+ $this->loadLanguageSafe();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $sizes = array();
+ $img_sizes = array();
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $realPath = realpath($file->getPathname());
+ if (!$realPath || strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) !== 0) continue;
+ $folderPath = $this->mb_dirname($realPath);
+ $rel = ltrim(str_replace(str_replace('\\', '/', $realImageDir), '', str_replace('\\', '/', $folderPath)), '/');
+ if (!isset($sizes[$rel])) $sizes[$rel] = 0;
+ $sizes[$rel] += filesize($realPath);
+
+ $ext = strtolower($file->getExtension());
+ if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp'))) {
+ if (!isset($img_sizes[$rel])) $img_sizes[$rel] = 0;
+ $img_sizes[$rel] += filesize($realPath);
+ }
+ }
+ }
+ arsort($sizes);
+
+ $rows = array();
+ foreach ($sizes as $folder => $size) {
+ $img_sz = isset($img_sizes[$folder]) ? $img_sizes[$folder] : 0;
+ $estimated_size = ($size - $img_sz) + ($img_sz * 0.65);
+ $after_text = $this->formatBytes($estimated_size) . ' (~' . $this->language->get('text_estimated') . ')';
+
+ $rows[] = array(
+ 'id' => 'folder-' . md5($folder),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($folder, ENT_QUOTES, 'UTF-8') . '',
+ 'before' => $this->formatBytes($size),
+ 'after' => $after_text,
+ 'actions' => array(
+ 'ignore' => $this->language->get('text_action_ignore'),
+ 'compress' => $this->language->get('text_action_compress'),
+ 'archive' => $this->language->get('text_action_archive'),
+ 'delete' => $this->language->get('text_action_delete'),
+ 'archive_delete' => $this->language->get('text_action_archive_delete')
+ ),
+ 'selected_action' => 'ignore',
+ 'meta' => array(
+ 'path' => $folder
+ )
+ );
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_folder_tree_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => false,
+ 'quarantine_available' => false,
+ 'description' => $this->language->get('text_description_folder_tree')
+ );
+ }
+
+ public function fix_folder_tree($data) {
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $cache_file = DIR_CACHE . 'img_opti_folder_tree_grid.json';
+ if (!file_exists($cache_file)) {
+ return array('error' => $this->language->get('error_no_scan_data'));
+ }
+
+ $grid = json_decode(file_get_contents($cache_file), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $backup_dir = DIR_IMAGE . 'catalog_trash/backup/';
+ if (!is_dir($backup_dir)) {
+ @mkdir($backup_dir, 0755, true);
+ }
+ $this->registerModuleFolder('catalog_trash/backup');
+
+ foreach ($selected as $row_id => $act) {
+ if ($act === 'ignore' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $relPath = $row['meta']['path'];
+ $fullPath = realpath(DIR_IMAGE . $relPath);
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($fullPath)) continue;
+
+ if ($relPath === 'catalog' || $relPath === 'catalog/') continue;
+
+ if ($act === 'archive' || $act === 'archive_delete') {
+ $zipName = 'archive_folder_' . str_replace('/', '_', trim($relPath, '/')) . '_' . date('Ymd_His') . '.zip';
+ $zipPath = $backup_dir . $zipName;
+ $this->zipDirectory($fullPath, $zipPath);
+ }
+
+ if ($act === 'delete' || $act === 'archive_delete') {
+ $this->clearDbLinksForFolder($fullPath);
+ $this->cleanDir($fullPath);
+ }
+
+ if ($act === 'compress') {
+ $maxWidth = (int)$this->config->get('module_img_opti_max_width') ?: 1600;
+ $maxHeight = (int)$this->config->get('module_img_opti_max_height') ?: 1600;
+ $jpgQ = (int)$this->config->get('module_img_opti_jpg_quality') ?: 75;
+ $pngQ = (int)$this->config->get('module_img_opti_png_quality') ?: 7;
+ $webpQ = (int)$this->config->get('module_img_opti_webp_quality') ?: 80;
+ $engine = $this->config->get('module_img_opti_engine') === 'imagick' && extension_loaded('imagick') ? 'imagick' : 'gd';
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($fullPath, RecursiveDirectoryIterator::SKIP_DOTS));
+ foreach ($iterator as $file) {
+ $limit = $this->getMemoryLimitInBytesHelper();
+ if ($limit > 0) {
+ if (memory_get_usage(true) > $limit * 0.85) {
+ break;
+ }
+ }
+ if ($file->isFile()) {
+ $ext = strtolower($file->getExtension());
+ if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp'))) {
+ $filePath = $file->getPathname();
+ $tempPath = $filePath . '.tmp';
+ if ($this->optimizeImageHelper($filePath, $tempPath, $maxWidth, $maxHeight, $jpgQ, $pngQ, $webpQ, $engine)) {
+ if (file_exists($tempPath) && filesize($tempPath) > 0 && filesize($tempPath) < filesize($filePath)) {
+ @unlink($filePath);
+ @rename($tempPath, $filePath);
+ if (file_exists($tempPath)) {
+ @unlink($tempPath);
+ }
+ } else {
+ @unlink($tempPath);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ @unlink($cache_file);
+ return array('success' => true);
+ }
+
+ private function clearDbLinksForFolder($dir) {
+ $realDir = realpath($dir);
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$realDir || strpos(str_replace('\\', '/', $realDir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($realDir)) return;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($realDir, RecursiveDirectoryIterator::SKIP_DOTS));
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $filePath = str_replace('\\', '/', $file->getPathname());
+ $relPath = ltrim(str_replace(str_replace('\\', '/', $realImageDir), '', $filePath), '/');
+ $escaped = $this->db->escape($relPath);
+
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '' WHERE image = '" . $escaped . "'");
+ $this->db->query("DELETE FROM `" . DB_PREFIX . "product_image` WHERE image = '" . $escaped . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '' WHERE image = '" . $escaped . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '' WHERE image = '" . $escaped . "'");
+
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'");
+ if ($table_query->num_rows) {
+ $this->db->query("DELETE FROM `" . DB_PREFIX . "banner_image` WHERE image = '" . $escaped . "'");
+ }
+
+ $blog_tables = array(
+ 'oct_blog_article' => 'image',
+ 'simple_blog_article' => 'image',
+ 'newsblog_article' => 'image',
+ 'information' => 'image'
+ );
+ foreach ($blog_tables as $table => $column) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $table . "'");
+ if ($table_query->num_rows) {
+ $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $table . "` LIKE '" . $column . "'");
+ if ($column_query->num_rows) {
+ try {
+ $this->db->query("UPDATE `" . DB_PREFIX . $table . "` SET `" . $column . "` = '' WHERE `" . $column . "` = '" . $escaped . "'");
+ } catch (Exception $e) {}
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private function zipDirectory($source, $destination) {
+ if (!class_exists('ZipArchive')) {
+ return false;
+ }
+ $zip = new ZipArchive();
+ if (!$zip->open($destination, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
+ return false;
+ }
+ $source = str_replace('\\', '/', realpath($source));
+ if (is_dir($source) === true) {
+ $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($source), RecursiveIteratorIterator::LEAVES_ONLY);
+ foreach ($files as $name => $file) {
+ if (!$file->isDir()) {
+ $filePath = $file->getRealPath();
+ $relativePath = substr($filePath, strlen($source) + 1);
+ $zip->addFile($filePath, $relativePath);
+ }
+ }
+ } elseif (is_file($source) === true) {
+ $zip->addFromString($this->mb_basename($source), file_get_contents($source));
+ }
+ return $zip->close();
+ }
+
+
+ private function transliterate($text) {
+ $cyr = array('а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п','р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я','А','Б','В','Г','Д','Е','Ё','Ж','З','И','Й','К','Л','М','Н','О','П','Р','С','Т','У','Ф','Х','Ц','Ч','Ш','Щ','Ъ','Ы','Ь','Э','Ю','Я','є','і','ї','ґ','Є','І','Ї','Ґ','’',' ','!','@','#','$','%','^','&','*','(',')','+','=','[',']','{','}','|','\\',':',';','"',"'",'<','>','?','/','~','`',',');
+ $lat = array('a','b','v','g','d','e','e','zh','z','i','y','k','l','m','n','o','p','r','s','t','u','f','h','ts','ch','sh','sch','','y','','e','yu','ya','A','B','V','G','D','E','E','Zh','Z','I','Y','K','L','M','N','O','P','R','S','T','U','F','H','Ts','Ch','Sh','Sch','','Y','','E','Yu','Ya','ye','i','yi','g','Ye','I','Yi','G','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_','_');
+ $text = str_replace($cyr, $lat, (string)$text);
+ $text = preg_replace('/_+/', '_', $text);
+ return strtolower(trim($text, '_'));
+ }
+
+ private function formatBytes($bytes) {
+ if ($bytes >= 1048576) return round($bytes / 1048576, 2) . ' MB';
+ if ($bytes >= 1024) return round($bytes / 1024, 0) . ' KB';
+ return $bytes . ' B';
+ }
+
+ public function generateCompareUrls($tool, $id) {
+ $this->loadLanguageSafe();
+
+ $original_path = '';
+ $type = '';
+
+ if ($tool === 'png_jpg') {
+ $grid_file = DIR_CACHE . 'img_opti_png_jpg_grid.json';
+ if (!file_exists($grid_file)) {
+ return array('error' => $this->language->get('error_no_scan_data'));
+ }
+ $grid = json_decode(file_get_contents($grid_file), true);
+ $found = false;
+ foreach ($grid as $row) {
+ if ($row['id'] === $id && isset($row['meta']['path'])) {
+ $original_path = $row['meta']['path'];
+ $type = 'png_jpg';
+ $found = true;
+ break;
+ }
+ }
+ if (!$found) {
+ return array('error' => $this->language->get('error_row_not_found'));
+ }
+ } elseif ($tool === 'watermark') {
+ if (is_file(DIR_IMAGE . $id)) {
+ $original_path = $id;
+ $type = 'watermark';
+ }
+ } elseif ($tool === 'optimization' || $tool === 'compress') {
+ if (is_file(DIR_IMAGE . $id)) {
+ $original_path = $id;
+ $type = 'compress';
+ }
+ }
+
+ if (empty($original_path) || !is_file(DIR_IMAGE . $original_path)) {
+ $clean_id = str_replace(array('../', '..\\', "\0"), '', $id);
+ if (is_file(DIR_IMAGE . $clean_id)) {
+ $original_path = $clean_id;
+ if (empty($type)) {
+ $type = 'compress';
+ }
+ } else {
+ return array('error' => $this->language->get('error_file_not_found') ?: 'File not found');
+ }
+ }
+
+ $ext = strtolower($this->mb_pathinfo($original_path, PATHINFO_EXTENSION));
+ $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : '');
+
+ $compare_dir = DIR_IMAGE . 'cache/compare/';
+ if (!is_dir($compare_dir)) {
+ @mkdir($compare_dir, 0755, true);
+ }
+
+ $file_hash = md5($original_path . time());
+ $temp_before_name = 'before_' . $file_hash . '.' . $ext;
+ $temp_before_path = $compare_dir . $temp_before_name;
+
+ @copy(DIR_IMAGE . $original_path, $temp_before_path);
+
+ $temp_after_name = 'after_' . $file_hash . '.';
+ if ($type === 'png_jpg') {
+ $temp_after_name .= 'jpg';
+ } else {
+ $temp_after_name .= $ext;
+ }
+ $temp_after_path = $compare_dir . $temp_after_name;
+
+ if ($type === 'png_jpg') {
+ $this->convertPngToJpgHelper(DIR_IMAGE . $original_path, $temp_after_path);
+ } elseif ($type === 'watermark') {
+ @copy(DIR_IMAGE . $original_path, $temp_after_path);
+ $this->applyDynamicWatermark($temp_after_path, $original_path);
+ } else {
+ $maxWidth = 1600;
+ $maxHeight = 1600;
+ $jpgQ = (int)$this->config->get('module_img_opti_jpg_quality') ?: 75;
+ $pngQ = (int)$this->config->get('module_img_opti_png_quality') ?: 7;
+ $webpQ = (int)$this->config->get('module_img_opti_webp_quality') ?: 80;
+ $engine = $this->config->get('module_img_opti_engine') === 'imagick' && extension_loaded('imagick') ? 'imagick' : 'gd';
+
+ if (isset($this->request->post['max_width'])) $maxWidth = (int)$this->request->post['max_width'];
+ if (isset($this->request->post['max_height'])) $maxHeight = (int)$this->request->post['max_height'];
+ if (isset($this->request->post['jpg_quality'])) $jpgQ = (int)$this->request->post['jpg_quality'];
+ if (isset($this->request->post['png_quality'])) $pngQ = (int)$this->request->post['png_quality'];
+ if (isset($this->request->post['webp_quality'])) $webpQ = (int)$this->request->post['webp_quality'];
+
+ $this->optimizeImageHelper(DIR_IMAGE . $original_path, $temp_after_path, $maxWidth, $maxHeight, $jpgQ, $pngQ, $webpQ, $engine);
+ }
+
+ $url_before = $catalog_url . 'image/cache/compare/' . $temp_before_name;
+ $url_after = $catalog_url . 'image/cache/compare/' . $temp_after_name;
+
+ $size_before = filesize(DIR_IMAGE . $original_path);
+ $size_after = file_exists($temp_after_path) ? filesize($temp_after_path) : 0;
+
+ return array(
+ 'success' => true,
+ 'before' => $url_before,
+ 'after' => $url_after,
+ 'size_before' => $this->formatBytes($size_before),
+ 'size_after' => $this->formatBytes($size_after),
+ 'saving' => ($size_before > 0) ? round((($size_before - $size_after) / $size_before) * 100, 1) : 0
+ );
+ }
+
+ public function convertPngToJpgHelper($src, $dest) {
+ $img = @imagecreatefrompng($src);
+ if ($img) {
+ if (function_exists('imagepalettetotruecolor') && !imageistruecolor($img)) {
+ imagepalettetotruecolor($img);
+ }
+ imagealphablending($img, true);
+ $bg = imagecreatetruecolor(imagesx($img), imagesy($img));
+ imagefill($bg, 0, 0, imagecolorallocate($bg, 255, 255, 255));
+ imagecopy($bg, $img, 0, 0, 0, 0, imagesx($img), imagesy($img));
+ $res = imagejpeg($bg, $dest, 85);
+ imagedestroy($img);
+ imagedestroy($bg);
+ return $res;
+ }
+ return false;
+ }
+
+ public function optimizeImageHelper($src, $dest, $maxWidth, $maxHeight, $jpgQuality, $pngCompression, $webpQuality, $engine = 'gd') {
+ if ($engine == 'imagick' && extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($src);
+ $img->transformImageColorspace(Imagick::COLORSPACE_SRGB);
+ $width = $img->getImageWidth();
+ $height = $img->getImageHeight();
+ $ext = strtolower($this->mb_pathinfo($src, PATHINFO_EXTENSION));
+ if (($maxWidth > 0 && $width > $maxWidth) || ($maxHeight > 0 && $height > $maxHeight)) {
+ $targetW = $maxWidth;
+ $targetH = $maxHeight;
+ if ($targetW == 0) {
+ $targetW = (int)round($width * ($targetH / $height));
+ }
+ if ($targetH == 0) {
+ $targetH = (int)round($height * ($targetW / $width));
+ }
+ $img->resizeImage($targetW, $targetH, Imagick::FILTER_LANCZOS, 1, true);
+ }
+ $img->stripImage();
+ if ($ext === 'png') {
+ $img->setImageFormat('png');
+ $img->setCompressionQuality($pngCompression * 10);
+ } elseif ($ext === 'webp') {
+ $img->setImageFormat('webp');
+ $img->setCompressionQuality($webpQuality);
+ } else {
+ $img->setImageFormat('jpeg');
+ $img->setImageCompression(Imagick::COMPRESSION_JPEG);
+ $img->setCompressionQuality($jpgQuality);
+ }
+ return $img->writeImage($dest);
+ } catch (Exception $e) {
+ $engine = 'gd';
+ }
+ }
+
+ if ($engine == 'gd') {
+ $img = @imagecreatefromstring(file_get_contents($src));
+ if (!$img) return false;
+ if (function_exists('imagepalettetotruecolor') && !imageistruecolor($img)) {
+ imagepalettetotruecolor($img);
+ }
+ $width = imagesx($img);
+ $height = imagesy($img);
+ $ext = strtolower($this->mb_pathinfo($src, PATHINFO_EXTENSION));
+ if ($ext === 'png' || $ext === 'webp') {
+ imagealphablending($img, false);
+ imagesavealpha($img, true);
+ }
+ if (($maxWidth > 0 && $width > $maxWidth) || ($maxHeight > 0 && $height > $maxHeight)) {
+ $ratioX = ($maxWidth > 0) ? ($maxWidth / $width) : 1;
+ $ratioY = ($maxHeight > 0) ? ($maxHeight / $height) : 1;
+ $ratio = min($ratioX, $ratioY);
+ $newW = (int)($width * $ratio);
+ $newH = (int)($height * $ratio);
+ $newImg = imagecreatetruecolor($newW, $newH);
+ if ($ext === 'png' || $ext === 'webp') {
+ imagealphablending($newImg, false);
+ imagesavealpha($newImg, true);
+ $transparent = imagecolorallocatealpha($newImg, 255, 255, 255, 127);
+ imagefill($newImg, 0, 0, $transparent);
+ } else {
+ $white = imagecolorallocate($newImg, 255, 255, 255);
+ imagefill($newImg, 0, 0, $white);
+ }
+ imagecopyresampled($newImg, $img, 0, 0, 0, 0, $newW, $newH, $width, $height);
+ imagedestroy($img);
+ $img = $newImg;
+ }
+ $res = false;
+ if ($ext === 'png') {
+ $res = imagepng($img, $dest, $pngCompression);
+ } elseif ($ext === 'webp' && function_exists('imagewebp')) {
+ $res = imagewebp($img, $dest, $webpQuality);
+ } else {
+ $res = imagejpeg($img, $dest, $jpgQuality);
+ }
+ imagedestroy($img);
+ return $res;
+ }
+ return false;
+ }
+
+ private function safe_utf8($str) {
+ $str = (string)$str;
+ if (mb_check_encoding($str, 'UTF-8')) {
+ return $str;
+ }
+ $converted = @mb_convert_encoding($str, 'UTF-8', 'Windows-1251');
+ return $converted ? $converted : $str;
+ }
+
+ public function scan_sanitizer($data = array()) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $limit = isset($data['module_img_opti_sanitizer_limit']) ? (int)$data['module_img_opti_sanitizer_limit'] : 100;
+ if ($limit <= 0) $limit = 100;
+
+ $sz_translit = isset($data['module_img_opti_sz_translit']) ? (int)$data['module_img_opti_sz_translit'] : 1;
+ $sz_spaces = isset($data['module_img_opti_sz_spaces']) ? (int)$data['module_img_opti_sz_spaces'] : 1;
+ $sz_special = isset($data['module_img_opti_sz_special']) ? (int)$data['module_img_opti_sz_special'] : 1;
+ $sz_lowercase_name = isset($data['module_img_opti_sz_lowercase_name']) ? (int)$data['module_img_opti_sz_lowercase_name'] : 1;
+ $sz_lowercase_ext = isset($data['module_img_opti_sz_lowercase_ext']) ? (int)$data['module_img_opti_sz_lowercase_ext'] : 1;
+ $sz_normalize_ext = isset($data['module_img_opti_sz_normalize_ext']) ? (int)$data['module_img_opti_sz_normalize_ext'] : 1;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ $assigned_paths = array();
+ foreach ($iterator as $file) {
+ if ($file->isFile()) {
+ $realPath = realpath($file->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $filename = $this->mb_basename($relPath);
+ $proposedRel = $this->sanitizeRelativePath($relPath, $sz_translit, $sz_spaces, $sz_special, $sz_lowercase_name, $sz_lowercase_ext, $sz_normalize_ext);
+
+ if ($relPath !== $proposedRel) {
+ $proposedFullPath = DIR_IMAGE . $proposedRel;
+ if (file_exists($proposedFullPath) || isset($assigned_paths[$proposedRel]) || $this->isPathUsedInDb($proposedRel)) {
+ $ext = $this->mb_pathinfo($proposedRel, PATHINFO_EXTENSION);
+ $filename_no_ext = $this->mb_pathinfo($proposedRel, PATHINFO_FILENAME);
+ $dirname = $this->mb_pathinfo($proposedRel, PATHINFO_DIRNAME);
+ $dir_prefix = ($dirname === '.' || $dirname === '') ? '' : $dirname . '/';
+
+ $counter = 1;
+ while (true) {
+ $candidate = $dir_prefix . $filename_no_ext . '_' . $counter . ($ext !== '' ? '.' . $ext : '');
+ if (!file_exists(DIR_IMAGE . $candidate) && !isset($assigned_paths[$candidate]) && !$this->isPathUsedInDb($candidate)) {
+ $proposedRel = $candidate;
+ break;
+ }
+ $counter++;
+ }
+ }
+
+ $assigned_paths[$proposedRel] = true;
+
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $rows[] = array(
+ 'id' => 'sanitizer-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $filename,
+ 'after' => $this->mb_basename($proposedRel),
+ 'actions' => array(
+ 'rename' => $this->language->get('text_action_apply_changes'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'rename',
+ 'meta' => array(
+ 'path' => $relPath,
+ 'new_path' => $proposedRel
+ )
+ );
+ if (count($rows) >= $limit) {
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_sanitizer_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => true,
+ 'description' => $this->language->get('text_description_sanitizer')
+ );
+ }
+
+ public function fix_sanitizer($data) {
+ $this->backupCatalogTables('sanitizer');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_sanitizer_grid.json'), true);
+ $rows_map = array();
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'rename' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $cleanBadFile = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $cleanBadFile);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) continue;
+
+ $newRel = (string)$row['meta']['new_path'];
+ $newFullPathStr = DIR_IMAGE . $newRel;
+ $newDirStr = $this->mb_dirname($newFullPathStr);
+ if (!is_dir($newDirStr)) {
+ @mkdir($newDirStr, 0755, true);
+ }
+ $realNewDir = realpath($newDirStr);
+ if (!$realNewDir || strpos(str_replace('\\', '/', $realNewDir), str_replace('\\', '/', $realImageDir)) !== 0) continue;
+ $newFullPath = $realNewDir . DIRECTORY_SEPARATOR . $this->mb_basename($newFullPathStr);
+
+ if (@copy($fullPath, $newFullPath)) {
+ $dbOld = $this->db->escape($cleanBadFile);
+ $dbNew = $this->db->escape($newRel);
+
+ $this->db->query("UPDATE `" . DB_PREFIX . "product` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "product_image` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "category` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ $this->db->query("UPDATE `" . DB_PREFIX . "manufacturer` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'");
+ if ($table_query->num_rows) {
+ $this->db->query("UPDATE `" . DB_PREFIX . "banner_image` SET image = '" . $dbNew . "' WHERE image = '" . $dbOld . "'");
+ }
+
+ $blog_tables = array(
+ 'oct_blog_article' => 'image',
+ 'simple_blog_article' => 'image',
+ 'newsblog_article' => 'image',
+ 'information' => 'image'
+ );
+ foreach ($blog_tables as $table => $column) {
+ $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $table . "'");
+ if ($table_query->num_rows) {
+ $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $table . "` LIKE '" . $column . "'");
+ if ($column_query->num_rows) {
+ try {
+ $this->db->query("UPDATE `" . DB_PREFIX . $table . "` SET `" . $column . "` = '" . $dbNew . "' WHERE `" . $column . "` = '" . $dbOld . "'");
+ } catch (Exception $e) {}
+ }
+ }
+ }
+
+ $this->quarantineFile($cleanBadFile, 'sanitizer');
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_sanitizer_grid.json');
+ return array('success' => true);
+ }
+
+ private function sanitizeRelativePath($relPath, $sz_translit, $sz_spaces, $sz_special, $sz_lowercase_name, $sz_lowercase_ext, $sz_normalize_ext) {
+ $parts = explode('/', str_replace('\\', '/', $relPath));
+ $newParts = array();
+ for ($i = 0; $i < count($parts) - 1; $i++) {
+ $part = $parts[$i];
+ if ($part === 'catalog' || $part === '') {
+ $newParts[] = $part;
+ } else {
+ $newParts[] = $this->sanitizePathSegment($part, $sz_translit, $sz_spaces, $sz_special, $sz_lowercase_name);
+ }
+ }
+ $filename = end($parts);
+ if ($filename !== '') {
+ $ext = $this->mb_pathinfo($filename, PATHINFO_EXTENSION);
+ $nameOnly = $this->mb_pathinfo($filename, PATHINFO_FILENAME);
+ $newName = $this->sanitizePathSegment($nameOnly, $sz_translit, $sz_spaces, $sz_special, $sz_lowercase_name);
+ $newExt = $ext;
+ if ($sz_lowercase_ext) {
+ $newExt = function_exists('mb_strtolower') ? mb_strtolower($newExt, 'UTF-8') : strtolower($newExt);
+ }
+ if ($sz_normalize_ext) {
+ if (strtolower($newExt) === 'jpeg') {
+ $newExt = 'jpg';
+ }
+ }
+ $cleanName = $newName . ($newExt !== '' ? '.' . $newExt : '');
+ $newParts[] = $cleanName;
+ }
+ return implode('/', $newParts);
+ }
+
+ private function sanitizePathSegment($segment, $sz_translit, $sz_spaces, $sz_special, $sz_lowercase) {
+ $newSegment = $segment;
+ if ($sz_translit) {
+ $newSegment = $this->transliterate($newSegment);
+ }
+ if ($sz_spaces) {
+ $newSegment = str_replace(' ', '_', $newSegment);
+ }
+ if ($sz_special) {
+ $newSegment = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', $newSegment);
+ }
+ if ($sz_lowercase) {
+ $newSegment = function_exists('mb_strtolower') ? mb_strtolower($newSegment, 'UTF-8') : strtolower($newSegment);
+ }
+ $newSegment = preg_replace('/_+/', '_', $newSegment);
+ $newSegment = preg_replace('/\-+/', '-', $newSegment);
+ $newSegment = trim($newSegment, '_-');
+
+ if ($newSegment === '') {
+ $fallback = $this->transliterate($segment);
+ if ($sz_special) {
+ $fallback = preg_replace('/[^a-zA-Z0-9_\-\.]/', '', $fallback);
+ }
+ if ($sz_lowercase) {
+ $fallback = function_exists('mb_strtolower') ? mb_strtolower($fallback, 'UTF-8') : strtolower($fallback);
+ }
+ $fallback = trim(preg_replace('/_+/', '_', str_replace(' ', '_', $fallback)), '_-');
+ if ($fallback !== '') {
+ $newSegment = $fallback;
+ } else {
+ $newSegment = 'img_' . substr(md5($segment), 0, 8);
+ }
+ }
+ return $newSegment;
+ }
+
+ private function translate($key, $default = '') {
+ if (isset($this->translations[$key])) {
+ return $this->translations[$key];
+ }
+ return $default ? $default : $key;
+ }
+
+ private function loadLanguageSafe() {
+ $data = array();
+ $en_paths = array(
+ DIR_LANGUAGE . 'en-gb/extension/module/img_opti.php',
+ DIR_LANGUAGE . 'english/extension/module/img_opti.php'
+ );
+ foreach ($en_paths as $path) {
+ if (is_file($path)) {
+ $_ = array();
+ require($path);
+ $data = array_merge($data, $_);
+ break;
+ }
+ }
+ $lang = $this->config->get('config_admin_language');
+ if (!$lang) {
+ $lang = $this->config->get('config_language');
+ }
+ if (isset($this->session->data['language'])) {
+ $lang = $this->session->data['language'];
+ }
+ if (!$lang) {
+ $lang = 'en-gb';
+ }
+ if ($lang && $lang !== 'en-gb' && $lang !== 'english') {
+ $paths = array(
+ DIR_LANGUAGE . $lang . '/extension/module/img_opti.php'
+ );
+ if (strpos($lang, 'ru') === 0) {
+ $paths[] = DIR_LANGUAGE . 'ru-ru/extension/module/img_opti.php';
+ $paths[] = DIR_LANGUAGE . 'russian/extension/module/img_opti.php';
+ } elseif (strpos($lang, 'uk') === 0 || strpos($lang, 'ua') === 0) {
+ $paths[] = DIR_LANGUAGE . 'uk-ua/extension/module/img_opti.php';
+ $paths[] = DIR_LANGUAGE . 'ukrainian/extension/module/img_opti.php';
+ }
+ foreach ($paths as $path) {
+ if (is_file($path)) {
+ $_ = array();
+ require($path);
+ $data = array_merge($data, $_);
+ break;
+ }
+ }
+ }
+ foreach ($data as $key => $value) {
+ $this->language->set($key, $value);
+ }
+ $this->translations = $data;
+ return $data;
+ }
+
+ private function getMemoryLimitInBytesHelper() {
+ $limit = ini_get('memory_limit');
+ if (!$limit || (int)$limit === -1) return 512 * 1024 * 1024;
+ $unit = strtolower(substr($limit, -1));
+ $bytes = (int)$limit;
+ switch ($unit) {
+ case 'g': $bytes *= 1024;
+ case 'm': $bytes *= 1024;
+ case 'k': $bytes *= 1024;
+ }
+ return $bytes;
+ }
+
+ public function scan_cmyk($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+ $this->load->model('tool/image');
+
+ foreach ($iterator as $file) {
+ if ($file->isFile() && in_array(strtolower($file->getExtension()), array('jpg', 'jpeg'))) {
+ $realPath = realpath($file->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $imgSize = @getimagesize($realPath);
+ if ($imgSize) {
+ $is_cmyk = false;
+ if (isset($imgSize['channels']) && $imgSize['channels'] == 4) {
+ $is_cmyk = true;
+ }
+ if (extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($realPath);
+ if ($img->getImageColorspace() == Imagick::COLORSPACE_CMYK) {
+ $is_cmyk = true;
+ }
+ $img->clear(); $img->destroy();
+ } catch (Exception $e) {}
+ }
+ if ($is_cmyk) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+ $size = filesize($realPath);
+ $rows[] = array(
+ 'id' => 'cmyk-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => 'CMYK',
+ 'after' => 'sRGB',
+ 'actions' => array(
+ 'convert' => $this->translate('text_action_convert_rgb', 'Convert to RGB'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'convert',
+ 'meta' => array(
+ 'path' => $relPath
+ )
+ );
+ }
+ }
+ }
+ }
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_cmyk_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => false,
+ 'description' => $this->translate('text_description_cmyk', 'Found JPEGs in CMYK colorspace. Conversion to sRGB ensures they are displayed correctly on all platforms, including iOS/macOS.')
+ );
+ }
+
+ public function fix_cmyk($data) {
+ $this->backupCatalogTables('cmyk');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_cmyk_grid.json'), true);
+ $rows_map = array();
+ if ($grid) {
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'convert' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $relPath = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $relPath);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) continue;
+
+ if (extension_loaded('imagick')) {
+ try {
+ $img = new Imagick($fullPath);
+ if ($img->getImageColorspace() == Imagick::COLORSPACE_CMYK) {
+ $img->transformImageColorspace(Imagick::COLORSPACE_SRGB);
+ $img->writeImage($fullPath);
+ $this->deleteImageCache($relPath);
+ }
+ $img->clear(); $img->destroy();
+ } catch (Exception $e) {}
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_cmyk_grid.json');
+ return array('success' => true);
+ }
+
+ public function scan_heavy_files($data) {
+ $this->loadLanguageSafe();
+ $rows = array();
+ $dir = realpath(DIR_IMAGE . 'catalog');
+ $realImageDir = realpath(DIR_IMAGE);
+ if (!$dir || strpos(str_replace('\\', '/', $dir), str_replace('\\', '/', $realImageDir)) !== 0 || !is_dir($dir)) {
+ return array('error' => $this->language->get('text_err_dir'));
+ }
+
+ $limit = isset($data['module_img_opti_heavy_limit']) ? (int)$data['module_img_opti_heavy_limit'] : 20;
+ if ($limit <= 0) $limit = 20;
+
+ $min_size = isset($data['module_img_opti_heavy_min_size']) ? (int)$data['module_img_opti_heavy_min_size'] : 500;
+ if ($min_size < 0) $min_size = 500;
+ $min_size_bytes = $min_size * 1024;
+
+ $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS));
+
+ $all_files = array();
+ foreach ($iterator as $file) {
+ if ($file->isFile() && in_array(strtolower($file->getExtension()), array('jpg', 'jpeg', 'png', 'webp'))) {
+ $realPath = realpath($file->getPathname());
+ if ($realPath && strpos(str_replace('\\', '/', $realPath), str_replace('\\', '/', $dir)) === 0) {
+ $size = filesize($realPath);
+ if ($size >= $min_size_bytes) {
+ $all_files[$realPath] = $size;
+ }
+ }
+ }
+ }
+
+ arsort($all_files);
+ $top_files = array_slice($all_files, 0, $limit, true);
+
+ $this->load->model('tool/image');
+
+ foreach ($top_files as $realPath => $size) {
+ $relPath = $this->getRelativeImagePath($realPath);
+ $thumb = $this->model_tool_image->resize($relPath, 100, 100);
+ $entities = $this->getEntitiesByImagePath($relPath);
+ $entities_html = !empty($entities) ? implode('
', $entities) : '' . $this->language->get('text_no_relations') . '';
+
+ $w = 0; $h = 0;
+ $imgSize = @getimagesize($realPath);
+ if ($imgSize) {
+ $w = $imgSize[0];
+ $h = $imgSize[1];
+ }
+
+ $rows[] = array(
+ 'id' => 'heavy-' . md5($relPath),
+ 'preview' => '',
+ 'name' => '' . htmlspecialchars($relPath, ENT_QUOTES, 'UTF-8') . '
' . $entities_html,
+ 'before' => $this->formatBytes($size) . ($w ? ' (' . $w . 'x' . $h . 'px)' : ''),
+ 'after' => $this->translate('text_action_compress', 'Compress'),
+ 'actions' => array(
+ 'compress' => $this->translate('text_action_compress', 'Compress'),
+ 'ignore' => $this->language->get('text_action_ignore')
+ ),
+ 'selected_action' => 'compress',
+ 'meta' => array(
+ 'path' => $relPath
+ )
+ );
+ }
+
+ file_put_contents(DIR_CACHE . 'img_opti_heavy_files_grid.json', json_encode($rows));
+
+ return array(
+ 'success' => true,
+ 'headers' => array(
+ $this->language->get('text_th_preview'),
+ $this->language->get('text_th_entity'),
+ $this->language->get('text_th_before'),
+ $this->language->get('text_th_after'),
+ $this->language->get('text_th_action')
+ ),
+ 'rows' => $rows,
+ 'backup_available' => true,
+ 'quarantine_available' => false,
+ 'description' => sprintf($this->translate('text_description_heavy', 'List of the top %d heaviest files in the image directory. You can optimize them here directly.'), $limit)
+ );
+ }
+
+ public function fix_heavy_files($data) {
+ $this->backupCatalogTables('heavy_files');
+ $selected = isset($data['selected']) ? (array)$data['selected'] : array();
+ $fix_all = isset($data['fix_all']) ? (int)$data['fix_all'] : 0;
+
+ $grid = json_decode(file_get_contents(DIR_CACHE . 'img_opti_heavy_files_grid.json'), true);
+ $rows_map = array();
+ if ($grid) {
+ foreach ($grid as $row) {
+ $rows_map[$row['id']] = $row;
+ if ($fix_all === 1 || empty($selected)) {
+ $selected[$row['id']] = $row['selected_action'];
+ }
+ }
+ }
+
+ $realImageDir = realpath(DIR_IMAGE);
+
+ $maxWidth = (int)$this->config->get('module_img_opti_max_width');
+ if (!$maxWidth) $maxWidth = 1600;
+ $maxHeight = (int)$this->config->get('module_img_opti_max_height');
+ if (!$maxHeight) $maxHeight = 1600;
+ $jpgQ = (int)$this->config->get('module_img_opti_jpg_quality');
+ if (!$jpgQ) $jpgQ = 72;
+ $pngQ = (int)$this->config->get('module_img_opti_png_quality');
+ if (!$pngQ) $pngQ = 7;
+ $webpQ = (int)$this->config->get('module_img_opti_webp_quality');
+ if (!$webpQ) $webpQ = 80;
+ $engine = $this->config->get('module_img_opti_engine') == 'imagick' && extension_loaded('imagick') ? 'imagick' : 'gd';
+
+ foreach ($selected as $row_id => $act) {
+ if ($act !== 'compress' || !isset($rows_map[$row_id])) continue;
+ $row = $rows_map[$row_id];
+ $relPath = str_replace(array('../', '..\\', "\0"), '', (string)$row['meta']['path']);
+ $fullPath = realpath(DIR_IMAGE . $relPath);
+ if (!$fullPath || strpos(str_replace('\\', '/', $fullPath), str_replace('\\', '/', $realImageDir)) !== 0 || !is_file($fullPath)) continue;
+
+ $tempPath = DIR_CACHE . 'opti_temp_heavy_' . md5($fullPath);
+ if ($this->optimizeImageHelper($fullPath, $tempPath, $maxWidth, $maxHeight, $jpgQ, $pngQ, $webpQ, $engine)) {
+ if (file_exists($tempPath)) {
+ @unlink($fullPath);
+ if (@rename($tempPath, $fullPath)) {
+ $this->deleteImageCache($relPath);
+ } else {
+ if (@copy($tempPath, $fullPath)) {
+ @unlink($tempPath);
+ $this->deleteImageCache($relPath);
+ }
+ }
+ }
+ }
+ }
+ @unlink(DIR_CACHE . 'img_opti_heavy_files_grid.json');
+ return array('success' => true);
+ }
+
+ private function deleteImageCache($relPath) {
+ $info = $this->mb_pathinfo($relPath);
+ if (!isset($info['extension']) || !isset($info['dirname'])) return;
+ $dir = DIR_IMAGE . 'cache/' . $info['dirname'];
+ if (!is_dir($dir)) return;
+ $filename = $info['filename'];
+ $ext = $info['extension'];
+
+ $files = glob($dir . '/' . $filename . '-*x*.' . $ext);
+ if ($files) {
+ foreach ($files as $file) {
+ @unlink($file);
+ }
+ }
+ $files_webp = glob($dir . '/' . $filename . '-*x*.webp');
+ if ($files_webp) {
+ foreach ($files_webp as $file) {
+ @unlink($file);
+ }
+ }
+ }
+
+ private function mb_pathinfo($path, $options = null) {
+ $ret = array('dirname' => '', 'basename' => '', 'extension' => '', 'filename' => '');
+ $path = str_replace('\\', '/', $path);
+ $last_slash = strrpos($path, '/');
+ if ($last_slash !== false) {
+ $ret['dirname'] = substr($path, 0, $last_slash);
+ $basename = substr($path, $last_slash + 1);
+ } else {
+ $basename = $path;
+ }
+ $ret['basename'] = $basename;
+ $last_dot = strrpos($basename, '.');
+ if ($last_dot !== false) {
+ $ret['filename'] = substr($basename, 0, $last_dot);
+ $ret['extension'] = substr($basename, $last_dot + 1);
+ } else {
+ $ret['filename'] = $basename;
+ }
+ if ($options === PATHINFO_DIRNAME) return $ret['dirname'];
+ if ($options === PATHINFO_BASENAME) return $ret['basename'];
+ if ($options === PATHINFO_EXTENSION) return $ret['extension'];
+ if ($options === PATHINFO_FILENAME) return $ret['filename'];
+ return $ret;
+ }
+
+ private function isPathUsedInDb($path) {
+ $escaped = $this->db->escape((string)$path);
+ $query = $this->db->query("SELECT product_id FROM `" . DB_PREFIX . "product` WHERE image = '" . $escaped . "' LIMIT 1");
+ if ($query->num_rows) return true;
+ $query = $this->db->query("SELECT product_id FROM `" . DB_PREFIX . "product_image` WHERE image = '" . $escaped . "' LIMIT 1");
+ if ($query->num_rows) return true;
+ $query = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "category` WHERE image = '" . $escaped . "' LIMIT 1");
+ if ($query->num_rows) return true;
+ $query = $this->db->query("SELECT manufacturer_id FROM `" . DB_PREFIX . "manufacturer` WHERE image = '" . $escaped . "' LIMIT 1");
+ if ($query->num_rows) return true;
+ return false;
+ }
+
+ private function mb_dirname($path) {
+ $path = str_replace('\\', '/', $path);
+ $last_slash = strrpos($path, '/');
+ if ($last_slash !== false) {
+ return substr($path, 0, $last_slash);
+ }
+ return '.';
+ }
+
+ private function mb_basename($path) {
+ $path = str_replace('\\', '/', $path);
+ $parts = explode('/', $path);
+ return end($parts);
+ }
+
+ private function getRelativeImagePath($absolutePath) {
+ $absolutePath = str_replace('\\', '/', $absolutePath);
+ $dirImageNorm = str_replace('\\', '/', DIR_IMAGE);
+ $rel = $absolutePath;
+ if (substr($absolutePath, 0, strlen($dirImageNorm)) === $dirImageNorm) {
+ $rel = substr($absolutePath, strlen($dirImageNorm));
+ } elseif (strtolower(substr($absolutePath, 0, 3)) === strtolower(substr($dirImageNorm, 0, 3))) {
+ $dirImageNormLower = strtolower($dirImageNorm);
+ $absLower = strtolower($absolutePath);
+ if (substr($absLower, 0, strlen($dirImageNormLower)) === $dirImageNormLower) {
+ $rel = substr($absolutePath, strlen($dirImageNorm));
+ }
+ }
+ $rel = ltrim($rel, '/');
+ return $this->safe_utf8($rel);
+ }
+}
\ No newline at end of file
diff --git a/upload/admin/view/template/module/img_opti.tpl b/upload/admin/view/template/module/img_opti.tpl
new file mode 100644
index 0000000..00b4738
--- /dev/null
+++ b/upload/admin/view/template/module/img_opti.tpl
@@ -0,0 +1,3709 @@
+
+
+ + + +
+ +
+ +
+ +
+ + + + diff --git a/upload/catalog/controller/module/img_opti_cron.php b/upload/catalog/controller/module/img_opti_cron.php new file mode 100644 index 0000000..01f57f8 --- /dev/null +++ b/upload/catalog/controller/module/img_opti_cron.php @@ -0,0 +1,637 @@ +load->model('setting/setting'); + $settings = $this->model_setting_setting->getSetting('module_img_opti'); + + if (PHP_SAPI !== 'cli') { + if (empty($this->request->get['key']) || $this->request->get['key'] !== $settings['module_img_opti_token']) { + die('Error: Unauthorized request. Invalid cron key.'); + } + } + + if (empty($settings['module_img_opti_status'])) { + die('Error: Module Image Optimizer [NAT] is disabled.'); + } + + if (empty($settings['module_img_opti_token']) || !$this->checkLicense($settings['module_img_opti_token'])) { + die('Error: Module token is invalid or license not verified.'); + } + + $tasks = array(); + if (!empty($settings['module_img_opti_cron_tasks'])) { + $tasks = json_decode(html_entity_decode($settings['module_img_opti_cron_tasks'], ENT_QUOTES, 'UTF-8'), true); + } + + if (!is_array($tasks) || empty($tasks)) { + $legacy_entities = isset($settings['module_img_opti_cron_entities']) ? $settings['module_img_opti_cron_entities'] : array(); + if (is_string($legacy_entities)) { + $legacy_entities = json_decode(html_entity_decode($legacy_entities, ENT_QUOTES, 'UTF-8'), true) ?: explode(',', $legacy_entities); + } + $legacy_folders = isset($settings['module_img_opti_cron_folders']) ? $settings['module_img_opti_cron_folders'] : array(); + if (is_string($legacy_folders)) { + $legacy_folders = json_decode(html_entity_decode($legacy_folders, ENT_QUOTES, 'UTF-8'), true) ?: explode(',', $legacy_folders); + } + + $tasks = array( + array( + 'status' => 0, + 'name' => 'Default Task', + 'new_only' => isset($settings['module_img_opti_cron_new_only']) ? (int)$settings['module_img_opti_cron_new_only'] : 1, + 'entities' => is_array($legacy_entities) ? $legacy_entities : array('product', 'category', 'manufacturer', 'banner', 'blog'), + 'folder_mode' => isset($settings['module_img_opti_cron_folder_mode']) ? $settings['module_img_opti_cron_folder_mode'] : 'exclude', + 'folders' => is_array($legacy_folders) ? $legacy_folders : array(), + 'quality_override' => isset($settings['module_img_opti_cron_quality_override']) ? (int)$settings['module_img_opti_cron_quality_override'] : 0, + 'jpg_quality' => isset($settings['module_img_opti_cron_jpg_quality']) ? (int)$settings['module_img_opti_cron_jpg_quality'] : 72, + 'webp_quality' => isset($settings['module_img_opti_cron_webp_quality']) ? (int)$settings['module_img_opti_cron_webp_quality'] : 80, + 'max_width' => isset($settings['module_img_opti_cron_max_width']) ? (int)$settings['module_img_opti_cron_max_width'] : 1600, + 'max_height' => isset($settings['module_img_opti_cron_max_height']) ? (int)$settings['module_img_opti_cron_max_height'] : 1600 + ) + ); + } + + $tasks_to_run = array(); + if (isset($this->request->get['task'])) { + $req_task = trim($this->request->get['task']); + foreach ($tasks as $task) { + if (strcasecmp($task['name'], $req_task) === 0) { + if (empty($task['status'])) { + die("Error: Requested cron task '" . htmlspecialchars($req_task) . "' is disabled."); + } + $tasks_to_run[] = $task; + break; + } + } + if (empty($tasks_to_run)) { + die("Error: Requested cron task '" . htmlspecialchars($req_task) . "' was not found."); + } + } else { + foreach ($tasks as $task) { + if (!empty($task['status'])) { + $tasks_to_run[] = $task; + } + } + if (empty($tasks_to_run)) { + die("Success: No enabled cron tasks found.\n"); + } + } + + echo "Image Optimizer [NAT] Cron Engine Started...\n"; + echo "Found " . count($tasks_to_run) . " task(s) to execute.\n\n"; + + $engine = isset($settings['module_img_opti_engine']) && $settings['module_img_opti_engine'] == 'imagick' && extension_loaded('imagick') ? 'imagick' : 'gd'; + $min_savings_bytes = isset($settings['module_img_opti_min_savings_bytes']) ? (int)$settings['module_img_opti_min_savings_bytes'] : 100; + $min_savings_percent = isset($settings['module_img_opti_min_savings_percent']) ? (float)$settings['module_img_opti_min_savings_percent'] : 0.3; + $threshold = isset($settings['module_img_opti_size_threshold']) ? (float)$settings['module_img_opti_size_threshold'] : 0; + + $cacheFileOpt = DIR_CACHE . 'img_opti_optimized.json'; + $cache = array(); + if (file_exists($cacheFileOpt)) { + $content = @file_get_contents($cacheFileOpt); + if ($content) { + $decoded = json_decode($content, true); + if (is_array($decoded)) { + $cache = $decoded; + } + } + } + + $realImageDir = rtrim(str_replace('\\', '/', realpath(DIR_IMAGE)), '/'); + $dirImageNorm = rtrim(str_replace('\\', '/', DIR_IMAGE), '/') . '/'; + + foreach ($tasks_to_run as $task) { + $taskName = $task['name']; + echo "--- Executing Task: $taskName ---\n"; + + $task_name_lower = strtolower($taskName); + $cacheFile = DIR_CACHE . 'img_opti_cron_last_run_' . md5($task_name_lower) . '.txt'; + if ($task_name_lower === 'default task' && !file_exists($cacheFile) && file_exists(DIR_CACHE . 'img_opti_cron_last_run.txt')) { + @copy(DIR_CACHE . 'img_opti_cron_last_run.txt', $cacheFile); + } + + $onlyNew = isset($task['new_only']) ? (int)$task['new_only'] : 1; + $lastRunTime = 0; + if ($onlyNew) { + $lastRunTime = file_exists($cacheFile) ? (int)file_get_contents($cacheFile) : 0; + } + + $task_maxWidth = isset($settings['module_img_opti_max_width']) ? (int)$settings['module_img_opti_max_width'] : 1600; + $task_maxHeight = isset($settings['module_img_opti_max_height']) ? (int)$settings['module_img_opti_max_height'] : 1600; + $task_jpgQ = isset($settings['module_img_opti_jpg_quality']) ? (int)$settings['module_img_opti_jpg_quality'] : 72; + $task_pngQ = isset($settings['module_img_opti_png_quality']) ? (int)$settings['module_img_opti_png_quality'] : 7; + $task_webpQ = isset($settings['module_img_opti_webp_quality']) ? (int)$settings['module_img_opti_webp_quality'] : 80; + + if (!empty($task['quality_override'])) { + $task_jpgQ = isset($task['jpg_quality']) ? (int)$task['jpg_quality'] : $task_jpgQ; + $task_webpQ = isset($task['webp_quality']) ? (int)$task['webp_quality'] : $task_webpQ; + $task_maxWidth = isset($task['max_width']) ? (int)$task['max_width'] : $task_maxWidth; + $task_maxHeight = isset($task['max_height']) ? (int)$task['max_height'] : $task_maxHeight; + } + + $batchLimit = 100; + $dirsToScan = array(); + $excludeDirs = array(); + + $folder_mode = isset($task['folder_mode']) ? $task['folder_mode'] : 'exclude'; + $selected_folders = isset($task['folders']) ? $task['folders'] : array(); + if (is_string($selected_folders)) { + $selected_folders = json_decode(html_entity_decode($selected_folders, ENT_QUOTES, 'UTF-8'), true) ?: explode(',', $selected_folders); + } + + if ($folder_mode === 'exclude') { + $dirsToScan[] = $realImageDir . '/catalog'; + foreach ($selected_folders as $folder) { + $folder = trim(str_replace(array('../', '..\\', "\0"), '', $folder)); + if ($folder === '') continue; + $path = realpath(DIR_IMAGE . $folder); + if ($path && is_dir($path) && strpos(str_replace('\\', '/', $path), $realImageDir) === 0) { + $excludeDirs[] = str_replace('\\', '/', $path); + } + } + } else { + foreach ($selected_folders as $folder) { + $folder = trim(str_replace(array('../', '..\\', "\0"), '', $folder)); + if ($folder === '') continue; + $path = realpath(DIR_IMAGE . $folder); + if ($path && is_dir($path) && strpos(str_replace('\\', '/', $path), $realImageDir) === 0) { + $dirsToScan[] = $path; + } + } + if (empty($dirsToScan)) { + $dirsToScan[] = $realImageDir . '/catalog'; + } + } + + $cron_entities = isset($task['entities']) ? $task['entities'] : array(); + if (is_string($cron_entities)) { + $cron_entities = json_decode(html_entity_decode($cron_entities, ENT_QUOTES, 'UTF-8'), true) ?: explode(',', $cron_entities); + } + + echo "Mode: " . ($onlyNew ? "Scanning for NEW files since " . date('Y-m-d H:i:s', $lastRunTime) : "Scanning ALL files") . "\n"; + echo "Quality Settings - JPG: $task_jpgQ%, WebP: $task_webpQ%, Max size: " . $task_maxWidth . "x" . $task_maxHeight . "px\n"; + + $filesToProcess = array(); + $currentTime = time(); + + foreach ($dirsToScan as $dir) { + if (!is_dir($dir)) continue; + try { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + if ($file->isFile()) { + $realPath = realpath($file->getPathname()); + if (!$realPath || strpos(str_replace('\\', '/', $realPath), $realImageDir) !== 0) continue; + + $normalizedPath = str_replace('\\', '/', $realPath); + $in_excluded = false; + foreach ($excludeDirs as $ex_dir) { + if (strpos($normalizedPath, $ex_dir) === 0) { + $in_excluded = true; + break; + } + } + if ($in_excluded) continue; + + $ext = strtolower($file->getExtension()); + if (in_array($ext, array('jpg', 'jpeg', 'png', 'webp', 'gif'))) { + $filePathNorm = str_replace('\\', '/', $realPath); + $relPath = $this->getRelativeImagePath($filePathNorm); + $skip = false; + if ($onlyNew && isset($cache[$relPath])) { + clearstatcache(true, $filePathNorm); + $size = @filesize($filePathNorm); + $mtime = @filemtime($filePathNorm); + if ($size === $cache[$relPath]['size'] && $mtime === $cache[$relPath]['mtime']) { + $skip = true; + } + } + if ($skip) continue; + + if ($file->getMTime() > $lastRunTime) { + $relativePath = $this->getRelativeImagePath($normalizedPath); + $entities = $this->getEntitiesByImagePath($relativePath); + $has_match = false; + + if (empty($entities)) { + if (empty($cron_entities)) { + $has_match = true; + } + } else { + if (empty($cron_entities)) { + $has_match = true; + } else { + foreach ($entities as $entity_html) { + if (strpos($entity_html, 'Product') !== false && in_array('product', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'Category') !== false && in_array('category', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'Manufacturer') !== false && in_array('manufacturer', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'Banner') !== false && in_array('banner', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'Blog') !== false && in_array('blog', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'Simple Blog') !== false && in_array('blog', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'OCT Blog') !== false && in_array('blog', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'NewsBlog') !== false && in_array('blog', $cron_entities)) $has_match = true; + if (strpos($entity_html, 'Information') !== false && in_array('blog', $cron_entities)) $has_match = true; + } + } + } + + if ($has_match) { + $filesToProcess[] = $realPath; + if (count($filesToProcess) >= $batchLimit) { + break 2; + } + } + } + } + } + } + } catch (Exception $e) { + continue; + } + } + + if (empty($filesToProcess)) { + file_put_contents($cacheFile, $currentTime); + echo "No new files found for optimization in this task.\n\n"; + continue; + } + + $processedCount = 0; + $savedBytes = 0; + $cache_updates = array(); + + foreach ($filesToProcess as $filePath) { + $realFilePath = realpath($filePath); + if (!$realFilePath || strpos(str_replace('\\', '/', $realFilePath), $dirImageNorm) !== 0 || !is_file($realFilePath)) continue; + + $relativePath = $this->getRelativeImagePath($realFilePath); + $fileParams = $this->getImageParameters($relativePath, array( + 'module_img_opti_folder_rules' => isset($settings['module_img_opti_folder_rules']) ? $settings['module_img_opti_folder_rules'] : array(), + 'module_img_opti_jpg_quality' => $task_jpgQ, + 'module_img_opti_png_quality' => $task_pngQ, + 'module_img_opti_webp_quality' => $task_webpQ, + 'module_img_opti_max_width' => $task_maxWidth, + 'module_img_opti_max_height' => $task_maxHeight + )); + + $oldSize = filesize($realFilePath); + $tempPath = DIR_CACHE . 'opti_cron_temp_' . md5($realFilePath); + + if ($this->optimizeImage($realFilePath, $tempPath, $fileParams['max_width'], $fileParams['max_height'], $fileParams['jpg_quality'], $fileParams['png_quality'], $fileParams['webp_quality'], $engine)) { + clearstatcache(); + $newSize = filesize($tempPath); + $increasePercent = ($oldSize > 0) ? (($newSize - $oldSize) / $oldSize) * 100 : 0; + + $savedBytesFile = $oldSize - $newSize; + $savedPercentFile = ($oldSize > 0) ? ($savedBytesFile / $oldSize) * 100 : 0; + $is_too_small_gain = ($savedBytesFile < $min_savings_bytes || $savedPercentFile < $min_savings_percent); + + if ($increasePercent > $threshold || $is_too_small_gain) { + @unlink($tempPath); + $cacheKey = $this->getRelativeImagePath($realFilePath); + $cache_updates[$cacheKey] = array( + 'size' => $oldSize, + 'mtime' => @filemtime($realFilePath) + ); + } else { + @rename($tempPath, $realFilePath); + $savedBytes += ($oldSize - $newSize); + $processedCount++; + clearstatcache(true, $realFilePath); + $cacheKey = $this->getRelativeImagePath($realFilePath); + $cache_updates[$cacheKey] = array( + 'size' => @filesize($realFilePath), + 'mtime' => @filemtime($realFilePath) + ); + } + } else { + @unlink($tempPath); + } + + gc_collect_cycles(); + + $memLimit = $this->getMemoryLimitInBytesHelper(); + if (memory_get_usage(true) > 0.80 * $memLimit) { + echo "Memory limit reached (80%). Stopping task execution batch...\n"; + break; + } + } + + if (!empty($cache_updates)) { + if (file_exists($cacheFileOpt)) { + $content = @file_get_contents($cacheFileOpt); + if ($content) { + $decoded = json_decode($content, true); + if (is_array($decoded)) { + $cache = array_merge($cache, $decoded); + } + } + } + foreach ($cache_updates as $k => $v) { + $cache[$k] = $v; + } + @file_put_contents($cacheFileOpt, json_encode($cache, JSON_UNESCAPED_UNICODE)); + } + + if ($processedCount > 0 || count($filesToProcess) < $batchLimit) { + file_put_contents($cacheFile, $currentTime); + } + + echo "Task Done: Processed $processedCount files. Saved space: " . $this->formatBytes($savedBytes) . ".\n\n"; + } + + echo "All cron tasks execution finished.\n"; + } + + private function getEntitiesByImagePath($image_path) { + $entities = array(); + $escaped = $this->db->escape((string)$image_path); + $catalog_url = (defined('HTTPS_CATALOG') && HTTPS_CATALOG) ? HTTPS_CATALOG : (defined('HTTP_CATALOG') ? HTTP_CATALOG : ''); + $query = $this->db->query("SELECT product_id, (SELECT name FROM `" . DB_PREFIX . "product_description` pd WHERE pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "' LIMIT 1) AS name FROM `" . DB_PREFIX . "product` p WHERE image = '" . $escaped . "'"); + foreach ($query->rows as $row) { + $entities[] = 'Product ID ' . $row['product_id']; + } + $query = $this->db->query("SELECT pi.product_id FROM `" . DB_PREFIX . "product_image` pi WHERE image = '" . $escaped . "'"); + foreach ($query->rows as $row) { + $entities[] = 'Product ID ' . $row['product_id']; + } + $query = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "category` c WHERE image = '" . $escaped . "'"); + foreach ($query->rows as $row) { + $entities[] = 'Category ID ' . $row['category_id']; + } + $query = $this->db->query("SELECT manufacturer_id, name FROM `" . DB_PREFIX . "manufacturer` WHERE image = '" . $escaped . "'"); + foreach ($query->rows as $row) { + $entities[] = 'Manufacturer ID ' . $row['manufacturer_id']; + } + $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'"); + if ($table_query->num_rows) { + $query_banner = $this->db->query("SELECT bi.banner_id FROM `" . DB_PREFIX . "banner_image` bi WHERE bi.image = '" . $escaped . "'"); + foreach ($query_banner->rows as $row) { + $entities[] = 'Banner ID ' . $row['banner_id']; + } + } + $blog_tables = array( + 'oct_blog_article' => array('id' => 'blog_article_id'), + 'simple_blog_article' => array('id' => 'simple_blog_article_id'), + 'newsblog_article' => array('id' => 'article_id'), + 'information' => array('id' => 'information_id') + ); + foreach ($blog_tables as $table => $cfg) { + $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $table . "'"); + if ($table_query->num_rows) { + $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $table . "` LIKE 'image'"); + if ($column_query->num_rows) { + try { + $query_blog = $this->db->query("SELECT `" . $cfg['id'] . "` FROM `" . DB_PREFIX . $table . "` WHERE image = '" . $escaped . "' LIMIT 5"); + foreach ($query_blog->rows as $row) { + $entities[] = 'Blog ID ' . $row[$cfg['id']]; + } + } catch (Exception $e) {} + } + } + } + return $entities; + } + + private function optimizeImage($src, $dest, $maxWidth, $maxHeight, $jpgQuality, $pngCompression, $webpQuality, $engine = 'gd') { + if ($engine == 'imagick' && extension_loaded('imagick')) { + try { + $img = new Imagick($src); + $img->transformImageColorspace(Imagick::COLORSPACE_SRGB); + + $width = $img->getImageWidth(); + $height = $img->getImageHeight(); + $ext = strtolower($this->mb_pathinfo($src, PATHINFO_EXTENSION)); + + if (($maxWidth > 0 && $width > $maxWidth) || ($maxHeight > 0 && $height > $maxHeight)) { + $targetW = $maxWidth; + $targetH = $maxHeight; + if ($targetW == 0) { + $targetW = (int)round($width * ($targetH / $height)); + } + if ($targetH == 0) { + $targetH = (int)round($height * ($targetW / $width)); + } + $img->resizeImage($targetW, $targetH, Imagick::FILTER_LANCZOS, 1, true); + } + + $img->stripImage(); + + if ($ext === 'png') { + $img->setImageFormat('png'); + $img->setCompressionQuality($pngCompression * 10); + } elseif ($ext === 'webp') { + $img->setImageFormat('webp'); + $img->setCompressionQuality($webpQuality); + } else { + $img->setImageFormat('jpeg'); + $img->setImageCompression(Imagick::COMPRESSION_JPEG); + $img->setCompressionQuality($jpgQuality); + } + + return $img->writeImage($dest); + } catch (Exception $e) { + $engine = 'gd'; + } + } + + if ($engine == 'gd') { + $img = @imagecreatefromstring(file_get_contents($src)); + if (!$img) return false; + + $width = imagesx($img); + $height = imagesy($img); + $ext = strtolower($this->mb_pathinfo($src, PATHINFO_EXTENSION)); + + if ($ext === 'png' || $ext === 'webp') { + imagealphablending($img, false); + imagesavealpha($img, true); + } + + if (($maxWidth > 0 && $width > $maxWidth) || ($maxHeight > 0 && $height > $maxHeight)) { + $ratioX = ($maxWidth > 0) ? ($maxWidth / $width) : 1; + $ratioY = ($maxHeight > 0) ? ($maxHeight / $height) : 1; + $ratio = min($ratioX, $ratioY); + + $newW = (int)($width * $ratio); + $newH = (int)($height * $ratio); + + $newImg = imagecreatetruecolor($newW, $newH); + + if ($ext === 'png' || $ext === 'webp') { + imagealphablending($newImg, false); + imagesavealpha($newImg, true); + $transparent = imagecolorallocatealpha($newImg, 255, 255, 255, 127); + imagefill($newImg, 0, 0, $transparent); + } else { + $white = imagecolorallocate($newImg, 255, 255, 255); + imagefill($newImg, 0, 0, $white); + } + + imagecopyresampled($newImg, $img, 0, 0, 0, 0, $newW, $newH, $width, $height); + imagedestroy($img); + $img = $newImg; + } + + $res = false; + if ($ext === 'png') { + $res = imagepng($img, $dest, $pngCompression); + } elseif ($ext === 'webp' && function_exists('imagewebp')) { + $res = imagewebp($img, $dest, $webpQuality); + } else { + $res = imagejpeg($img, $dest, $jpgQuality); + } + + imagedestroy($img); + return $res; + } + return false; + } + + private function checkLicense($key) { + $host = explode(':', str_replace(array('http://', 'https://', 'www.'), '', strtolower($this->request->server['HTTP_HOST'])))[0]; + if ($host === 'localhost' || $host === '127.0.0.1') return true; + if (empty($key)) return false; + $parts = explode('.', $host); + $v_k = 'k'; $v_to = 'to'; $v_bit = '_lom'; $v_end = 'aet_'; $v_img = 'image_'; $v_opt = 'optimizer_'; $v_mid = 'tot'; $v_core = '_redis'; $v_sfx = 'ka'; + $salt = ($v_k . $v_to . $v_bit) . ($v_end . $v_img . $v_opt . $v_mid) . ($v_core . $v_sfx); + while(count($parts) >= 2) { + $domain = implode('.', $parts); + if ($key === strtoupper(implode('-', str_split(substr(md5($domain . $salt), 0, 16), 4)))) return true; + array_shift($parts); + } + return false; + } + + private function formatBytes($bytes) { + if ($bytes >= 1048576) return round($bytes / 1048576, 2) . ' MB'; + if ($bytes >= 1024) return round($bytes / 1024, 0) . ' KB'; + return $bytes . ' B'; + } + + private function getImageParameters($relativePath, $globalSettings) { + $rules = array(); + if (isset($globalSettings['module_img_opti_folder_rules'])) { + if (is_array($globalSettings['module_img_opti_folder_rules'])) { + $rules = $globalSettings['module_img_opti_folder_rules']; + } else { + $rules = json_decode(html_entity_decode((string)$globalSettings['module_img_opti_folder_rules'], ENT_QUOTES, 'UTF-8'), true); + if (!is_array($rules)) { + $rules = array(); + } + } + } + + $jpgQ = isset($globalSettings['module_img_opti_jpg_quality']) ? (int)$globalSettings['module_img_opti_jpg_quality'] : 72; + $pngQ = isset($globalSettings['module_img_opti_png_quality']) ? (int)$globalSettings['module_img_opti_png_quality'] : 7; + $webpQ = isset($globalSettings['module_img_opti_webp_quality']) ? (int)$globalSettings['module_img_opti_webp_quality'] : 80; + $maxW = isset($globalSettings['module_img_opti_max_width']) ? (int)$globalSettings['module_img_opti_max_width'] : 1600; + $maxH = isset($globalSettings['module_img_opti_max_height']) ? (int)$globalSettings['module_img_opti_max_height'] : 1600; + + $relativePath = str_replace('\\', '/', $relativePath); + $relativePath = ltrim($relativePath, '/'); + + foreach ($rules as $rule) { + if (empty($rule['folder'])) continue; + $folder = str_replace('\\', '/', $rule['folder']); + $folder = trim($folder, '/'); + + if ($folder !== '' && strpos($relativePath, $folder) !== false) { + if (isset($rule['jpg_quality']) && $rule['jpg_quality'] !== '') { + $jpgQ = (int)$rule['jpg_quality']; + } + if (isset($rule['webp_quality']) && $rule['webp_quality'] !== '') { + $webpQ = (int)$rule['webp_quality']; + } + if (isset($rule['max_width']) && $rule['max_width'] !== '') { + $maxW = (int)$rule['max_width']; + } + if (isset($rule['max_height']) && $rule['max_height'] !== '') { + $maxH = (int)$rule['max_height']; + } + break; + } + } + + return array( + 'jpg_quality' => $jpgQ, + 'png_quality' => $pngQ, + 'webp_quality' => $webpQ, + 'max_width' => $maxW, + 'max_height' => $maxH + ); + } + + private function getMemoryLimitInBytesHelper() { + $limit = ini_get('memory_limit'); + if (!$limit || (int)$limit === -1) return 512 * 1024 * 1024; + $unit = strtolower(substr($limit, -1)); + $bytes = (int)$limit; + switch ($unit) { + case 'g': $bytes *= 1024; + case 'm': $bytes *= 1024; + case 'k': $bytes *= 1024; + } + return $bytes; + } + + private function getRelativeImagePath($absolutePath) { + $absolutePath = str_replace('\\', '/', $absolutePath); + $dirImageNorm = str_replace('\\', '/', DIR_IMAGE); + $rel = $absolutePath; + if (strpos(strtolower($absolutePath), strtolower($dirImageNorm)) === 0) { + $rel = substr($absolutePath, strlen($dirImageNorm)); + } + $rel = ltrim($rel, '/'); + if (mb_check_encoding($rel, 'UTF-8')) { + return $rel; + } + $converted = @mb_convert_encoding($rel, 'UTF-8', 'Windows-1251'); + return $converted ? $converted : $rel; + } + + private function mb_pathinfo($path, $options = null) { + $ret = array('dirname' => '', 'basename' => '', 'extension' => '', 'filename' => ''); + $path = str_replace('\\', '/', $path); + $last_slash = strrpos($path, '/'); + if ($last_slash !== false) { + $ret['dirname'] = substr($path, 0, $last_slash); + $basename = substr($path, $last_slash + 1); + } else { + $basename = $path; + } + $ret['basename'] = $basename; + $last_dot = strrpos($basename, '.'); + if ($last_dot !== false) { + $ret['filename'] = substr($basename, 0, $last_dot); + $ret['extension'] = substr($basename, $last_dot + 1); + } else { + $ret['filename'] = $basename; + } + if ($options === PATHINFO_DIRNAME) return $ret['dirname']; + if ($options === PATHINFO_BASENAME) return $ret['basename']; + if ($options === PATHINFO_EXTENSION) return $ret['extension']; + if ($options === PATHINFO_FILENAME) return $ret['filename']; + return $ret; + } + + private function safe_utf8($str) { + $str = (string)$str; + if (mb_check_encoding($str, 'UTF-8')) { + return $str; + } + $converted = @mb_convert_encoding($str, 'UTF-8', 'Windows-1251'); + return $converted ? $converted : $str; + } + + private function mb_basename($path) { + $path = str_replace('\\', '/', $path); + $parts = explode('/', $path); + return end($parts); + } +} \ No newline at end of file diff --git a/upload/catalog/model/module/img_opti.php b/upload/catalog/model/module/img_opti.php new file mode 100644 index 0000000..7f3a165 --- /dev/null +++ b/upload/catalog/model/module/img_opti.php @@ -0,0 +1,672 @@ +db->escape($original_file_normalized); + $matched_rules = array(); + + if ($is_preview) { + $matched_rules[] = array( + 'status' => 1, + 'type' => $this->config->get('module_img_opti_wm_type'), + 'image' => $this->config->get('module_img_opti_wm_image'), + 'text_val' => $this->config->get('module_img_opti_wm_text_val'), + 'position' => $this->config->get('module_img_opti_wm_position'), + 'opacity' => $this->config->get('module_img_opti_wm_opacity'), + 'angle' => $this->config->get('module_img_opti_wm_angle'), + 'corner_radius' => $this->config->get('module_img_opti_wm_corner_radius'), + 'size_type' => $this->config->get('module_img_opti_wm_size_type'), + 'size_percent' => $this->config->get('module_img_opti_wm_size_percent'), + 'text_color' => $this->config->get('module_img_opti_wm_text_color'), + 'text_font' => $this->config->get('module_img_opti_wm_text_font') + ); + } else { + $rules_json = $this->config->get('module_img_opti_wm_rules'); + $rules = json_decode(html_entity_decode($rules_json, ENT_QUOTES, 'UTF-8'), true) ?: array(); + + foreach ($rules as $rule) { + if (empty($rule['status'])) continue; + + $targets = array(); + if (!empty($rule['target_product'])) $targets[] = 'product'; + if (!empty($rule['target_category'])) $targets[] = 'category'; + if (!empty($rule['target_brand'])) $targets[] = 'manufacturer'; + if (!empty($rule['target_banner'])) $targets[] = 'banner'; + if (!empty($rule['target_blog'])) $targets[] = 'blog'; + + $is_match = false; + $product_id = 0; + $manufacturer_id = 0; + + if (in_array('product', $targets)) { + $query_prod = $this->db->query("SELECT product_id, manufacturer_id FROM `" . DB_PREFIX . "product` WHERE image = '" . $escaped_original . "' LIMIT 1"); + if ($query_prod->num_rows) { + $is_match = true; + $product_id = (int)$query_prod->row['product_id']; + $manufacturer_id = (int)$query_prod->row['manufacturer_id']; + } else { + $query_prod_add = $this->db->query("SELECT product_id FROM `" . DB_PREFIX . "product_image` WHERE image = '" . $escaped_original . "' LIMIT 1"); + if ($query_prod_add->num_rows) { + $is_match = true; + $product_id = (int)$query_prod_add->row['product_id']; + $query_p_info = $this->db->query("SELECT manufacturer_id FROM `" . DB_PREFIX . "product` WHERE product_id = '" . $product_id . "' LIMIT 1"); + $manufacturer_id = $query_p_info->num_rows ? (int)$query_p_info->row['manufacturer_id'] : 0; + } + } + + if ($is_match) { + $categories = array(); + if (!empty($rule['categories'])) { + foreach ($rule['categories'] as $c) { + $categories[] = is_array($c) ? (int)$c['id'] : (int)$c; + } + } + $brands = array(); + if (!empty($rule['brands'])) { + foreach ($rule['brands'] as $b) { + $brands[] = is_array($b) ? (int)$b['id'] : (int)$b; + } + } + + $prod_categories = array(); + $query_p_cats = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "product_to_category` WHERE product_id = '" . $product_id . "'"); + foreach ($query_p_cats->rows as $row) { + $prod_categories[] = (int)$row['category_id']; + } + + $in_selected_cats = false; + if (!empty($categories)) { + foreach ($prod_categories as $c_id) { + if (in_array($c_id, $categories)) { + $in_selected_cats = true; + break; + } + } + } + + $in_selected_brands = false; + if (!empty($brands) && in_array($manufacturer_id, $brands)) { + $in_selected_brands = true; + } + + $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include'; + if ($filter_mode === 'include') { + if (!empty($categories) || !empty($brands)) { + if (!$in_selected_cats && !$in_selected_brands) { + $is_match = false; + } + } + } else { + if ($in_selected_cats || $in_selected_brands) { + $is_match = false; + } + } + } + } + + if (!$is_match && in_array('category', $targets)) { + $query_cat = $this->db->query("SELECT category_id FROM `" . DB_PREFIX . "category` WHERE image = '" . $escaped_original . "' LIMIT 1"); + if ($query_cat->num_rows) { + $is_match = true; + $category_id = (int)$query_cat->row['category_id']; + $categories = array(); + if (!empty($rule['categories'])) { + foreach ($rule['categories'] as $c) { + $categories[] = is_array($c) ? (int)$c['id'] : (int)$c; + } + } + if (!empty($categories)) { + $in_selected = in_array($category_id, $categories); + $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include'; + if ($filter_mode === 'include' && !$in_selected) { + $is_match = false; + } elseif ($filter_mode === 'exclude' && $in_selected) { + $is_match = false; + } + } + } + } + + if (!$is_match && in_array('manufacturer', $targets)) { + $query_brand = $this->db->query("SELECT manufacturer_id FROM `" . DB_PREFIX . "manufacturer` WHERE image = '" . $escaped_original . "' LIMIT 1"); + if ($query_brand->num_rows) { + $is_match = true; + $manufacturer_id = (int)$query_brand->row['manufacturer_id']; + $brands = array(); + if (!empty($rule['brands'])) { + foreach ($rule['brands'] as $b) { + $brands[] = is_array($b) ? (int)$b['id'] : (int)$b; + } + } + if (!empty($brands)) { + $in_selected = in_array($manufacturer_id, $brands); + $filter_mode = isset($rule['filter_mode']) ? $rule['filter_mode'] : 'include'; + if ($filter_mode === 'include' && !$in_selected) { + $is_match = false; + } elseif ($filter_mode === 'exclude' && $in_selected) { + $is_match = false; + } + } + } + } + + if (!$is_match && in_array('banner', $targets)) { + $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . "banner_image'"); + if ($table_query->num_rows) { + $query_banner = $this->db->query("SELECT banner_image_id FROM `" . DB_PREFIX . "banner_image` WHERE image = '" . $escaped_original . "' LIMIT 1"); + if ($query_banner->num_rows) { + $is_match = true; + } + } + } + + if (!$is_match && in_array('blog', $targets)) { + $blog_tables = array( + 'oct_blog_article', + 'simple_blog_article', + 'newsblog_article', + 'information' + ); + foreach ($blog_tables as $b_table) { + $table_query = $this->db->query("SHOW TABLES LIKE '" . DB_PREFIX . $b_table . "'"); + if ($table_query->num_rows) { + $column_query = $this->db->query("SHOW COLUMNS FROM `" . DB_PREFIX . $b_table . "` LIKE 'image'"); + if ($column_query->num_rows) { + $query_blog = $this->db->query("SELECT * FROM `" . DB_PREFIX . $b_table . "` WHERE image = '" . $escaped_original . "' LIMIT 1"); + if ($query_blog->num_rows) { + $is_match = true; + break; + } + } + } + } + } + + if ($is_match) { + $matched_rules[] = $rule; + } + } + } + + if (empty($matched_rules)) { + return false; + } + + $engine = preg_replace('/[^a-z]/', '', strtolower($this->config->get('module_img_opti_engine'))); + $hasImagick = extension_loaded('imagick'); + + if ($engine === 'imagick' && $hasImagick) { + try { + $img = new Imagick($realCachedFile); + foreach ($matched_rules as $rule) { + $type = preg_replace('/[^a-z]/', '', $rule['type']); + $imagePathStr = str_replace(array('../', '..\\', ' '), '', (string)$rule['image']); + $imagePath = realpath(DIR_IMAGE . $imagePathStr); + if ($imagePath && stripos(str_replace('\\', '/', $imagePath), str_replace('\\', '/', $realImageDir)) !== 0) { + $imagePath = false; + } + $textVal = (string)$rule['text_val']; + $position = (int)$rule['position']; + $opacity = (int)$rule['opacity']; + $size_type = isset($rule['size_type']) ? $rule['size_type'] : 'original'; + $size_percent = isset($rule['size_percent']) ? (int)$rule['size_percent'] : 20; + $angle = isset($rule['angle']) ? (int)$rule['angle'] : 0; + $corner_radius = isset($rule['corner_radius']) ? (int)$rule['corner_radius'] : 0; + $text_font = isset($rule['text_font']) ? (string)$rule['text_font'] : ''; + $text_color = isset($rule['text_color']) ? (string)$rule['text_color'] : '#ffffff'; + + if ($type === 'image') { + if (!$imagePath || !is_file($imagePath)) continue; + } + if ($type === 'text' && empty($textVal)) continue; + + $imgW = $img->getImageWidth(); + $imgH = $img->getImageHeight(); + + if ($type === 'image') { + $wm = new Imagick($imagePath); + + $max_wm_w = (int)$this->config->get('module_img_opti_wm_max_width'); + if ($max_wm_w <= 0) $max_wm_w = 800; + $max_wm_h = (int)$this->config->get('module_img_opti_wm_max_height'); + if ($max_wm_h <= 0) $max_wm_h = 800; + + $wmW = $wm->getImageWidth(); + $wmH = $wm->getImageHeight(); + if ($wmW > $max_wm_w || $wmH > $max_wm_h) { + $ratio = min($max_wm_w / $wmW, $max_wm_h / $wmH); + $new_wm_w = intval($wmW * $ratio); + $new_wm_h = intval($wmH * $ratio); + if ($new_wm_w > 0 && $new_wm_h > 0) { + $wm->resizeImage($new_wm_w, $new_wm_h, Imagick::FILTER_LANCZOS, 1); + } + } + + if ($size_type === 'percent' && $size_percent > 0) { + $targetW = intval(($imgW * $size_percent) / 100); + $targetH = intval(($wm->getImageHeight() / $wm->getImageWidth()) * $targetW); + if ($targetW > 0 && $targetH > 0) { + $wm->resizeImage($targetW, $targetH, Imagick::FILTER_LANCZOS, 1); + } + } + if ($corner_radius > 0) { + $wm->roundCorners($corner_radius, $corner_radius); + } + if ($angle != 0) { + $wm->rotateImage(new ImagickPixel('none'), $angle); + } + $wm->evaluateImage(Imagick::EVALUATE_MULTIPLY, $opacity / 100, Imagick::CHANNEL_ALPHA); + $wmW = $wm->getImageWidth(); + $wmH = $wm->getImageHeight(); + } else { + $wm = new Imagick(); + $draw = new ImagickDraw(); + $fontFile = $text_font; + if (!empty($fontFile) && !is_file($fontFile)) { + $base_font = basename($fontFile); + if (is_file(DIR_SYSTEM . 'library/font/' . $base_font)) { + $fontFile = DIR_SYSTEM . 'library/font/' . $base_font; + } elseif (is_file(DIR_SYSTEM . 'fonts/' . $base_font)) { + $fontFile = DIR_SYSTEM . 'fonts/' . $base_font; + } elseif (is_file(DIR_SYSTEM . 'font/' . $base_font)) { + $fontFile = DIR_SYSTEM . 'font/' . $base_font; + } + } + if (empty($fontFile) || !is_file($fontFile)) { + $fontFile = DIR_SYSTEM . 'library/font/Roboto-Regular.ttf'; + } + if (is_file($fontFile)) { + $draw->setFont($fontFile); + } + $fontSize = max(12, $imgW * 0.04); + $draw->setFontSize($fontSize); + $colorHex = $text_color; + list($r, $g, $b) = $this->hex2rgb($colorHex); + $draw->setFillColor(new ImagickPixel('rgba(' . $r . ',' . $g . ',' . $b . ',' . ($opacity / 100) . ')')); + $draw->setGravity(Imagick::GRAVITY_CENTER); + $wm->newImage($imgW, $imgH, new ImagickPixel('transparent')); + $wm->annotateImage($draw, 0, 0, 0, $textVal); + if ($angle != 0) { + $wm->rotateImage(new ImagickPixel('none'), $angle); + } + $wmW = $wm->getImageWidth(); + $wmH = $wm->getImageHeight(); + } + $posX = 0; $posY = 0; $pad = 10; + if (in_array($position, array(1, 4, 7))) $posX = $pad; + elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $wmW) / 2; + elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $wmW - $pad; + + if (in_array($position, array(1, 2, 3))) $posY = $pad; + elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $wmH) / 2; + elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $wmH - $pad; + + $img->compositeImage($wm, Imagick::COMPOSITE_OVER, $posX, $posY); + $wm->clear(); $wm->destroy(); + } + $img->writeImage($realCachedFile); + $img->clear(); $img->destroy(); + } catch (Exception $e) {} + } else { + $ext = strtolower($this->mb_pathinfo($realCachedFile, PATHINFO_EXTENSION)); + if ($ext === 'jpg' || $ext === 'jpeg') { + $img = @imagecreatefromjpeg($realCachedFile); + } elseif ($ext === 'png') { + $img = @imagecreatefrompng($realCachedFile); + } elseif ($ext === 'webp' && function_exists('imagecreatefromwebp')) { + $img = @imagecreatefromwebp($realCachedFile); + } else { + $img = false; + } + + if ($img) { + if (function_exists('imagepalettetotruecolor') && !imageistruecolor($img)) { + imagepalettetotruecolor($img); + } + imagealphablending($img, true); + + $imgW = imagesx($img); + $imgH = imagesy($img); + + foreach ($matched_rules as $rule) { + $type = preg_replace('/[^a-z]/', '', $rule['type']); + $imagePathStr = str_replace(array('../', '..\\', ' '), '', (string)$rule['image']); + $imagePath = realpath(DIR_IMAGE . $imagePathStr); + if ($imagePath && stripos(str_replace('\\', '/', $imagePath), str_replace('\\', '/', $realImageDir)) !== 0) { + $imagePath = false; + } + $textVal = (string)$rule['text_val']; + $position = (int)$rule['position']; + $opacity = (int)$rule['opacity']; + $size_type = isset($rule['size_type']) ? $rule['size_type'] : 'original'; + $size_percent = isset($rule['size_percent']) ? (int)$rule['size_percent'] : 20; + $angle = isset($rule['angle']) ? (int)$rule['angle'] : 0; + $corner_radius = isset($rule['corner_radius']) ? (int)$rule['corner_radius'] : 0; + $text_font = isset($rule['text_font']) ? (string)$rule['text_font'] : ''; + $text_color = isset($rule['text_color']) ? (string)$rule['text_color'] : '#ffffff'; + + if ($type === 'image') { + if (!$imagePath || !is_file($imagePath)) continue; + } + if ($type === 'text' && empty($textVal)) continue; + + if ($type === 'image') { + $wm = @imagecreatefrompng($imagePath); + if ($wm) { + if (function_exists('imagepalettetotruecolor') && !imageistruecolor($wm)) { + imagepalettetotruecolor($wm); + } + imagealphablending($wm, false); + imagesavealpha($wm, true); + + $wmW = imagesx($wm); + $wmH = imagesy($wm); + + $max_wm_w = (int)$this->config->get('module_img_opti_wm_max_width'); + if ($max_wm_w <= 0) $max_wm_w = 800; + $max_wm_h = (int)$this->config->get('module_img_opti_wm_max_height'); + if ($max_wm_h <= 0) $max_wm_h = 800; + + if ($wmW > $max_wm_w || $wmH > $max_wm_h) { + $ratio = min($max_wm_w / $wmW, $max_wm_h / $wmH); + $new_wm_w = intval($wmW * $ratio); + $new_wm_h = intval($wmH * $ratio); + if ($new_wm_w > 0 && $new_wm_h > 0) { + $resized_wm = imagecreatetruecolor($new_wm_w, $new_wm_h); + imagealphablending($resized_wm, false); + imagesavealpha($resized_wm, true); + $transparent = imagecolorallocatealpha($resized_wm, 255, 255, 255, 127); + imagefill($resized_wm, 0, 0, $transparent); + imagecopyresampled($resized_wm, $wm, 0, 0, 0, 0, $new_wm_w, $new_wm_h, $wmW, $wmH); + imagedestroy($wm); + $wm = $resized_wm; + $wmW = $new_wm_w; + $wmH = $new_wm_h; + } + } + if ($size_type === 'percent' && $size_percent > 0) { + $targetW = intval(($imgW * $size_percent) / 100); + $targetH = intval(($wmH / $wmW) * $targetW); + if ($targetW > 0 && $targetH > 0) { + $resized_wm = imagecreatetruecolor($targetW, $targetH); + imagealphablending($resized_wm, false); + imagesavealpha($resized_wm, true); + $transparent = imagecolorallocatealpha($resized_wm, 255, 255, 255, 127); + imagefill($resized_wm, 0, 0, $transparent); + imagecopyresampled($resized_wm, $wm, 0, 0, 0, 0, $targetW, $targetH, $wmW, $wmH); + imagedestroy($wm); + $wm = $resized_wm; + $wmW = $targetW; + $wmH = $targetH; + } + } + if ($corner_radius > 0) { + $wm = $this->roundCornersGD($wm, $corner_radius); + } + if ($angle != 0) { + $gd_angle = -1 * $angle; + $transparent = imagecolorallocatealpha($wm, 255, 255, 255, 127); + $rotated = imagerotate($wm, $gd_angle, $transparent); + if ($rotated) { + imagealphablending($rotated, false); + imagesavealpha($rotated, true); + imagedestroy($wm); + $wm = $rotated; + $wmW = imagesx($wm); + $wmH = imagesy($wm); + } + } + $posX = 0; $posY = 0; $pad = 10; + if (in_array($position, array(1, 4, 7))) $posX = $pad; + elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $wmW) / 2; + elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $wmW - $pad; + + if (in_array($position, array(1, 2, 3))) $posY = $pad; + elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $wmH) / 2; + elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $wmH - $pad; + + $this->imagecopymerge_alpha($img, $wm, $posX, $posY, 0, 0, $wmW, $wmH, $opacity); + imagedestroy($wm); + } + } else { + $fontFile = $text_font; + if (!empty($fontFile) && !is_file($fontFile)) { + $base_font = basename($fontFile); + if (is_file(DIR_SYSTEM . 'library/font/' . $base_font)) { + $fontFile = DIR_SYSTEM . 'library/font/' . $base_font; + } elseif (is_file(DIR_SYSTEM . 'fonts/' . $base_font)) { + $fontFile = DIR_SYSTEM . 'fonts/' . $base_font; + } elseif (is_file(DIR_SYSTEM . 'font/' . $base_font)) { + $fontFile = DIR_SYSTEM . 'font/' . $base_font; + } + } + if (empty($fontFile) || !is_file($fontFile)) { + $fontFile = DIR_SYSTEM . 'library/font/Roboto-Regular.ttf'; + } + if (!is_file($fontFile)) { + $open_basedir = ini_get('open_basedir'); + $fallback_dirs = array( + DIR_SYSTEM . 'library/font/', + DIR_SYSTEM . 'fonts/', + DIR_SYSTEM . 'font/', + DIR_CATALOG . 'view/theme/default/fonts/' + ); + if (DIRECTORY_SEPARATOR === '/') { + if (!$open_basedir) { + $fallback_dirs[] = '/usr/share/fonts/truetype/dejavu/'; + $fallback_dirs[] = '/usr/share/fonts/truetype/liberation/'; + $fallback_dirs[] = '/usr/share/fonts/truetype/freefont/'; + } + } else { + if (!$open_basedir) { + $fallback_dirs[] = 'C:/Windows/Fonts/'; + } + } + foreach ($fallback_dirs as $f_dir) { + if (@is_dir($f_dir)) { + $glob = @glob($f_dir . '*.ttf'); + if ($glob) { + $fontFile = $glob[0]; + break; + } + } + } + } + + if (!is_file($fontFile)) { + $fontSize = max(3, intval($imgW / 50)); + $fontWidth = imagefontwidth($fontSize) * strlen($textVal); + $fontHeight = imagefontheight($fontSize); + $posX = 0; $posY = 0; $pad = 10; + if (in_array($position, array(1, 4, 7))) $posX = $pad; + elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $fontWidth) / 2; + elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $fontWidth - $pad; + + if (in_array($position, array(1, 2, 3))) $posY = $pad; + elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $fontHeight) / 2; + elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $fontHeight - $pad; + + $alpha = intval(127 - (127 * ($opacity / 100))); + $color = imagecolorallocatealpha($img, 255, 255, 255, $alpha); + imagestring($img, $fontSize, $posX, $posY, $textVal, $color); + } else { + $fontSize = max(10, intval($imgW / 25)); + $colorHex = $text_color; + list($r, $g, $b) = $this->hex2rgb($colorHex); + $alpha = intval(127 - (127 * ($opacity / 100))); + $color = imagecolorallocatealpha($img, $r, $g, $b, $alpha); + + $bbox = imagettfbbox($fontSize, $angle, $fontFile, $textVal); + $textW = abs($bbox[4] - $bbox[0]); + $textH = abs($bbox[5] - $bbox[1]); + + $posX = 0; $posY = 0; $pad = 10; + if (in_array($position, array(1, 4, 7))) $posX = $pad; + elseif (in_array($position, array(2, 5, 8))) $posX = ($imgW - $textW) / 2; + elseif (in_array($position, array(3, 6, 9))) $posX = $imgW - $textW - $pad; + + if (in_array($position, array(1, 2, 3))) $posY = $pad + $textH; + elseif (in_array($position, array(4, 5, 6))) $posY = ($imgH - $textH) / 2 + $textH; + elseif (in_array($position, array(7, 8, 9))) $posY = $imgH - $pad; + + imagettftext($img, $fontSize, $angle, $posX, $posY, $color, $fontFile, $textVal); + } + } + } + + if ($ext === 'png' || $ext === 'webp') { + imagealphablending($img, false); + imagesavealpha($img, true); + } + if ($ext === 'jpg' || $ext === 'jpeg') { imagejpeg($img, $realCachedFile, 90); } + elseif ($ext === 'png') { imagepng($img, $realCachedFile, 9); } + elseif ($ext === 'webp') { imagewebp($img, $realCachedFile, 90); } + imagedestroy($img); + } + } + return true; + } + +private function hex2rgb($hex) { + $hex = str_replace('#', '', $hex); + if (strlen($hex) == 3) { + $r = hexdec(substr($hex, 0, 1) . substr($hex, 0, 1)); + $g = hexdec(substr($hex, 1, 1) . substr($hex, 1, 1)); + $b = hexdec(substr($hex, 2, 1) . substr($hex, 2, 1)); + } else { + $r = hexdec(substr($hex, 0, 2)); + $g = hexdec(substr($hex, 2, 2)); + $b = hexdec(substr($hex, 4, 2)); + } + return array($r, $g, $b); + } + + private function roundCornersGD($im, $radius) { + $w = imagesx($im); + $h = imagesy($im); + $new_im = imagecreatetruecolor($w, $h); + imagealphablending($new_im, false); + imagesavealpha($new_im, true); + $transparent = imagecolorallocatealpha($new_im, 255, 255, 255, 127); + imagefill($new_im, 0, 0, $transparent); + for ($x = 0; $x < $w; $x++) { + for ($y = 0; $y < $h; $y++) { + $rgba = imagecolorat($im, $x, $y); + $in_corner = false; + if ($x < $radius && $y < $radius) { + if (pow($radius - $x, 2) + pow($radius - $y, 2) > pow($radius, 2)) $in_corner = true; + } elseif ($x >= $w - $radius && $y < $radius) { + if (pow($x - ($w - $radius - 1), 2) + pow($radius - $y, 2) > pow($radius, 2)) $in_corner = true; + } elseif ($x < $radius && $y >= $h - $radius) { + if (pow($radius - $x, 2) + pow($y - ($h - $radius - 1), 2) > pow($radius, 2)) $in_corner = true; + } elseif ($x >= $w - $radius && $y >= $h - $radius) { + if (pow($x - ($w - $radius - 1), 2) + pow($y - ($h - $radius - 1), 2) > pow($radius, 2)) $in_corner = true; + } + if ($in_corner) { + imagesetpixel($new_im, $x, $y, $transparent); + } else { + imagesetpixel($new_im, $x, $y, $rgba); + } + } + } + imagedestroy($im); + return $new_im; + } + + private function imagecopymerge_alpha($dst_im, $src_im, $dst_x, $dst_y, $src_x, $src_y, $src_w, $src_h, $pct) { + $opacity = $pct / 100; + for ($x = 0; $x < $src_w; $x++) { + for ($y = 0; $y < $src_h; $y++) { + $src_color_idx = imagecolorat($src_im, $src_x + $x, $src_y + $y); + $src_color = imagecolorsforindex($src_im, $src_color_idx); + $src_alpha = (127 - $src_color['alpha']) / 127; + $src_alpha = $src_alpha * $opacity; + if ($src_alpha <= 0) { + continue; + } + $dst_x_pos = $dst_x + $x; + $dst_y_pos = $dst_y + $y; + if ($dst_x_pos < 0 || $dst_x_pos >= imagesx($dst_im) || $dst_y_pos < 0 || $dst_y_pos >= imagesy($dst_im)) { + continue; + } + $dst_color_idx = imagecolorat($dst_im, $dst_x_pos, $dst_y_pos); + $dst_color = imagecolorsforindex($dst_im, $dst_color_idx); + $dst_alpha = (127 - $dst_color['alpha']) / 127; + $out_alpha = $src_alpha + $dst_alpha * (1 - $src_alpha); + if ($out_alpha > 0) { + $out_r = ($src_color['red'] * $src_alpha + $dst_color['red'] * $dst_alpha * (1 - $src_alpha)) / $out_alpha; + $out_g = ($src_color['green'] * $src_alpha + $dst_color['green'] * $dst_alpha * (1 - $src_alpha)) / $out_alpha; + $out_b = ($src_color['blue'] * $src_alpha + $dst_color['blue'] * $dst_alpha * (1 - $src_alpha)) / $out_alpha; + $out_alpha_val = 127 - round($out_alpha * 127); + } else { + $out_r = 0; + $out_g = 0; + $out_b = 0; + $out_alpha_val = 127; + } + $out_r = max(0, min(255, round($out_r))); + $out_g = max(0, min(255, round($out_g))); + $out_b = max(0, min(255, round($out_b))); + $out_alpha_val = max(0, min(127, $out_alpha_val)); + $new_color = imagecolorallocatealpha($dst_im, $out_r, $out_g, $out_b, $out_alpha_val); + if ($new_color === false) { + $new_color = imagecolorresolvealpha($dst_im, $out_r, $out_g, $out_b, $out_alpha_val); + } + imagesetpixel($dst_im, $dst_x_pos, $dst_y_pos, $new_color); + } + } + } + + private function mb_pathinfo($path, $options = null) { + $ret = array('dirname' => '', 'basename' => '', 'extension' => '', 'filename' => ''); + $path = str_replace('\\', '/', $path); + $last_slash = strrpos($path, '/'); + if ($last_slash !== false) { + $ret['dirname'] = substr($path, 0, $last_slash); + $basename = substr($path, $last_slash + 1); + } else { + $basename = $path; + } + $ret['basename'] = $basename; + $last_dot = strrpos($basename, '.'); + if ($last_dot !== false) { + $ret['filename'] = substr($basename, 0, $last_dot); + $ret['extension'] = substr($basename, $last_dot + 1); + } else { + $ret['filename'] = $basename; + } + if ($options === PATHINFO_DIRNAME) return $ret['dirname']; + if ($options === PATHINFO_BASENAME) return $ret['basename']; + if ($options === PATHINFO_EXTENSION) return $ret['extension']; + if ($options === PATHINFO_FILENAME) return $ret['filename']; + return $ret; + } + + private function checkLicense($key) { + $host = explode(':', str_replace(array('http://', 'https://', 'www.'), '', strtolower($this->request->server['HTTP_HOST'])))[0]; + if ($host === 'localhost' || $host === '127.0.0.1') return true; + if (empty($key)) return false; + $parts = explode('.', $host); + $v_k = 'k'; $v_to = 'to'; $v_bit = '_lom'; $v_end = 'aet_'; $v_img = 'image_'; $v_opt = 'optimizer_'; $v_mid = 'tot'; $v_core = '_redis'; $v_sfx = 'ka'; + $salt = ($v_k . $v_to . $v_bit) . ($v_end . $v_img . $v_opt . $v_mid) . ($v_core . $v_sfx); + while(count($parts) >= 2) { + $domain = implode('.', $parts); + if ($key === strtoupper(implode('-', str_split(substr(md5($domain . $salt), 0, 16), 4)))) return true; + array_shift($parts); + } + return false; + } + + private function mb_basename($path) { + $path = str_replace('\\', '/', $path); + $parts = explode('/', $path); + return end($parts); + } +} diff --git a/upload/system/library/font/Roboto-Regular.ttf b/upload/system/library/font/Roboto-Regular.ttf new file mode 100644 index 0000000..ddee473 Binary files /dev/null and b/upload/system/library/font/Roboto-Regular.ttf differ diff --git a/upload/system/library/sitecreator/elFinder/php/MySQLStorage.sql b/upload/system/library/sitecreator/elFinder/php/MySQLStorage.sql new file mode 100644 index 0000000..205e9c3 --- /dev/null +++ b/upload/system/library/sitecreator/elFinder/php/MySQLStorage.sql @@ -0,0 +1,47 @@ +DROP TABLE IF EXISTS `elfinder_file`; +CREATE TABLE IF NOT EXISTS `elfinder_file` ( + `id` int(7) unsigned NOT NULL auto_increment, + `parent_id` int(7) unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `content` longblob NOT NULL, + `size` int(10) unsigned NOT NULL default '0', + `mtime` int(10) unsigned NOT NULL default '0', + `mime` varchar(256) NOT NULL default 'unknown', + `read` enum('1', '0') NOT NULL default '1', + `write` enum('1', '0') NOT NULL default '1', + `locked` enum('1', '0') NOT NULL default '0', + `hidden` enum('1', '0') NOT NULL default '0', + `width` int(5) NOT NULL default '0', + `height` int(5) NOT NULL default '0', + PRIMARY KEY (`id`), + UNIQUE KEY `parent_name` (`parent_id`, `name`), + KEY `parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +INSERT INTO `elfinder_file` +(`id`, `parent_id`, `name`, `content`, `size`, `mtime`, `mime`, `read`, `write`, `locked`, `hidden`, `width`, `height`) VALUES +('1' , '0', 'DATABASE', '', '0', '0','directory', '1', '1', '0', '0', '0', '0'); + +DROP TABLE IF EXISTS `elfinder_trash`; +CREATE TABLE IF NOT EXISTS `elfinder_trash` ( + `id` int(7) unsigned NOT NULL auto_increment, + `parent_id` int(7) unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `content` longblob NOT NULL, + `size` int(10) unsigned NOT NULL default '0', + `mtime` int(10) unsigned NOT NULL default '0', + `mime` varchar(256) NOT NULL default 'unknown', + `read` enum('1', '0') NOT NULL default '1', + `write` enum('1', '0') NOT NULL default '1', + `locked` enum('1', '0') NOT NULL default '0', + `hidden` enum('1', '0') NOT NULL default '0', + `width` int(5) NOT NULL default '0', + `height` int(5) NOT NULL default '0', + PRIMARY KEY (`id`), + UNIQUE KEY `parent_name` (`parent_id`, `name`), + KEY `parent_id` (`parent_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; + +INSERT INTO `elfinder_trash` +(`id`, `parent_id`, `name`, `content`, `size`, `mtime`, `mime`, `read`, `write`, `locked`, `hidden`, `width`, `height`) VALUES +('1' , '0', 'DB Trash', '', '0', '0','directory', '1', '1', '0', '0', '0', '0'); diff --git a/upload/system/library/sitecreator/elFinder/php/autoload.php b/upload/system/library/sitecreator/elFinder/php/autoload.php new file mode 100644 index 0000000..720a4fd --- /dev/null +++ b/upload/system/library/sitecreator/elFinder/php/autoload.php @@ -0,0 +1,55 @@ + 'elFinder.class.php', + 'elFinderConnector' => 'elFinderConnector.class.php', + 'elFinderEditor' => 'editors/editor.php', + 'elFinderLibGdBmp' => 'libs/GdBmp.php', + 'elFinderPlugin' => 'elFinderPlugin.php', + 'elFinderPluginAutoResize' => 'plugins/AutoResize/plugin.php', + 'elFinderPluginAutoRotate' => 'plugins/AutoRotate/plugin.php', + 'elFinderPluginNormalizer' => 'plugins/Normalizer/plugin.php', + 'elFinderPluginSanitizer' => 'plugins/Sanitizer/plugin.php', + 'elFinderPluginWatermark' => 'plugins/Watermark/plugin.php', + 'elFinderSession' => 'elFinderSession.php', + 'elFinderSessionInterface' => 'elFinderSessionInterface.php', + 'elFinderVolumeDriver' => 'elFinderVolumeDriver.class.php', + 'elFinderVolumeDropbox2' => 'elFinderVolumeDropbox2.class.php', + 'elFinderVolumeFTP' => 'elFinderVolumeFTP.class.php', + 'elFinderVolumeFlysystemGoogleDriveCache' => 'elFinderFlysystemGoogleDriveNetmount.php', + 'elFinderVolumeFlysystemGoogleDriveNetmount' => 'elFinderFlysystemGoogleDriveNetmount.php', + 'elFinderVolumeGoogleDrive' => 'elFinderVolumeGoogleDrive.class.php', + 'elFinderVolumeGroup' => 'elFinderVolumeGroup.class.php', + 'elFinderVolumeLocalFileSystem' => 'elFinderVolumeLocalFileSystem.class.php', + 'elFinderVolumeMySQL' => 'elFinderVolumeMySQL.class.php', + 'elFinderVolumeTrash' => 'elFinderVolumeTrash.class.php', + ); + if (isset($map[$name])) { + return include_once(ELFINDER_PHP_ROOT_PATH . '/' . $map[$name]); + } + $prefix = substr($name, 0, 14); + if (substr($prefix, 0, 8) === 'elFinder') { + if ($prefix === 'elFinderVolume') { + $file = ELFINDER_PHP_ROOT_PATH . '/' . $name . '.class.php'; + return (is_file($file) && include_once($file)); + } else if ($prefix === 'elFinderPlugin') { + $file = ELFINDER_PHP_ROOT_PATH . '/plugins/' . substr($name, 14) . '/plugin.php'; + return (is_file($file) && include_once($file)); + } else if ($prefix === 'elFinderEditor') { + $file = ELFINDER_PHP_ROOT_PATH . '/editors/' . substr($name, 14) . '/editor.php'; + return (is_file($file) && include_once($file)); + } + } + return false; +} + +if (version_compare(PHP_VERSION, '5.3', '<')) { + spl_autoload_register('elFinderAutoloader'); +} else { + spl_autoload_register('elFinderAutoloader', true, true); +} + diff --git a/upload/system/library/sitecreator/elFinder/php/connector.maximal.php-dist b/upload/system/library/sitecreator/elFinder/php/connector.maximal.php-dist new file mode 100644 index 0000000..ce7592a --- /dev/null +++ b/upload/system/library/sitecreator/elFinder/php/connector.maximal.php-dist @@ -0,0 +1,433 @@ +file = $path; + $dir = dirname($path); + if (!is_dir($dir)) { + mkdir($dir); + } + } + + /** + * Create log record + * + * @param string $cmd command name + * @param array $result command result + * @param array $args command arguments from client + * @param elFinder $elfinder elFinder instance + * @param elFinderVolumeDriver $volume current volume driver instance + * @return void|true + * @author Dmitry (dio) Levashov + **/ + public function log($cmd, $result, $args, $elfinder, $volume) + { + $log = $cmd.' ['.date('d.m H:s')."]\n"; + + if (!empty($result['error'])) { + $log .= "\tERROR: ".implode(' ', $result['error'])."\n"; + } + + if (!empty($result['warning'])) { + $log .= "\tWARNING: ".implode(' ', $result['warning'])."\n"; + } + + if (!empty($result['removed'])) { + foreach ($result['removed'] as $file) { + // removed file contain additional field "realpath" + $log .= "\tREMOVED: ".$file['realpath']."\n"; + } + } + + if (!empty($result['added'])) { + foreach ($result['added'] as $file) { + $log .= "\tADDED: ".$elfinder->realpath($file['hash'])."\n"; + } + } + + if (!empty($result['changed'])) { + foreach ($result['changed'] as $file) { + $log .= "\tCHANGED: ".$elfinder->realpath($file['hash'])."\n"; + } + } + + $this->write($log); + } + + /** + * Write log into file + * + * @param string $log log record + * @return void + * @author Dmitry (dio) Levashov + **/ + protected function write($log) + { + + if (($fp = @fopen($this->file, 'a'))) { + fwrite($fp, $log."\n"); + fclose($fp); + } + } + +} // END class +// Make logger instance +$logger = new elFinderSimpleLogger('.log.txt'); + +// Documentation for connector options: +// https://github.com/Studio-42/elFinder/wiki/Connector-configuration-options +$opts = array( + 'debug' => true, // enable debug mode + 'roots' => array( + // Items volume + array( + 'driver' => 'LocalFileSystem', // driver for accessing file system (REQUIRED) + 'path' => '../files/', // path to files (REQUIRED) + 'URL' => dirname($_SERVER['PHP_SELF']) . '/../files/', // URL to files (REQUIRED) + 'trashHash' => 't1_Lw', // elFinder's hash of trash folder + 'winHashFix' => DIRECTORY_SEPARATOR !== '/', // to make hash same to Linux one on windows too + 'uploadDeny' => array('all'), // All Mimetypes not allowed to upload + 'uploadAllow' => array('image/x-ms-bmp', 'image/gif', 'image/jpeg', 'image/png', 'image/x-icon', 'text/plain'), // Mimetype `image` and `text/plain` allowed to upload + 'uploadOrder' => array('deny', 'allow'), // allowed Mimetype `image` and `text/plain` only + 'accessControl' => 'access', // disable and hide dot starting files (OPTIONAL) + 'attributes' => array( // additional thumbnail directories + 'pattern' => '~^/\.tmb(?:Cloud|Netmount)$~', + 'read' => false, + 'write' => false, + 'locked' => true, + 'hidden' => true ), + ), + // Trash volume + array( + 'id' => '1', + 'driver' => 'Trash', + 'path' => '../files/.trash/', + 'tmbURL' => dirname($_SERVER['PHP_SELF']) . '/../files/.trash/.tmb/', + 'winHashFix' => DIRECTORY_SEPARATOR !== '/', // to make hash same to Linux one on windows too + 'uploadDeny' => array('all'), // Recomend the same settings as the original volume that uses the trash + 'uploadAllow' => array('image/x-ms-bmp', 'image/gif', 'image/jpeg', 'image/png', 'image/x-icon', 'text/plain'), // Same as above + 'uploadOrder' => array('deny', 'allow'), // Same as above + 'accessControl' => 'access', // Same as above + ), + ), + // some bind functions + 'bind' => array( + // enable logger + // '*' => array($logger, 'log'), + 'mkdir mkfile rename duplicate upload rm paste' => array($logger, 'log'), + // enable plugins + 'archive.pre ls.pre mkdir.pre mkfile.pre rename.pre upload.pre' => array( + 'Plugin.Normalizer.cmdPreprocess', + 'Plugin.Sanitizer.cmdPreprocess' + ), + 'upload.presave' => array( + 'Plugin.AutoRotate.onUpLoadPreSave', + 'Plugin.AutoResize.onUpLoadPreSave', + 'Plugin.Watermark.onUpLoadPreSave', + 'Plugin.Normalizer.onUpLoadPreSave', + 'Plugin.Sanitizer.onUpLoadPreSave', + ), + ), + // volume options of netmount volumes + 'optionsNetVolumes' => array( + '*' => array( // "*" is all of netmount volumes + 'tmbURL' => dirname($_SERVER['PHP_SELF']) . '/../files/.tmbNetmount/', + 'tmbPath' => '../files/.tmbNetmount', + 'tmbGcMaxlifeHour' => 1, // 1 hour + 'tmbGcPercentage' => 10, // 10 execute / 100 tmb querys + 'plugin' => array( + 'AutoResize' => array( + 'enable' => false + ), + 'Watermark' => array( + 'enable' => false + ), + 'Normalizer' => array( + 'enable' => false + ), + 'Sanitizer' => array( + 'enable' => false + ) + ), + ) + ), +); + +// Extended other volume types +// To get an access token or refresh token, see the elFinder wiki. +// https://github.com/Studio-42/elFinder/wiki/How-to-get-OAuth-token + +// Thumbnail settings for cloud volumes +$tmbConfig = array( + 'tmbPath' => '../files/.tmbCloud', + 'tmbURL' => dirname($_SERVER['PHP_SELF']) . '/../files/.tmbCloud/', + 'tmbGcMaxlifeHour' => 2160, // 90 days + 'tmbGcPercentage' => 5, // 5 execute / 100 tmb querys +); + +// MySQL config +$mySqlConfig = array( + 'path' => 1, + 'host' => '127.0.0.1', + 'user' => '', // @String DB user name + 'pass' => '', // @String DB user password + 'db' => '', // @String Database name + 'uploadMaxSize' => '10M', // It should be less than "max_allowed_packet" value of MySQL setting +); +// MySQL volume +$opts['roots'][] = array_merge($tmbConfig, $mySqlConfig, array( + 'driver' => 'MySQL', + 'trashHash' => 'tm1_MQ', // set trash to MySQL trash (tm1_) 1 (MQ) + 'files_table' => 'elfinder_file', +)); +// MySQL trash volume +$opts['roots'][] = array_merge($tmbConfig, $mySqlConfig, array( + 'id' => '1', // volume id became "tm1_" + 'alias' => 'DB Trash', + 'driver' => 'TrashMySQL', + 'files_table' => 'elfinder_trash', +)); + +// Volume group +$opts['roots'][] = array( + 'id' => '1', // volume id became "g1_" + 'alias' => 'CloudVolumes', + 'driver' => 'Group', + 'rootCssClass' => 'elfinder-navbar-root-network' // set volume icon +); + +// FTP volume +$opts['roots'][] = array_merge($tmbConfig, array( + 'phash' => 'g1_Lw', // set parent to Volume group (g1_) root "/" (Lw) + 'driver' => 'FTP', + 'host' => 'ftp.jaist.ac.jp', + 'user' => 'anonymous', + 'path' => '/', + 'owner' => false, +)); + +// To enable the following cloud volumes, first complete the steps +// for enabling each network-mounted volume described earlier in this file. + +// Box volume +// Require constant "ELFINDER_BOX_CLIENTID" and "ELFINDER_BOX_CLIENTSECRET" +$opts['roots'][] = array_merge($tmbConfig, array( + 'phash' => 'g1_Lw', // set parent to Volume group (g1_) root "/" (Lw) + 'driver' => 'Box', + 'path' => '/', // or folder id as root + 'accessToken' => '', // @JSON String access token including refresh token +)); + +// Dropbox volume +// Require constant "ELFINDER_DROPBOX_APPKEY" and "ELFINDER_DROPBOX_APPSECRET" +$opts['roots'][] = array_merge($tmbConfig, array( + 'phash' => 'g1_Lw', // set parent to Volume group (g1_) root "/" (Lw) + 'driver' => 'Dropbox2', + 'path' => '/', // or folder path as root + 'access_token' => '', // @String your access token +)); + +// GoogleDrive volume with refresh token +// Require constant "ELFINDER_GOOGLEDRIVE_CLIENTID" and "ELFINDER_GOOGLEDRIVE_CLIENTSECRET" +$opts['roots'][] = array_merge($tmbConfig, array( + 'phash' => 'g1_Lw', // set parent to Volume group (g1_) root "/" (Lw) + 'driver' => 'GoogleDrive', + 'path' => '/', // or folder id as root + 'refresh_token' => '', // @String your refresh token +)); + +// GoogleDrive volume with service account +// Require constant "ELFINDER_GOOGLEDRIVE_CLIENTID" and "ELFINDER_GOOGLEDRIVE_CLIENTSECRET" +$opts['roots'][] = array_merge($tmbConfig, array( + 'phash' => 'g1_Lw', // set parent to Volume group (g1_) root "/" (Lw) + 'driver' => 'GoogleDrive', + 'path' => '/', // or folder id as root + 'serviceAccountConfigFile' => '', // @String path to config json file +)); + +// OneDrive volume +// Require constant "ELFINDER_ONEDRIVE_CLIENTID" and "ELFINDER_ONEDRIVE_CLIENTSECRET" +$opts['roots'][] = array_merge($tmbConfig, array( + 'phash' => 'g1_Lw', // set parent to Volume group (g1_) root "/" (Lw) + 'driver' => 'OneDrive', + 'path' => '/', // or folder id as root + 'accessToken' => '', // @JSON String access token including refresh token +)); + + +// run elFinder +$connector = new elFinderConnector(new elFinder($opts)); +$connector->run(); + diff --git a/upload/system/library/sitecreator/elFinder/php/connector.minimal.php-dist b/upload/system/library/sitecreator/elFinder/php/connector.minimal.php-dist new file mode 100644 index 0000000..fcafdd3 --- /dev/null +++ b/upload/system/library/sitecreator/elFinder/php/connector.minimal.php-dist @@ -0,0 +1,179 @@ + true, + 'roots' => array( + // Items volume + array( + 'driver' => 'LocalFileSystem', // driver for accessing file system (REQUIRED) + 'path' => '../files/', // path to files (REQUIRED) + 'URL' => dirname($_SERVER['PHP_SELF']) . '/../files/', // URL to files (REQUIRED) + 'trashHash' => 't1_Lw', // elFinder's hash of trash folder + 'winHashFix' => DIRECTORY_SEPARATOR !== '/', // to make hash same to Linux one on windows too + 'uploadDeny' => array('all'), // All Mimetypes not allowed to upload + 'uploadAllow' => array('image/x-ms-bmp', 'image/gif', 'image/jpeg', 'image/png', 'image/x-icon', 'text/plain'), // Mimetype `image` and `text/plain` allowed to upload + 'uploadOrder' => array('deny', 'allow'), // allowed Mimetype `image` and `text/plain` only + 'accessControl' => 'access' // disable and hide dot starting files (OPTIONAL) + ), + // Trash volume + array( + 'id' => '1', + 'driver' => 'Trash', + 'path' => '../files/.trash/', + 'tmbURL' => dirname($_SERVER['PHP_SELF']) . '/../files/.trash/.tmb/', + 'winHashFix' => DIRECTORY_SEPARATOR !== '/', // to make hash same to Linux one on windows too + 'uploadDeny' => array('all'), // Recomend the same settings as the original volume that uses the trash + 'uploadAllow' => array('image/x-ms-bmp', 'image/gif', 'image/jpeg', 'image/png', 'image/x-icon', 'text/plain'), // Same as above + 'uploadOrder' => array('deny', 'allow'), // Same as above + 'accessControl' => 'access', // Same as above + ), + ) +); + +// run elFinder +$connector = new elFinderConnector(new elFinder($opts)); +$connector->run(); + diff --git a/upload/system/library/sitecreator/elFinder/php/elFinder.class.php b/upload/system/library/sitecreator/elFinder/php/elFinder.class.php new file mode 100644 index 0000000..9167ab5 --- /dev/null +++ b/upload/system/library/sitecreator/elFinder/php/elFinder.class.php @@ -0,0 +1,5273 @@ + array('id' => true), + 'archive' => array('targets' => true, 'type' => true, 'mimes' => false, 'name' => false), + 'callback' => array('node' => true, 'json' => false, 'bind' => false, 'done' => false), + 'chmod' => array('targets' => true, 'mode' => true), + 'dim' => array('target' => true, 'substitute' => false), + 'duplicate' => array('targets' => true, 'suffix' => false), + 'editor' => array('name' => true, 'method' => true, 'args' => false), + 'extract' => array('target' => true, 'mimes' => false, 'makedir' => false), + 'file' => array('target' => true, 'download' => false, 'cpath' => false, 'onetime' => false), + 'get' => array('target' => true, 'conv' => false), + 'info' => array('targets' => true, 'compare' => false), + 'ls' => array('target' => true, 'mimes' => false, 'intersect' => false), + 'mkdir' => array('target' => true, 'name' => false, 'dirs' => false), + 'mkfile' => array('target' => true, 'name' => true, 'mimes' => false), + 'netmount' => array('protocol' => true, 'host' => true, 'path' => false, 'port' => false, 'user' => false, 'pass' => false, 'alias' => false, 'options' => false), + 'open' => array('target' => false, 'tree' => false, 'init' => false, 'mimes' => false, 'compare' => false), + 'parents' => array('target' => true, 'until' => false), + 'paste' => array('dst' => true, 'targets' => true, 'cut' => false, 'mimes' => false, 'renames' => false, 'hashes' => false, 'suffix' => false), + 'put' => array('target' => true, 'content' => '', 'mimes' => false, 'encoding' => false), + 'rename' => array('target' => true, 'name' => true, 'mimes' => false, 'targets' => false, 'q' => false), + 'resize' => array('target' => true, 'width' => false, 'height' => false, 'mode' => false, 'x' => false, 'y' => false, 'degree' => false, 'quality' => false, 'bg' => false), + 'rm' => array('targets' => true), + 'search' => array('q' => true, 'mimes' => false, 'target' => false, 'type' => false), + 'size' => array('targets' => true), + 'subdirs' => array('targets' => true), + 'tmb' => array('targets' => true), + 'tree' => array('target' => true), + 'upload' => array('target' => true, 'FILES' => true, 'mimes' => false, 'html' => false, 'upload' => false, 'name' => false, 'upload_path' => false, 'chunk' => false, 'cid' => false, 'node' => false, 'renames' => false, 'hashes' => false, 'suffix' => false, 'mtime' => false, 'overwrite' => false, 'contentSaveId' => false), + 'url' => array('target' => true, 'options' => false), + 'zipdl' => array('targets' => true, 'download' => false) + ); + + /** + * Plugins instance + * + * @var array + **/ + protected $plugins = array(); + + /** + * Commands listeners + * + * @var array + **/ + protected $listeners = array(); + + /** + * script work time for debug + * + * @var string + **/ + protected $time = 0; + /** + * Is elFinder init correctly? + * + * @var bool + **/ + protected $loaded = false; + /** + * Send debug to client? + * + * @var string + **/ + protected $debug = false; + + /** + * Call `session_write_close()` before exec command? + * + * @var bool + */ + protected $sessionCloseEarlier = true; + + /** + * SESSION use commands @see __construct() + * + * @var array + */ + protected $sessionUseCmds = array(); + + /** + * session expires timeout + * + * @var int + **/ + protected $timeout = 0; + + /** + * Temp dir path for Upload + * + * @var string + */ + protected $uploadTempPath = ''; + + /** + * Max allowed archive files size (0 - no limit) + * + * @var integer + */ + protected $maxArcFilesSize = 0; + + /** + * undocumented class variable + * + * @var string + **/ + protected $uploadDebug = ''; + + /** + * Max allowed numbar of targets (0 - no limit) + * + * @var integer + */ + public $maxTargets = 1000; + + /** + * Errors from PHP + * + * @var array + **/ + public static $phpErrors = array(); + + /** + * Errors from not mounted volumes + * + * @var array + **/ + public $mountErrors = array(); + + + /** + * Archivers cache + * + * @var array + */ + public static $archivers = array(); + + /** + * URL for callback output window for CORS + * redirect to this URL when callback output + * + * @var string URL + */ + protected $callbackWindowURL = ''; + + /** + * hash of items to unlock on command completion + * + * @var array hashes + */ + protected $autoUnlocks = array(); + + /** + * Item locking expiration (seconds) + * Default: 3600 secs + * + * @var integer + */ + protected $itemLockExpire = 3600; + + /** + * Additional request querys + * + * @var array|null + */ + protected $customData = null; + + /** + * Ids to remove of session var "urlContentSaveIds" for contents uploading by URL + * + * @var array + */ + protected $removeContentSaveIds = array(); + + /** + * Flag of throw Error on exec() + * + * @var boolean + */ + protected $throwErrorOnExec = false; + + /** + * Default params of toastParams + * + * @var array + */ + protected $toastParamsDefault = array( + 'mode' => 'warning', + 'prefix' => '' + ); + + /** + * Toast params of runtime notification + * + * @var array + */ + private $toastParams = array(); + + /** + * Toast messages of runtime notification + * + * @var array + */ + private $toastMessages = array(); + + /** + * Optional UTF-8 encoder + * + * @var callable || null + */ + private $utf8Encoder = null; + + /** + * Seekable URL file pointer ids - for getStreamByUrl() + * + * @var array + */ + private static $seekableUrlFps = array(); + + // Errors messages + const ERROR_ACCESS_DENIED = 'errAccess'; + const ERROR_ARC_MAXSIZE = 'errArcMaxSize'; + const ERROR_ARC_SYMLINKS = 'errArcSymlinks'; + const ERROR_ARCHIVE = 'errArchive'; + const ERROR_ARCHIVE_EXEC = 'errArchiveExec'; + const ERROR_ARCHIVE_TYPE = 'errArcType'; + const ERROR_CONF = 'errConf'; + const ERROR_CONF_NO_JSON = 'errJSON'; + const ERROR_CONF_NO_VOL = 'errNoVolumes'; + const ERROR_CONV_UTF8 = 'errConvUTF8'; + const ERROR_COPY = 'errCopy'; + const ERROR_COPY_FROM = 'errCopyFrom'; + const ERROR_COPY_ITSELF = 'errCopyInItself'; + const ERROR_COPY_TO = 'errCopyTo'; + const ERROR_CREATING_TEMP_DIR = 'errCreatingTempDir'; + const ERROR_DIR_NOT_FOUND = 'errFolderNotFound'; + const ERROR_EXISTS = 'errExists'; // 'File named "$1" already exists.' + const ERROR_EXTRACT = 'errExtract'; + const ERROR_EXTRACT_EXEC = 'errExtractExec'; + const ERROR_FILE_NOT_FOUND = 'errFileNotFound'; // 'File not found.' + const ERROR_FTP_DOWNLOAD_FILE = 'errFtpDownloadFile'; + const ERROR_FTP_MKDIR = 'errFtpMkdir'; + const ERROR_FTP_UPLOAD_FILE = 'errFtpUploadFile'; + const ERROR_INV_PARAMS = 'errCmdParams'; + const ERROR_INVALID_DIRNAME = 'errInvDirname'; // 'Invalid folder name.' + const ERROR_INVALID_NAME = 'errInvName'; // 'Invalid file name.' + const ERROR_LOCKED = 'errLocked'; // '"$1" is locked and can not be renamed, moved or removed.' + const ERROR_MAX_TARGTES = 'errMaxTargets'; // 'Max number of selectable items is $1.' + const ERROR_MKDIR = 'errMkdir'; + const ERROR_MKFILE = 'errMkfile'; + const ERROR_MKOUTLINK = 'errMkOutLink'; // 'Unable to create a link to outside the volume root.' + const ERROR_MOVE = 'errMove'; + const ERROR_NETMOUNT = 'errNetMount'; + const ERROR_NETMOUNT_FAILED = 'errNetMountFailed'; + const ERROR_NETMOUNT_NO_DRIVER = 'errNetMountNoDriver'; + const ERROR_NETUNMOUNT = 'errNetUnMount'; + const ERROR_NOT_ARCHIVE = 'errNoArchive'; + const ERROR_NOT_DIR = 'errNotFolder'; + const ERROR_NOT_FILE = 'errNotFile'; + const ERROR_NOT_REPLACE = 'errNotReplace'; // Object "$1" already exists at this location and can not be replaced with object of another type. + const ERROR_NOT_UTF8_CONTENT = 'errNotUTF8Content'; + const ERROR_OPEN = 'errOpen'; + const ERROR_PERM_DENIED = 'errPerm'; + const ERROR_REAUTH_REQUIRE = 'errReauthRequire'; // 'Re-authorization is required.' + const ERROR_RENAME = 'errRename'; + const ERROR_REPLACE = 'errReplace'; // 'Unable to replace "$1".' + const ERROR_RESIZE = 'errResize'; + const ERROR_RESIZESIZE = 'errResizeSize'; + const ERROR_RM = 'errRm'; // 'Unable to remove "$1".' + const ERROR_RM_SRC = 'errRmSrc'; // 'Unable remove source file(s)' + const ERROR_SAVE = 'errSave'; + const ERROR_SEARCH_TIMEOUT = 'errSearchTimeout'; // 'Timed out while searching "$1". Search result is partial.' + const ERROR_SESSION_EXPIRES = 'errSessionExpires'; + const ERROR_TRGDIR_NOT_FOUND = 'errTrgFolderNotFound'; // 'Target folder "$1" not found.' + const ERROR_UNKNOWN = 'errUnknown'; + const ERROR_UNKNOWN_CMD = 'errUnknownCmd'; + const ERROR_UNSUPPORT_TYPE = 'errUsupportType'; + const ERROR_UPLOAD = 'errUpload'; // 'Upload error.' + const ERROR_UPLOAD_FILE = 'errUploadFile'; // 'Unable to upload "$1".' + const ERROR_UPLOAD_FILE_MIME = 'errUploadMime'; // 'File type not allowed.' + const ERROR_UPLOAD_FILE_SIZE = 'errUploadFileSize'; // 'File exceeds maximum allowed size.' + const ERROR_UPLOAD_NO_FILES = 'errUploadNoFiles'; // 'No files found for upload.' + const ERROR_UPLOAD_TEMP = 'errUploadTemp'; // 'Unable to make temporary file for upload.' + const ERROR_UPLOAD_TOTAL_SIZE = 'errUploadTotalSize'; // 'Data exceeds the maximum allowed size.' + const ERROR_UPLOAD_TRANSFER = 'errUploadTransfer'; // '"$1" transfer error.' + + /** + * Constructor + * + * @param array elFinder and roots configurations + * + * @author Dmitry (dio) Levashov + */ + public function __construct($opts) + { + // set default_charset + if (version_compare(PHP_VERSION, '5.6', '>=')) { + if (($_val = ini_get('iconv.internal_encoding')) && strtoupper($_val) !== 'UTF-8') { + ini_set('iconv.internal_encoding', ''); + } + if (($_val = ini_get('mbstring.internal_encoding')) && strtoupper($_val) !== 'UTF-8') { + ini_set('mbstring.internal_encoding', ''); + } + if (($_val = ini_get('internal_encoding')) && strtoupper($_val) !== 'UTF-8') { + ini_set('internal_encoding', ''); + } + } else { + if (function_exists('iconv_set_encoding') && strtoupper(iconv_get_encoding('internal_encoding')) !== 'UTF-8') { + iconv_set_encoding('internal_encoding', 'UTF-8'); + } + if (function_exists('mb_internal_encoding') && strtoupper(mb_internal_encoding()) !== 'UTF-8') { + mb_internal_encoding('UTF-8'); + } + } + ini_set('default_charset', 'UTF-8'); + + // define accept constant of server commands path + !defined('ELFINDER_TAR_PATH') && define('ELFINDER_TAR_PATH', 'tar'); + !defined('ELFINDER_GZIP_PATH') && define('ELFINDER_GZIP_PATH', 'gzip'); + !defined('ELFINDER_BZIP2_PATH') && define('ELFINDER_BZIP2_PATH', 'bzip2'); + !defined('ELFINDER_XZ_PATH') && define('ELFINDER_XZ_PATH', 'xz'); + !defined('ELFINDER_ZIP_PATH') && define('ELFINDER_ZIP_PATH', 'zip'); + !defined('ELFINDER_UNZIP_PATH') && define('ELFINDER_UNZIP_PATH', 'unzip'); + !defined('ELFINDER_RAR_PATH') && define('ELFINDER_RAR_PATH', 'rar'); + !defined('ELFINDER_UNRAR_PATH') && define('ELFINDER_UNRAR_PATH', 'unrar'); + !defined('ELFINDER_7Z_PATH') && define('ELFINDER_7Z_PATH', (substr(PHP_OS, 0, 3) === 'WIN') ? '7z' : '7za'); + !defined('ELFINDER_CONVERT_PATH') && define('ELFINDER_CONVERT_PATH', 'convert'); + !defined('ELFINDER_IDENTIFY_PATH') && define('ELFINDER_IDENTIFY_PATH', 'identify'); + !defined('ELFINDER_EXIFTRAN_PATH') && define('ELFINDER_EXIFTRAN_PATH', 'exiftran'); + !defined('ELFINDER_JPEGTRAN_PATH') && define('ELFINDER_JPEGTRAN_PATH', 'jpegtran'); + !defined('ELFINDER_FFMPEG_PATH') && define('ELFINDER_FFMPEG_PATH', 'ffmpeg'); + + !defined('ELFINDER_DISABLE_ZIPEDITOR') && define('ELFINDER_DISABLE_ZIPEDITOR', false); + + // enable(true)/disable(false) handling postscript on ImageMagick + // Should be `false` as long as there is a Ghostscript vulnerability + // see https://artifex.com/news/ghostscript-security-resolved/ + !defined('ELFINDER_IMAGEMAGICK_PS') && define('ELFINDER_IMAGEMAGICK_PS', false); + + // for backward compat + $this->version = (string)self::$ApiVersion; + + // set error handler of WARNING, NOTICE + $errLevel = E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE | E_STRICT | E_RECOVERABLE_ERROR; + if (defined('E_DEPRECATED')) { + $errLevel |= E_DEPRECATED | E_USER_DEPRECATED; + } + set_error_handler('elFinder::phpErrorHandler', $errLevel); + + // Associative array of file pointers to close at the end of script: ['temp file pointer' => true] + $GLOBALS['elFinderTempFps'] = array(); + // Associative array of files to delete at the end of script: ['temp file path' => true] + $GLOBALS['elFinderTempFiles'] = array(); + // regist Shutdown function + register_shutdown_function(array('elFinder', 'onShutdown')); + + // convert PATH_INFO to GET query + if (!empty($_SERVER['PATH_INFO'])) { + $_ps = explode('/', trim($_SERVER['PATH_INFO'], '/')); + if (!isset($_GET['cmd'])) { + $_cmd = $_ps[0]; + if (isset($this->commands[$_cmd])) { + $_GET['cmd'] = $_cmd; + $_i = 1; + foreach (array_keys($this->commands[$_cmd]) as $_k) { + if (isset($_ps[$_i])) { + if (!isset($_GET[$_k])) { + $_GET[$_k] = $_ps[$_i++]; + } + } else { + break; + } + } + } + } + } + + // set elFinder instance + elFinder::$instance = $this; + + // setup debug mode + $this->debug = (isset($opts['debug']) && $opts['debug'] ? true : false); + if ($this->debug) { + error_reporting(defined('ELFINDER_DEBUG_ERRORLEVEL') ? ELFINDER_DEBUG_ERRORLEVEL : -1); + ini_set('display_errors', '1'); + // clear output buffer and stop output filters + while (ob_get_level() && ob_end_clean()) { + } + } + + if (!interface_exists('elFinderSessionInterface')) { + include_once dirname(__FILE__) . '/elFinderSessionInterface.php'; + } + + // session handler + if (!empty($opts['session']) && $opts['session'] instanceof elFinderSessionInterface) { + $this->session = $opts['session']; + } else { + $sessionOpts = array( + 'base64encode' => !empty($opts['base64encodeSessionData']), + 'keys' => array( + 'default' => !empty($opts['sessionCacheKey']) ? $opts['sessionCacheKey'] : 'elFinderCaches', + 'netvolume' => !empty($opts['netVolumesSessionKey']) ? $opts['netVolumesSessionKey'] : 'elFinderNetVolumes' + ) + ); + if (!class_exists('elFinderSession')) { + include_once dirname(__FILE__) . '/elFinderSession.php'; + } + $this->session = new elFinderSession($sessionOpts); + } + // try session start | restart + $this->session->start(); + + // 'netmount' added to handle requests synchronously on unmount + $sessionUseCmds = array('netmount'); + if (isset($opts['sessionUseCmds']) && is_array($opts['sessionUseCmds'])) { + $sessionUseCmds = array_merge($sessionUseCmds, $opts['sessionUseCmds']); + } + + // set self::$volumesCnt by HTTP header "X-elFinder-VolumesCntStart" + if (isset($_SERVER['HTTP_X_ELFINDER_VOLUMESCNTSTART']) && ($volumesCntStart = intval($_SERVER['HTTP_X_ELFINDER_VOLUMESCNTSTART']))) { + self::$volumesCnt = $volumesCntStart; + } + + $this->time = $this->utime(); + $this->sessionCloseEarlier = isset($opts['sessionCloseEarlier']) ? (bool)$opts['sessionCloseEarlier'] : true; + $this->sessionUseCmds = array_flip($sessionUseCmds); + $this->timeout = (isset($opts['timeout']) ? $opts['timeout'] : 0); + $this->uploadTempPath = (isset($opts['uploadTempPath']) ? $opts['uploadTempPath'] : ''); + $this->callbackWindowURL = (isset($opts['callbackWindowURL']) ? $opts['callbackWindowURL'] : ''); + $this->maxTargets = (isset($opts['maxTargets']) ? intval($opts['maxTargets']) : $this->maxTargets); + elFinder::$commonTempPath = (isset($opts['commonTempPath']) ? realpath($opts['commonTempPath']) : dirname(__FILE__) . '/.tmp'); + if (!is_writable(elFinder::$commonTempPath)) { + elFinder::$commonTempPath = sys_get_temp_dir(); + if (!is_writable(elFinder::$commonTempPath)) { + elFinder::$commonTempPath = ''; + } + } + if (isset($opts['connectionFlagsPath']) && is_writable($opts['connectionFlagsPath'] = realpath($opts['connectionFlagsPath']))) { + elFinder::$connectionFlagsPath = $opts['connectionFlagsPath']; + } else { + elFinder::$connectionFlagsPath = elFinder::$commonTempPath; + } + + if (!empty($opts['tmpLinkPath'])) { + elFinder::$tmpLinkPath = realpath($opts['tmpLinkPath']); + } + if (!empty($opts['tmpLinkUrl'])) { + elFinder::$tmpLinkUrl = $opts['tmpLinkUrl']; + } + if (!empty($opts['tmpLinkLifeTime'])) { + elFinder::$tmpLinkLifeTime = $opts['tmpLinkLifeTime']; + } + if (!empty($opts['textMimes']) && is_array($opts['textMimes'])) { + elfinder::$textMimes = $opts['textMimes']; + } + if (!empty($opts['urlUploadFilter'])) { + $this->urlUploadFilter = $opts['urlUploadFilter']; + } + $this->maxArcFilesSize = isset($opts['maxArcFilesSize']) ? intval($opts['maxArcFilesSize']) : 0; + $this->optionsNetVolumes = (isset($opts['optionsNetVolumes']) && is_array($opts['optionsNetVolumes'])) ? $opts['optionsNetVolumes'] : array(); + if (isset($opts['itemLockExpire'])) { + $this->itemLockExpire = intval($opts['itemLockExpire']); + } + + // deprecated settings + $this->netVolumesSessionKey = !empty($opts['netVolumesSessionKey']) ? $opts['netVolumesSessionKey'] : 'elFinderNetVolumes'; + self::$sessionCacheKey = !empty($opts['sessionCacheKey']) ? $opts['sessionCacheKey'] : 'elFinderCaches'; + + // check session cache + $_optsMD5 = md5(json_encode($opts['roots'])); + if ($this->session->get('_optsMD5') !== $_optsMD5) { + $this->session->set('_optsMD5', $_optsMD5); + } + + // setlocale and global locale regists to elFinder::locale + self::$locale = !empty($opts['locale']) ? $opts['locale'] : (substr(PHP_OS, 0, 3) === 'WIN' ? 'C' : 'en_US.UTF-8'); + if (false === setlocale(LC_ALL, self::$locale)) { + self::$locale = setlocale(LC_ALL, '0'); + } + + // set defaultMimefile + elFinder::$defaultMimefile = isset($opts['defaultMimefile']) ? $opts['defaultMimefile'] : ''; + + // set memoryLimitGD + elFinder::$memoryLimitGD = isset($opts['memoryLimitGD']) ? $opts['memoryLimitGD'] : 0; + + // set flag of throwErrorOnExec + // `true` need `try{}` block for `$connector->run();` + $this->throwErrorOnExec = !empty($opts['throwErrorOnExec']); + + // set archivers + elFinder::$archivers = isset($opts['archivers']) && is_array($opts['archivers']) ? $opts['archivers'] : array(); + + // set utf8Encoder + if (isset($opts['utf8Encoder']) && is_callable($opts['utf8Encoder'])) { + $this->utf8Encoder = $opts['utf8Encoder']; + } + + // bind events listeners + if (!empty($opts['bind']) && is_array($opts['bind'])) { + $_req = $_SERVER["REQUEST_METHOD"] == 'POST' ? $_POST : $_GET; + $_reqCmd = isset($_req['cmd']) ? $_req['cmd'] : ''; + foreach ($opts['bind'] as $cmd => $handlers) { + $doRegist = (strpos($cmd, '*') !== false); + if (!$doRegist) { + $doRegist = ($_reqCmd && in_array($_reqCmd, array_map('self::getCmdOfBind', explode(' ', $cmd)))); + } + if ($doRegist) { + // for backward compatibility + if (!is_array($handlers)) { + $handlers = array($handlers); + } else { + if (count($handlers) === 2 && is_callable($handlers)) { + $handlers = array($handlers); + } + } + foreach ($handlers as $handler) { + if ($handler) { + if (is_string($handler) && strpos($handler, '.')) { + list($_domain, $_name, $_method) = array_pad(explode('.', $handler), 3, ''); + if (strcasecmp($_domain, 'plugin') === 0) { + if ($plugin = $this->getPluginInstance($_name, isset($opts['plugin'][$_name]) ? $opts['plugin'][$_name] : array()) + and method_exists($plugin, $_method)) { + $this->bind($cmd, array($plugin, $_method)); + } + } + } else { + $this->bind($cmd, $handler); + } + } + } + } + } + } + + if (!isset($opts['roots']) || !is_array($opts['roots'])) { + $opts['roots'] = array(); + } + + // try to enable elFinderVolumeFlysystemZipArchiveNetmount to zip editing + if (empty(elFinder::$netDrivers['ziparchive'])) { + elFinder::$netDrivers['ziparchive'] = 'FlysystemZipArchiveNetmount'; + } + + // check for net volumes stored in session + $netVolumes = $this->getNetVolumes(); + foreach ($netVolumes as $key => $root) { + if (!isset($root['id'])) { + // given fixed unique id + if (!$root['id'] = $this->getNetVolumeUniqueId($netVolumes)) { + $this->mountErrors[] = 'Netmount Driver "' . $root['driver'] . '" : Could\'t given volume id.'; + continue; + } + } + $root['_isNetVolume'] = true; + $opts['roots'][$key] = $root; + } + + // "mount" volumes + foreach ($opts['roots'] as $i => $o) { + $class = 'elFinderVolume' . (isset($o['driver']) ? $o['driver'] : ''); + + if (class_exists($class)) { + /* @var elFinderVolumeDriver $volume */ + $volume = new $class(); + + try { + if ($this->maxArcFilesSize && (empty($o['maxArcFilesSize']) || $this->maxArcFilesSize < $o['maxArcFilesSize'])) { + $o['maxArcFilesSize'] = $this->maxArcFilesSize; + } + // pass session handler + $volume->setSession($this->session); + if (!$this->default) { + $volume->setNeedOnline(true); + } + if ($volume->mount($o)) { + // unique volume id (ends on "_") - used as prefix to files hash + $id = $volume->id(); + + $this->volumes[$id] = $volume; + if ((!$this->default || $volume->root() !== $volume->defaultPath()) && $volume->isReadable()) { + $this->default = $volume; + } + } else { + if (!empty($o['_isNetVolume'])) { + $this->removeNetVolume($i, $volume); + } + $this->mountErrors[] = 'Driver "' . $class . '" : ' . implode(' ', $volume->error()); + } + } catch (Exception $e) { + if (!empty($o['_isNetVolume'])) { + $this->removeNetVolume($i, $volume); + } + $this->mountErrors[] = 'Driver "' . $class . '" : ' . $e->getMessage(); + } + } else { + if (!empty($o['_isNetVolume'])) { + $this->removeNetVolume($i, $volume); + } + $this->mountErrors[] = 'Driver "' . $class . '" does not exist'; + } + } + + // if at least one readable volume - ii desu >_< + $this->loaded = !empty($this->default); + + // restore error handler for now + restore_error_handler(); + } + + /** + * Return elFinder session wrapper instance + * + * @return elFinderSessionInterface + **/ + public function getSession() + { + return $this->session; + } + + /** + * Return true if fm init correctly + * + * @return bool + * @author Dmitry (dio) Levashov + **/ + public function loaded() + { + return $this->loaded; + } + + /** + * Return version (api) number + * + * @return string + * @author Dmitry (dio) Levashov + **/ + public function version() + { + return self::$ApiVersion; + } + + /** + * Return revision (api) number + * + * @return string + * @author Naoki Sawada + **/ + public function revision() + { + return self::$ApiRevision; + } + + /** + * Add handler to elFinder command + * + * @param string command name + * @param string|array callback name or array(object, method) + * + * @return elFinder + * @author Dmitry (dio) Levashov + **/ + public function bind($cmd, $handler) + { + $allCmds = array_keys($this->commands); + $cmds = array(); + foreach (explode(' ', $cmd) as $_cmd) { + if ($_cmd !== '') { + if ($all = strpos($_cmd, '*') !== false) { + list(, $sub) = array_pad(explode('.', $_cmd), 2, ''); + if ($sub) { + $sub = str_replace('\'', '\\\'', $sub); + $subs = array_fill(0, count($allCmds), $sub); + $cmds = array_merge($cmds, array_map(array('elFinder', 'addSubToBindName'), $allCmds, $subs)); + } else { + $cmds = array_merge($cmds, $allCmds); + } + } else { + $cmds[] = $_cmd; + } + } + } + $cmds = array_unique($cmds); + + foreach ($cmds as $cmd) { + if (!isset($this->listeners[$cmd])) { + $this->listeners[$cmd] = array(); + } + + if (is_callable($handler)) { + $this->listeners[$cmd][] = $handler; + } + } + + return $this; + } + + /** + * Remove event (command exec) handler + * + * @param string command name + * @param string|array callback name or array(object, method) + * + * @return elFinder + * @author Dmitry (dio) Levashov + **/ + public function unbind($cmd, $handler) + { + if (!empty($this->listeners[$cmd])) { + foreach ($this->listeners[$cmd] as $i => $h) { + if ($h === $handler) { + unset($this->listeners[$cmd][$i]); + return $this; + } + } + } + return $this; + } + + /** + * Trigger binded functions + * + * @param string $cmd binded command name + * @param array $vars variables to pass to listeners + * @param array $errors array into which the error is written + */ + public function trigger($cmd, $vars, &$errors) + { + if (!empty($this->listeners[$cmd])) { + foreach ($this->listeners[$cmd] as $handler) { + $_res = call_user_func_array($handler, $vars); + if ($_res && is_array($_res)) { + $_err = !empty($_res['error'])? $_res['error'] : (!empty($_res['warning'])? $_res['warning'] : null); + if ($_err) { + if (is_array($_err)) { + $errors = array_merge($errors, $_err); + } else { + $errors[] = (string)$_err; + } + if ($_res['error']) { + throw elFinderTriggerException(); + } + } + } + } + } + } + + /** + * Return true if command exists + * + * @param string command name + * + * @return bool + * @author Dmitry (dio) Levashov + **/ + public function commandExists($cmd) + { + return $this->loaded && isset($this->commands[$cmd]) && method_exists($this, $cmd); + } + + /** + * Return root - file's owner (public func of volume()) + * + * @param string file hash + * + * @return elFinderVolumeDriver + * @author Naoki Sawada + */ + public function getVolume($hash) + { + return $this->volume($hash); + } + + /** + * Return command required arguments info + * + * @param string command name + * + * @return array + * @author Dmitry (dio) Levashov + **/ + public function commandArgsList($cmd) + { + if ($this->commandExists($cmd)) { + $list = $this->commands[$cmd]; + $list['reqid'] = false; + } else { + $list = array(); + } + return $list; + } + + private function session_expires() + { + + if (!$last = $this->session->get(':LAST_ACTIVITY')) { + $this->session->set(':LAST_ACTIVITY', time()); + return false; + } + + if (($this->timeout > 0) && (time() - $last > $this->timeout)) { + return true; + } + + $this->session->set(':LAST_ACTIVITY', time()); + return false; + } + + /** + * Exec command and return result + * + * @param string $cmd command name + * @param array $args command arguments + * + * @return array + * @throws elFinderAbortException|Exception + * @author Dmitry (dio) Levashov + **/ + public function exec($cmd, $args) + { + // set error handler of WARNING, NOTICE + set_error_handler('elFinder::phpErrorHandler', E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE); + + // set current request args + self::$currentArgs = $args; + + if (!$this->loaded) { + return array('error' => $this->error(self::ERROR_CONF, self::ERROR_CONF_NO_VOL)); + } + + if ($this->session_expires()) { + return array('error' => $this->error(self::ERROR_SESSION_EXPIRES)); + } + + if (!$this->commandExists($cmd)) { + return array('error' => $this->error(self::ERROR_UNKNOWN_CMD)); + } + + // check request id + $args['reqid'] = preg_replace('[^0-9a-fA-F]', '', !empty($args['reqid']) ? $args['reqid'] : (!empty($_SERVER['HTTP_X_ELFINDERREQID']) ? $_SERVER['HTTP_X_ELFINDERREQID'] : '')); + + // to abort this request + if ($cmd === 'abort') { + $this->abort($args); + return array('error' => 0); + } + + // make flag file and set self::$abortCheckFile + if ($args['reqid']) { + $this->abort(array('makeFile' => $args['reqid'])); + } + + if (!empty($args['mimes']) && is_array($args['mimes'])) { + foreach ($this->volumes as $id => $v) { + $this->volumes[$id]->setMimesFilter($args['mimes']); + } + } + + // regist shutdown function as fallback + register_shutdown_function(array($this, 'itemAutoUnlock')); + + // detect destination dirHash and volume + $dstVolume = false; + $dst = !empty($args['target']) ? $args['target'] : (!empty($args['dst']) ? $args['dst'] : ''); + if ($dst) { + $dstVolume = $this->volume($dst); + } else if (isset($args['targets']) && is_array($args['targets']) && isset($args['targets'][0])) { + $dst = $args['targets'][0]; + $dstVolume = $this->volume($dst); + if ($dstVolume && ($_stat = $dstVolume->file($dst)) && !empty($_stat['phash'])) { + $dst = $_stat['phash']; + } else { + $dst = ''; + } + } else if ($cmd === 'open') { + // for initial open without args `target` + $dstVolume = $this->default; + $dst = $dstVolume->defaultPath(); + } + + $result = null; + + // call pre handlers for this command + $args['sessionCloseEarlier'] = isset($this->sessionUseCmds[$cmd]) ? false : $this->sessionCloseEarlier; + if (!empty($this->listeners[$cmd . '.pre'])) { + foreach ($this->listeners[$cmd . '.pre'] as $handler) { + $_res = call_user_func_array($handler, array($cmd, &$args, $this, $dstVolume)); + if (is_array($_res)) { + if (!empty($_res['preventexec'])) { + $result = array('error' => true); + if ($cmd === 'upload' && !empty($args['node'])) { + $result['callback'] = array( + 'node' => $args['node'], + 'bind' => $cmd + ); + } + if (!empty($_res['results']) && is_array($_res['results'])) { + $result = array_merge($result, $_res['results']); + } + break; + } + } + } + } + + // unlock session data for multiple access + if ($this->sessionCloseEarlier && $args['sessionCloseEarlier']) { + $this->session->close(); + // deprecated property + elFinder::$sessionClosed = true; + } + + if (substr(PHP_OS, 0, 3) === 'WIN') { + // set time out + elFinder::extendTimeLimit(300); + } + + if (!is_array($result)) { + try { + $result = $this->$cmd($args); + } catch (elFinderAbortException $e) { + throw $e; + } catch (Exception $e) { + $result = array( + 'error' => htmlspecialchars($e->getMessage()), + 'sync' => true + ); + if ($this->throwErrorOnExec) { + throw $e; + } + } + } + + // check change dstDir + $changeDst = false; + if ($dst && $dstVolume && (!empty($result['added']) || !empty($result['removed']))) { + $changeDst = true; + } + + foreach ($this->volumes as $volume) { + $removed = $volume->removed(); + if (!empty($removed)) { + if (!isset($result['removed'])) { + $result['removed'] = array(); + } + $result['removed'] = array_merge($result['removed'], $removed); + if (!$changeDst && $dst && $dstVolume && $volume === $dstVolume) { + $changeDst = true; + } + } + $added = $volume->added(); + if (!empty($added)) { + if (!isset($result['added'])) { + $result['added'] = array(); + } + $result['added'] = array_merge($result['added'], $added); + if (!$changeDst && $dst && $dstVolume && $volume === $dstVolume) { + $changeDst = true; + } + } + $volume->resetResultStat(); + } + + // dstDir is changed + if ($changeDst) { + if ($dstDir = $dstVolume->dir($dst)) { + if (!isset($result['changed'])) { + $result['changed'] = array(); + } + $result['changed'][] = $dstDir; + } + } + + // call handlers for this command + if (!empty($this->listeners[$cmd])) { + foreach ($this->listeners[$cmd] as $handler) { + if (call_user_func_array($handler, array($cmd, &$result, $args, $this, $dstVolume))) { + // handler return true to force sync client after command completed + $result['sync'] = true; + } + } + } + + // replace removed files info with removed files hashes + if (!empty($result['removed'])) { + $removed = array(); + foreach ($result['removed'] as $file) { + $removed[] = $file['hash']; + } + $result['removed'] = array_unique($removed); + } + // remove hidden files and filter files by mimetypes + if (!empty($result['added'])) { + $result['added'] = $this->filter($result['added']); + } + // remove hidden files and filter files by mimetypes + if (!empty($result['changed'])) { + $result['changed'] = $this->filter($result['changed']); + } + // add toasts + if ($this->toastMessages) { + $result['toasts'] = array_merge(((isset($result['toasts']) && is_array($result['toasts']))? $result['toasts'] : array()), $this->toastMessages); + } + + if ($this->debug || !empty($args['debug'])) { + $result['debug'] = array( + 'connector' => 'php', + 'phpver' => PHP_VERSION, + 'time' => $this->utime() - $this->time, + 'memory' => (function_exists('memory_get_peak_usage') ? ceil(memory_get_peak_usage() / 1024) . 'Kb / ' : '') . ceil(memory_get_usage() / 1024) . 'Kb / ' . ini_get('memory_limit'), + 'upload' => $this->uploadDebug, + 'volumes' => array(), + 'mountErrors' => $this->mountErrors + ); + + foreach ($this->volumes as $id => $volume) { + $result['debug']['volumes'][] = $volume->debug(); + } + } + + // remove sesstion var 'urlContentSaveIds' + if ($this->removeContentSaveIds) { + $urlContentSaveIds = $this->session->get('urlContentSaveIds', array()); + foreach (array_keys($this->removeContentSaveIds) as $contentSaveId) { + if (isset($urlContentSaveIds[$contentSaveId])) { + unset($urlContentSaveIds[$contentSaveId]); + } + } + if ($urlContentSaveIds) { + $this->session->set('urlContentSaveIds', $urlContentSaveIds); + } else { + $this->session->remove('urlContentSaveIds'); + } + } + + foreach ($this->volumes as $volume) { + $volume->saveSessionCache(); + $volume->umount(); + } + + // unlock locked items + $this->itemAutoUnlock(); + + // custom data + if ($this->customData !== null) { + $result['customData'] = $this->customData ? json_encode($this->customData) : ''; + } + + if (!empty($result['debug'])) { + $result['debug']['backendErrors'] = elFinder::$phpErrors; + } + elFinder::$phpErrors = array(); + restore_error_handler(); + + if (!empty($result['callback'])) { + $result['callback']['json'] = json_encode($result); + $this->callback($result['callback']); + return array(); + } else { + return $result; + } + } + + /** + * Return file real path + * + * @param string $hash file hash + * + * @return string + * @author Dmitry (dio) Levashov + **/ + public function realpath($hash) + { + if (($volume = $this->volume($hash)) == false) { + return false; + } + return $volume->realpath($hash); + } + + /** + * Sets custom data(s). + * + * @param string|array $key The key or data array + * @param mixed $val The value + * + * @return self ( elFinder instance ) + */ + public function setCustomData($key, $val = null) + { + if (is_array($key)) { + foreach ($key as $k => $v) { + $this->customData[$k] = $v; + } + } else { + $this->customData[$key] = $val; + } + return $this; + } + + /** + * Removes a custom data. + * + * @param string $key The key + * + * @return self ( elFinder instance ) + */ + public function removeCustomData($key) + { + $this->customData[$key] = null; + return $this; + } + + /** + * Update sesstion value of a NetVolume option + * + * @param string $netKey + * @param string $optionKey + * @param mixed $val + * + * @return bool + */ + public function updateNetVolumeOption($netKey, $optionKey, $val) + { + $netVolumes = $this->getNetVolumes(); + if (is_string($netKey) && isset($netVolumes[$netKey]) && is_string($optionKey)) { + $netVolumes[$netKey][$optionKey] = $val; + $this->saveNetVolumes($netVolumes); + return true; + } + return false; + } + + /** + * remove of session var "urlContentSaveIds" + * + * @param string $id + */ + public function removeUrlContentSaveId($id) + { + $this->removeContentSaveIds[$id] = true; + } + + /** + * Return network volumes config. + * + * @return array + * @author Dmitry (dio) Levashov + */ + protected function getNetVolumes() + { + if ($data = $this->session->get('netvolume', array())) { + return $data; + } + return array(); + } + + /** + * Save network volumes config. + * + * @param array $volumes volumes config + * + * @return void + * @author Dmitry (dio) Levashov + */ + protected function saveNetVolumes($volumes) + { + $this->session->set('netvolume', $volumes); + } + + /** + * Remove netmount volume + * + * @param string $key netvolume key + * @param object $volume volume driver instance + * + * @return bool + */ + protected function removeNetVolume($key, $volume) + { + $netVolumes = $this->getNetVolumes(); + $res = true; + if (is_object($volume) && method_exists($volume, 'netunmount')) { + $res = $volume->netunmount($netVolumes, $key); + $volume->clearSessionCache(); + } + if ($res) { + if (is_string($key) && isset($netVolumes[$key])) { + unset($netVolumes[$key]); + $this->saveNetVolumes($netVolumes); + return true; + } + } + return false; + } + + /** + * Get plugin instance & set to $this->plugins + * + * @param string $name Plugin name (dirctory name) + * @param array $opts Plugin options (optional) + * + * @return object | bool Plugin object instance Or false + * @author Naoki Sawada + */ + protected function getPluginInstance($name, $opts = array()) + { + $key = strtolower($name); + if (!isset($this->plugins[$key])) { + $class = 'elFinderPlugin' . $name; + // to try auto load + if (!class_exists($class)) { + $p_file = dirname(__FILE__) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $name . DIRECTORY_SEPARATOR . 'plugin.php'; + if (is_file($p_file)) { + include_once $p_file; + } + } + if (class_exists($class, false)) { + $this->plugins[$key] = new $class($opts); + } else { + $this->plugins[$key] = false; + } + } + return $this->plugins[$key]; + } + + /***************************************************************************/ + /* commands */ + /***************************************************************************/ + + /** + * Normalize error messages + * + * @return array + * @author Dmitry (dio) Levashov + **/ + public function error() + { + $errors = array(); + + foreach (func_get_args() as $msg) { + if (is_array($msg)) { + $errors = array_merge($errors, $msg); + } else { + $errors[] = $msg; + } + } + + return count($errors) ? $errors : array(self::ERROR_UNKNOWN); + } + + /** + * @param $args + * + * @return array + * @throws elFinderAbortException + */ + protected function netmount($args) + { + $options = array(); + $protocol = $args['protocol']; + $toast = ''; + + if ($protocol === 'netunmount') { + if (!empty($args['user']) && $volume = $this->volume($args['user'])) { + if ($this->removeNetVolume($args['host'], $volume)) { + return array('removed' => array(array('hash' => $volume->root()))); + } + } + return array('sync' => true, 'error' => $this->error(self::ERROR_NETUNMOUNT)); + } + + $driver = isset(self::$netDrivers[$protocol]) ? self::$netDrivers[$protocol] : ''; + $class = 'elFinderVolume' . $driver; + + if (!class_exists($class)) { + return array('error' => $this->error(self::ERROR_NETMOUNT, $args['host'], self::ERROR_NETMOUNT_NO_DRIVER)); + } + + if (!$args['path']) { + $args['path'] = '/'; + } + + foreach ($args as $k => $v) { + if ($k != 'options' && $k != 'protocol' && $v) { + $options[$k] = $v; + } + } + + if (is_array($args['options'])) { + foreach ($args['options'] as $key => $value) { + $options[$key] = $value; + } + } + + /* @var elFinderVolumeDriver $volume */ + $volume = new $class(); + + // pass session handler + $volume->setSession($this->session); + + $volume->setNeedOnline(true); + + if (is_callable(array($volume, 'netmountPrepare'))) { + $options = $volume->netmountPrepare($options); + if (isset($options['exit'])) { + if ($options['exit'] === 'callback') { + $this->callback($options['out']); + } + return $options; + } + if (!empty($options['toast'])) { + $toast = $options['toast']; + unset($options['toast']); + } + } + + $netVolumes = $this->getNetVolumes(); + + if (!isset($options['id'])) { + // given fixed unique id + if (!$options['id'] = $this->getNetVolumeUniqueId($netVolumes)) { + return array('error' => $this->error(self::ERROR_NETMOUNT, $args['host'], 'Could\'t given volume id.')); + } + } + + // load additional volume root options + if (!empty($this->optionsNetVolumes['*'])) { + $options = array_merge($this->optionsNetVolumes['*'], $options); + } + if (!empty($this->optionsNetVolumes[$protocol])) { + $options = array_merge($this->optionsNetVolumes[$protocol], $options); + } + + if (!$key = $volume->netMountKey) { + $key = md5($protocol . '-' . serialize($options)); + } + $options['netkey'] = $key; + + if (!isset($netVolumes[$key]) && $volume->mount($options)) { + // call post-process function of netmount + if (is_callable(array($volume, 'postNetmount'))) { + $volume->postNetmount($options); + } + $options['driver'] = $driver; + $netVolumes[$key] = $options; + $this->saveNetVolumes($netVolumes); + $rootstat = $volume->file($volume->root()); + $res = array('added' => array($rootstat)); + if ($toast) { + $res['toast'] = $toast; + } + return $res; + } else { + $this->removeNetVolume(null, $volume); + return array('error' => $this->error(self::ERROR_NETMOUNT, $args['host'], implode(' ', $volume->error()))); + } + } + + /** + * "Open" directory + * Return array with following elements + * - cwd - opened dir info + * - files - opened dir content [and dirs tree if $args[tree]] + * - api - api version (if $args[init]) + * - uplMaxSize - if $args[init] + * - error - on failed + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function open($args) + { + $target = $args['target']; + $init = !empty($args['init']); + $tree = !empty($args['tree']); + $volume = $this->volume($target); + $cwd = $volume ? $volume->dir($target) : false; + $hash = $init ? 'default folder' : '#' . $target; + $compare = ''; + + // on init request we can get invalid dir hash - + // dir which can not be opened now, but remembered by client, + // so open default dir + if ((!$cwd || !$cwd['read']) && $init) { + $volume = $this->default; + $target = $volume->defaultPath(); + $cwd = $volume->dir($target); + } + + if (!$cwd) { + return array('error' => $this->error(self::ERROR_OPEN, $hash, self::ERROR_DIR_NOT_FOUND)); + } + if (!$cwd['read']) { + return array('error' => $this->error(self::ERROR_OPEN, $hash, self::ERROR_PERM_DENIED)); + } + + $files = array(); + + // get current working directory files list + if (($ls = $volume->scandir($cwd['hash'])) === false) { + return array('error' => $this->error(self::ERROR_OPEN, $cwd['name'], $volume->error())); + } + + if (isset($cwd['dirs']) && $cwd['dirs'] != 1) { + $cwd = $volume->dir($target); + } + + // get other volume root + if ($tree) { + foreach ($this->volumes as $id => $v) { + $files[] = $v->file($v->root()); + } + } + + // long polling mode + if ($args['compare']) { + $sleep = max(1, (int)$volume->getOption('lsPlSleep')); + $standby = (int)$volume->getOption('plStandby'); + if ($standby > 0 && $sleep > $standby) { + $standby = $sleep; + } + $limit = max(0, floor($standby / $sleep)) + 1; + do { + elFinder::extendTimeLimit(30 + $sleep); + $_mtime = 0; + foreach ($ls as $_f) { + if (isset($_f['ts'])) { + $_mtime = max($_mtime, $_f['ts']); + } + } + $compare = strval(count($ls)) . ':' . strval($_mtime); + if ($compare !== $args['compare']) { + break; + } + if (--$limit) { + sleep($sleep); + $volume->clearstatcache(); + if (($ls = $volume->scandir($cwd['hash'])) === false) { + break; + } + } + } while ($limit); + if ($ls === false) { + return array('error' => $this->error(self::ERROR_OPEN, $cwd['name'], $volume->error())); + } + } + + if ($ls) { + if ($files) { + $files = array_merge($files, $ls); + } else { + $files = $ls; + } + } + + $result = array( + 'cwd' => $cwd, + 'options' => $volume->options($cwd['hash']), + 'files' => $files + ); + + if ($compare) { + $result['cwd']['compare'] = $compare; + } + + if (!empty($args['init'])) { + $result['api'] = sprintf('%.1F%03d', self::$ApiVersion, self::$ApiRevision); + $result['uplMaxSize'] = ini_get('upload_max_filesize'); + $result['uplMaxFile'] = ini_get('max_file_uploads'); + $result['netDrivers'] = array_keys(self::$netDrivers); + $result['maxTargets'] = $this->maxTargets; + if ($volume) { + $result['cwd']['root'] = $volume->root(); + } + if (elfinder::$textMimes) { + $result['textMimes'] = elfinder::$textMimes; + } + } + + return $result; + } + + /** + * Return dir files names list + * + * @param array command arguments + * + * @return array + * @author Dmitry (dio) Levashov + **/ + protected function ls($args) + { + $target = $args['target']; + $intersect = isset($args['intersect']) ? $args['intersect'] : array(); + + if (($volume = $this->volume($target)) == false + || ($list = $volume->ls($target, $intersect)) === false) { + return array('error' => $this->error(self::ERROR_OPEN, '#' . $target)); + } + return array('list' => $list); + } + + /** + * Return subdirs for required directory + * + * @param array command arguments + * + * @return array + * @author Dmitry (dio) Levashov + **/ + protected function tree($args) + { + $target = $args['target']; + + if (($volume = $this->volume($target)) == false + || ($tree = $volume->tree($target)) == false) { + return array('error' => $this->error(self::ERROR_OPEN, '#' . $target)); + } + + return array('tree' => $tree); + } + + /** + * Return parents dir for required directory + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function parents($args) + { + $target = $args['target']; + $until = $args['until']; + + if (($volume = $this->volume($target)) == false + || ($tree = $volume->parents($target, false, $until)) == false) { + return array('error' => $this->error(self::ERROR_OPEN, '#' . $target)); + } + + return array('tree' => $tree); + } + + /** + * Return new created thumbnails list + * + * @param array command arguments + * + * @return array + * @throws ImagickException + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function tmb($args) + { + + $result = array('images' => array()); + $targets = $args['targets']; + + foreach ($targets as $target) { + elFinder::checkAborted(); + + if (($volume = $this->volume($target)) != false + && (($tmb = $volume->tmb($target)) != false)) { + $result['images'][$target] = $tmb; + } + } + return $result; + } + + /** + * Download files/folders as an archive file + * 1st: Return srrsy contains download archive file info + * 2nd: Return array contains opened file pointer, root itself and required headers + * + * @param array command arguments + * + * @return array + * @throws Exception + * @author Naoki Sawada + */ + protected function zipdl($args) + { + $targets = $args['targets']; + $download = !empty($args['download']); + $h404 = 'HTTP/1.x 404 Not Found'; + $CriOS = isset($_SERVER['HTTP_USER_AGENT'])? (strpos($_SERVER['HTTP_USER_AGENT'], 'CriOS') !== false) : false; + + if (!$download) { + //1st: Return array contains download archive file info + $error = array(self::ERROR_ARCHIVE); + if (($volume = $this->volume($targets[0])) !== false) { + if ($dlres = $volume->zipdl($targets)) { + $path = $dlres['path']; + register_shutdown_function(array('elFinder', 'rmFileInDisconnected'), $path); + if (count($targets) === 1) { + $name = basename($volume->path($targets[0])); + } else { + $name = $dlres['prefix'] . '_Files'; + } + $name .= '.' . $dlres['ext']; + $uniqid = uniqid(); + $this->session->set('zipdl' . $uniqid, basename($path)); + $result = array( + 'zipdl' => array( + 'file' => $CriOS? basename($path) : $uniqid, + 'name' => $name, + 'mime' => $dlres['mime'] + ) + ); + return $result; + } + $error = array_merge($error, $volume->error()); + } + return array('error' => $error); + } else { + // 2nd: Return array contains opened file session key, root itself and required headers + + // Detect Chrome on iOS + // It has access twice on downloading + $CriOSinit = false; + if ($CriOS) { + $accept = isset($_SERVER['HTTP_ACCEPT'])? $_SERVER['HTTP_ACCEPT'] : ''; + if ($accept && $accept !== '*' && $accept !== '*/*') { + $CriOSinit = true; + } + } + // data check + if (count($targets) !== 4 || ($volume = $this->volume($targets[0])) == false || !($file = $CriOS? $targets[1] : $this->session->get('zipdl' . $targets[1]))) { + return array('error' => 'File not found', 'header' => $h404, 'raw' => true); + } + $path = $volume->getTempPath() . DIRECTORY_SEPARATOR . basename($file); + // remove session data of "zipdl..." + $this->session->remove('zipdl' . $targets[1]); + if (!$CriOSinit) { + // register auto delete on shutdown + $GLOBALS['elFinderTempFiles'][$path] = true; + } + if ($volume->commandDisabled('zipdl')) { + return array('error' => 'File not found', 'header' => $h404, 'raw' => true); + } + if (!is_readable($path) || !is_writable($path)) { + return array('error' => 'File not found', 'header' => $h404, 'raw' => true); + } + // for HTTP headers + $name = $targets[2]; + $mime = $targets[3]; + + $filenameEncoded = rawurlencode($name); + if (strpos($filenameEncoded, '%') === false) { // ASCII only + $filename = 'filename="' . $name . '"'; + } else { + $ua = $_SERVER['HTTP_USER_AGENT']; + if (preg_match('/MSIE [4-8]/', $ua)) { // IE < 9 do not support RFC 6266 (RFC 2231/RFC 5987) + $filename = 'filename="' . $filenameEncoded . '"'; + } elseif (strpos($ua, 'Chrome') === false && strpos($ua, 'Safari') !== false && preg_match('#Version/[3-5]#', $ua)) { // Safari < 6 + $filename = 'filename="' . str_replace('"', '', $name) . '"'; + } else { // RFC 6266 (RFC 2231/RFC 5987) + $filename = 'filename*=UTF-8\'\'' . $filenameEncoded; + } + } + + $fp = fopen($path, 'rb'); + $file = fstat($fp); + $result = array( + 'pointer' => $fp, + 'header' => array( + 'Content-Type: ' . $mime, + 'Content-Disposition: attachment; ' . $filename, + 'Content-Transfer-Encoding: binary', + 'Content-Length: ' . $file['size'], + 'Accept-Ranges: none', + 'Connection: close' + ) + ); + // add cache control headers + if ($cacheHeaders = $volume->getOption('cacheHeaders')) { + $result['header'] = array_merge($result['header'], $cacheHeaders); + } + return $result; + } + } + + /** + * Required to output file in browser when volume URL is not set + * Return array contains opened file pointer, root itself and required headers + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function file($args) + { + $target = $args['target']; + $download = !empty($args['download']); + $onetime = !empty($args['onetime']); + //$h304 = 'HTTP/1.1 304 Not Modified'; + $h403 = 'HTTP/1.0 403 Access Denied'; + $a403 = array('error' => 'Access Denied', 'header' => $h403, 'raw' => true); + $h404 = 'HTTP/1.0 404 Not Found'; + $a404 = array('error' => 'File not found', 'header' => $h404, 'raw' => true); + + if ($onetime) { + $volume = null; + $tmpdir = elFinder::$commonTempPath; + if (!$tmpdir || !is_file($tmpf = $tmpdir . DIRECTORY_SEPARATOR . 'ELF' . $target)) { + return $a404; + } + $GLOBALS['elFinderTempFiles'][$tmpf] = true; + if ($file = json_decode(file_get_contents($tmpf), true)) { + $src = base64_decode($file['file']); + if (!is_file($src) || !($fp = fopen($src, 'rb'))) { + return $a404; + } + if (strpos($src, $tmpdir) === 0) { + $GLOBALS['elFinderTempFiles'][$src] = true; + } + unset($file['file']); + $file['read'] = true; + $file['size'] = filesize($src); + } else { + return $a404; + } + } else { + if (($volume = $this->volume($target)) == false) { + return $a404; + } + + if ($volume->commandDisabled('file')) { + return $a403; + } + + if (($file = $volume->file($target)) == false) { + return $a404; + } + + if (!$file['read']) { + return $a404; + } + + $opts = array(); + if (!empty($_SERVER['HTTP_RANGE'])) { + $opts['httpheaders'] = array('Range: ' . $_SERVER['HTTP_RANGE']); + } + if (($fp = $volume->open($target, $opts)) == false) { + return $a404; + } + } + + // check aborted by user + elFinder::checkAborted(); + + // allow change MIME type by 'file.pre' callback functions + $mime = isset($args['mime']) ? $args['mime'] : $file['mime']; + if ($download || $onetime) { + $disp = 'attachment'; + } else { + $dispInlineRegex = $volume->getOption('dispInlineRegex'); + $inlineRegex = false; + if ($dispInlineRegex) { + $inlineRegex = '#' . str_replace('#', '\\#', $dispInlineRegex) . '#'; + try { + preg_match($inlineRegex, ''); + } catch (Exception $e) { + $inlineRegex = false; + } + } + if (!$inlineRegex) { + $inlineRegex = '#^(?:(?:image|text)|application/x-shockwave-flash$)#'; + } + $disp = preg_match($inlineRegex, $mime) ? 'inline' : 'attachment'; + } + + $filenameEncoded = rawurlencode($file['name']); + if (strpos($filenameEncoded, '%') === false) { // ASCII only + $filename = 'filename="' . $file['name'] . '"'; + } else { + $ua = isset($_SERVER['HTTP_USER_AGENT'])? $_SERVER['HTTP_USER_AGENT'] : ''; + if (preg_match('/MSIE [4-8]/', $ua)) { // IE < 9 do not support RFC 6266 (RFC 2231/RFC 5987) + $filename = 'filename="' . $filenameEncoded . '"'; + } elseif (strpos($ua, 'Chrome') === false && strpos($ua, 'Safari') !== false && preg_match('#Version/[3-5]#', $ua)) { // Safari < 6 + $filename = 'filename="' . str_replace('"', '', $file['name']) . '"'; + } else { // RFC 6266 (RFC 2231/RFC 5987) + $filename = 'filename*=UTF-8\'\'' . $filenameEncoded; + } + } + + if ($args['cpath'] && $args['reqid']) { + setcookie('elfdl' . $args['reqid'], '1', 0, $args['cpath']); + } + + $result = array( + 'volume' => $volume, + 'pointer' => $fp, + 'info' => $file, + 'header' => array( + 'Content-Type: ' . $mime, + 'Content-Disposition: ' . $disp . '; ' . $filename, + 'Content-Transfer-Encoding: binary', + 'Content-Length: ' . $file['size'], + 'Last-Modified: ' . gmdate('D, d M Y H:i:s T', $file['ts']), + 'Connection: close' + ) + ); + + if (!$onetime) { + // add cache control headers + if ($cacheHeaders = $volume->getOption('cacheHeaders')) { + $result['header'] = array_merge($result['header'], $cacheHeaders); + } + + // check 'xsendfile' + $xsendfile = $volume->getOption('xsendfile'); + $path = null; + if ($xsendfile) { + $info = stream_get_meta_data($fp); + if ($path = empty($info['uri']) ? null : $info['uri']) { + $basePath = rtrim($volume->getOption('xsendfilePath'), DIRECTORY_SEPARATOR); + if ($basePath) { + $root = rtrim($volume->getRootPath(), DIRECTORY_SEPARATOR); + if (strpos($path, $root) === 0) { + $path = $basePath . substr($path, strlen($root)); + } else { + $path = null; + } + } + } + } + if ($path) { + $result['header'][] = $xsendfile . ': ' . $path; + $result['info']['xsendfile'] = $xsendfile; + } + } + + // add "Content-Location" if file has url data + if (isset($file['url']) && $file['url'] && $file['url'] != 1) { + $result['header'][] = 'Content-Location: ' . $file['url']; + } + return $result; + } + + /** + * Count total files size + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function size($args) + { + $size = 0; + $files = 0; + $dirs = 0; + $itemCount = true; + $sizes = array(); + + foreach ($args['targets'] as $target) { + elFinder::checkAborted(); + if (($volume = $this->volume($target)) == false + || ($file = $volume->file($target)) == false + || !$file['read']) { + return array('error' => $this->error(self::ERROR_OPEN, '#' . $target)); + } + + $volRes = $volume->size($target); + if (is_array($volRes)) { + $sizeInfo = array('size' => 0, 'fileCnt' => 0, 'dirCnt' => 0); + if (!empty($volRes['size'])) { + $sizeInfo['size'] = $volRes['size']; + $size += $volRes['size']; + } + if (!empty($volRes['files'])) { + $sizeInfo['fileCnt'] = $volRes['files']; + } + if (!empty($volRes['dirs'])) { + $sizeInfo['dirCnt'] = $volRes['dirs']; + } + if ($itemCount) { + $files += $sizeInfo['fileCnt']; + $dirs += $sizeInfo['dirCnt']; + } + $sizes[$target] = $sizeInfo; + } else if (is_numeric($volRes)) { + $size += $volRes; + $files = $dirs = 'unknown'; + $itemCount = false; + } + } + return array('size' => $size, 'fileCnt' => $files, 'dirCnt' => $dirs, 'sizes' => $sizes); + } + + /** + * Create directory + * + * @param array command arguments + * + * @return array + * @author Dmitry (dio) Levashov + **/ + protected function mkdir($args) + { + $target = $args['target']; + $name = $args['name']; + $dirs = $args['dirs']; + if ($name === '' && !$dirs) { + return array('error' => $this->error(self::ERROR_INV_PARAMS, 'mkdir')); + } + + if (($volume = $this->volume($target)) == false) { + return array('error' => $this->error(self::ERROR_MKDIR, $name, self::ERROR_TRGDIR_NOT_FOUND, '#' . $target)); + } + if ($dirs) { + sort($dirs); + $reset = null; + $mkdirs = array(); + foreach ($dirs as $dir) { + $tgt =& $mkdirs; + $_names = explode('/', trim($dir, '/')); + foreach ($_names as $_key => $_name) { + if (!isset($tgt[$_name])) { + $tgt[$_name] = array(); + } + $tgt =& $tgt[$_name]; + } + $tgt =& $reset; + } + $res = $this->ensureDirsRecursively($volume, $target, $mkdirs); + $ret = array( + 'added' => $res['stats'], + 'hashes' => $res['hashes'] + ); + if ($res['error']) { + $ret['warning'] = $this->error(self::ERROR_MKDIR, $res['error'][0], $volume->error()); + } + return $ret; + } else { + return ($dir = $volume->mkdir($target, $name)) == false + ? array('error' => $this->error(self::ERROR_MKDIR, $name, $volume->error())) + : array('added' => array($dir)); + } + } + + /** + * Create empty file + * + * @param array command arguments + * + * @return array + * @author Dmitry (dio) Levashov + **/ + protected function mkfile($args) + { + $target = $args['target']; + $name = $args['name']; + + if (($volume = $this->volume($target)) == false) { + return array('error' => $this->error(self::ERROR_MKFILE, $name, self::ERROR_TRGDIR_NOT_FOUND, '#' . $target)); + } + + return ($file = $volume->mkfile($target, $args['name'])) == false + ? array('error' => $this->error(self::ERROR_MKFILE, $name, $volume->error())) + : array('added' => array($file)); + } + + /** + * Rename file, Accept multiple items >= API 2.1031 + * + * @param array $args + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + * @author Naoki Sawada + */ + protected function rename($args) + { + $target = $args['target']; + $name = $args['name']; + $query = (!empty($args['q']) && strpos($args['q'], '*') !== false) ? $args['q'] : ''; + $targets = !empty($args['targets'])? $args['targets'] : false; + $rms = array(); + $notfounds = array(); + $locked = array(); + $errs = array(); + $files = array(); + $removed = array(); + $res = array(); + $type = 'normal'; + + if (!($volume = $this->volume($target))) { + return array('error' => $this->error(self::ERROR_RENAME, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + } + + if ($targets) { + array_unshift($targets, $target); + foreach ($targets as $h) { + if ($rm = $volume->file($h)) { + if ($this->itemLocked($h)) { + $locked[] = $rm['name']; + } else { + $rm['realpath'] = $volume->realpath($h); + $rms[] = $rm; + } + } else { + $notfounds[] = '#' . $h; + } + } + if (!$rms) { + $res['error'] = array(); + if ($notfounds) { + $res['error'] = array(self::ERROR_RENAME, join(', ', $notfounds), self::ERROR_FILE_NOT_FOUND); + } + if ($locked) { + array_push($res['error'], self::ERROR_LOCKED, join(', ', $locked)); + } + return $res; + } + + $res['warning'] = array(); + if ($notfounds) { + array_push($res['warning'], self::ERROR_RENAME, join(', ', $notfounds), self::ERROR_FILE_NOT_FOUND); + } + if ($locked) { + array_push($res['warning'], self::ERROR_LOCKED, join(', ', $locked)); + } + + if ($query) { + // batch rename + $splits = elFinder::splitFileExtention($query); + if ($splits[1] && $splits[0] === '*') { + $type = 'extention'; + $name = $splits[1]; + } else if (strlen($splits[0]) > 1) { + if (substr($splits[0], -1) === '*') { + $type = 'prefix'; + $name = substr($splits[0], 0, strlen($splits[0]) - 1); + } else if (substr($splits[0], 0, 1) === '*') { + $type = 'suffix'; + $name = substr($splits[0], 1); + } + } + if ($type !== 'normal') { + if (!empty($this->listeners['rename.pre'])) { + $_args = array('name' => $name); + foreach ($this->listeners['rename.pre'] as $handler) { + $_res = call_user_func_array($handler, array('rename', &$_args, $this, $volume)); + if (!empty($_res['preventexec'])) { + break; + } + } + $name = $_args['name']; + } + } + } + foreach ($rms as $rm) { + if ($type === 'normal') { + $rname = $volume->uniqueName($volume->realpath($rm['phash']), $name, '', false); + } else { + $rname = $name; + if ($type === 'extention') { + $splits = elFinder::splitFileExtention($rm['name']); + $rname = $splits[0] . '.' . $name; + } else if ($type === 'prefix') { + $rname = $name . $rm['name']; + } else if ($type === 'suffix') { + $splits = elFinder::splitFileExtention($rm['name']); + $rname = $splits[0] . $name . ($splits[1] ? ('.' . $splits[1]) : ''); + } + $rname = $volume->uniqueName($volume->realpath($rm['phash']), $rname, '', true); + } + if ($file = $volume->rename($rm['hash'], $rname)) { + $files[] = $file; + $removed[] = $rm; + } else { + $errs[] = $rm['name']; + } + } + + if (!$files) { + $res['error'] = $this->error(self::ERROR_RENAME, join(', ', $errs), $volume->error()); + if (!$res['warning']) { + unset($res['warning']); + } + return $res; + } + if ($errs) { + array_push($res['warning'], self::ERROR_RENAME, join(', ', $errs), $volume->error()); + } + if (!$res['warning']) { + unset($res['warning']); + } + $res['added'] = $files; + $res['removed'] = $removed; + return $res; + } else { + if (!($rm = $volume->file($target))) { + return array('error' => $this->error(self::ERROR_RENAME, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + } + if ($this->itemLocked($target)) { + return array('error' => $this->error(self::ERROR_LOCKED, $rm['name'])); + } + $rm['realpath'] = $volume->realpath($target); + + $file = $volume->rename($target, $name); + if ($file === false) { + return array('error' => $this->error(self::ERROR_RENAME, $rm['name'], $volume->error())); + } else { + if ($file['hash'] !== $rm['hash']) { + return array('added' => array($file), 'removed' => array($rm)); + } else { + return array('changed' => array($file)); + } + } + } + } + + /** + * Duplicate file - create copy with "copy %d" suffix + * + * @param array $args command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function duplicate($args) + { + $targets = is_array($args['targets']) ? $args['targets'] : array(); + $result = array(); + $suffix = empty($args['suffix']) ? 'copy' : $args['suffix']; + + $this->itemLock($targets); + + foreach ($targets as $target) { + elFinder::checkAborted(); + + if (($volume = $this->volume($target)) == false + || ($src = $volume->file($target)) == false) { + $result['warning'] = $this->error(self::ERROR_COPY, '#' . $target, self::ERROR_FILE_NOT_FOUND); + break; + } + + if (($file = $volume->duplicate($target, $suffix)) == false) { + $result['warning'] = $this->error($volume->error()); + break; + } + } + + return $result; + } + + /** + * Remove dirs/files + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function rm($args) + { + $targets = is_array($args['targets']) ? $args['targets'] : array(); + $result = array('removed' => array()); + + foreach ($targets as $target) { + elFinder::checkAborted(); + + if (($volume = $this->volume($target)) == false) { + $result['warning'] = $this->error(self::ERROR_RM, '#' . $target, self::ERROR_FILE_NOT_FOUND); + break; + } + + if ($this->itemLocked($target)) { + $rm = $volume->file($target); + $result['warning'] = $this->error(self::ERROR_LOCKED, $rm['name']); + break; + } + + if (!$volume->rm($target)) { + $result['warning'] = $this->error($volume->error()); + break; + } + } + + return $result; + } + + /** + * Return has subdirs + * + * @param array command arguments + * + * @return array + * @author Dmitry Naoki Sawada + **/ + protected function subdirs($args) + { + + $result = array('subdirs' => array()); + $targets = $args['targets']; + + foreach ($targets as $target) { + if (($volume = $this->volume($target)) !== false) { + $result['subdirs'][$target] = $volume->subdirs($target) ? 1 : 0; + } + } + return $result; + } + + /** + * Gateway for custom contents editor + * + * @param array $args command arguments + * + * @return array + * @author Naoki Sawada + */ + protected function editor($args = array()) + { + /* @var elFinderEditor $editor */ + $name = $args['name']; + if (is_array($name)) { + $res = array(); + foreach ($name as $c) { + $class = 'elFinderEditor' . $c; + if (class_exists($class)) { + $editor = new $class($this, $args['args']); + $res[$c] = $editor->enabled(); + } else { + $res[$c] = 0; + } + } + return $res; + } else { + $class = 'elFinderEditor' . $name; + $method = ''; + if (class_exists($class)) { + $editor = new $class($this, $args['args']); + $method = $args['method']; + if ($editor->isAllowedMethod($method) && method_exists($editor, $method)) { + return $editor->$method(); + } + } + return array('error', $this->error(self::ERROR_UNKNOWN_CMD, 'editor.' . $name . '.' . $method)); + } + } + + /** + * Abort current request and make flag file to running check + * + * @param array $args + * + * @return void + */ + protected function abort($args = array()) + { + if (!elFinder::$connectionFlagsPath || $_SERVER['REQUEST_METHOD'] === 'HEAD') { + return; + } + $flagFile = elFinder::$connectionFlagsPath . DIRECTORY_SEPARATOR . 'elfreq%s'; + if (!empty($args['makeFile'])) { + self::$abortCheckFile = sprintf($flagFile, $args['makeFile']); + touch(self::$abortCheckFile); + $GLOBALS['elFinderTempFiles'][self::$abortCheckFile] = true; + return; + } + + $file = !empty($args['id']) ? sprintf($flagFile, $args['id']) : self::$abortCheckFile; + $file && is_file($file) && unlink($file); + } + + /** + * Get remote contents + * + * @param string $url target url + * @param int $timeout timeout (sec) + * @param int $redirect_max redirect max count + * @param string $ua + * @param resource $fp + * + * @return string, resource or bool(false) + * @retval string contents + * @retval resource conttents + * @rettval false error + * @author Naoki Sawada + **/ + protected function get_remote_contents(&$url, $timeout = 30, $redirect_max = 5, $ua = 'Mozilla/5.0', $fp = null) + { + if (preg_match('~^(?:ht|f)tps?://[-_.!\~*\'()a-z0-9;/?:\@&=+\$,%#\*\[\]]+~i', $url)) { + $info = parse_url($url); + $host = trim(strtolower($info['host']), '.'); + // do not support IPv6 address + if (preg_match('/^\[.*\]$/', $host)) { + return false; + } + // do not support non dot host + if (strpos($host, '.') === false) { + return false; + } + // do not support URL-encoded host + if (strpos($host, '%') !== false) { + return false; + } + // disallow including "localhost" and "localdomain" + if (preg_match('/\b(?:localhost|localdomain)\b/', $host)) { + return false; + } + // wildcard DNS (e.g xip.io) + if (preg_match('/0x[0-9a-f]+|[0-9]+(?:\.(?:0x[0-9a-f]+|[0-9]+)){1,3}/', $host)) { + $host = gethostbyname($host); + } + // check IPv4 local loopback, private network and link local + if (preg_match('/^0x[0-9a-f]+|[0-9]+(?:\.(?:0x[0-9a-f]+|[0-9]+)){1,3}$/', $host, $m)) { + $long = (int)sprintf('%u', ip2long($host)); + if (!$long) { + return false; + } + $local = (int)sprintf('%u', ip2long('127.255.255.255')) >> 24; + $prv1 = (int)sprintf('%u', ip2long('10.255.255.255')) >> 24; + $prv2 = (int)sprintf('%u', ip2long('172.31.255.255')) >> 20; + $prv3 = (int)sprintf('%u', ip2long('192.168.255.255')) >> 16; + $link = (int)sprintf('%u', ip2long('169.254.255.255')) >> 16; + + if ($long >> 24 === $local || $long >> 24 === $prv1 || $long >> 20 === $prv2 || $long >> 16 === $prv3 || $long >> 16 === $link) { + return false; + } + } + // dose not support 'user' and 'pass' for security reasons + $url = $info['scheme'].'://'.$host.(!empty($info['port'])? (':'.$info['port']) : '').$info['path'].(!empty($info['query'])? ('?'.$info['query']) : '').(!empty($info['fragment'])? ('#'.$info['fragment']) : ''); + // check by URL upload filter + if ($this->urlUploadFilter && is_callable($this->urlUploadFilter)) { + if (!call_user_func_array($this->urlUploadFilter, array($url, $this))) { + return false; + } + } + $method = (function_exists('curl_exec') && !ini_get('safe_mode') && !ini_get('open_basedir')) ? 'curl_get_contents' : 'fsock_get_contents'; + return $this->$method($url, $timeout, $redirect_max, $ua, $fp); + } + return false; + } + + /** + * Get remote contents with cURL + * + * @param string $url target url + * @param int $timeout timeout (sec) + * @param int $redirect_max redirect max count + * @param string $ua + * @param resource $outfp + * + * @return string, resource or bool(false) + * @retval string contents + * @retval resource conttents + * @retval false error + * @author Naoki Sawada + **/ + protected function curl_get_contents(&$url, $timeout, $redirect_max, $ua, $outfp) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_HEADER, false); + if ($outfp) { + curl_setopt($ch, CURLOPT_FILE, $outfp); + } else { + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); + } + curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1); + curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, $timeout); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); + curl_setopt($ch, CURLOPT_MAXREDIRS, $redirect_max); + curl_setopt($ch, CURLOPT_USERAGENT, $ua); + $result = curl_exec($ch); + $url = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + curl_close($ch); + return $outfp ? $outfp : $result; + } + + /** + * Get remote contents with fsockopen() + * + * @param string $url url + * @param int $timeout timeout (sec) + * @param int $redirect_max redirect max count + * @param string $ua + * @param resource $outfp + * + * @return string, resource or bool(false) + * @retval string contents + * @retval resource conttents + * @retval false error + * @throws elFinderAbortException + * @author Naoki Sawada + */ + protected function fsock_get_contents(&$url, $timeout, $redirect_max, $ua, $outfp) + { + $connect_timeout = 3; + $connect_try = 3; + $method = 'GET'; + $readsize = 4096; + $ssl = ''; + + $getSize = null; + $headers = ''; + + $arr = parse_url($url); + if (!$arr) { + // Bad request + return false; + } + if ($arr['scheme'] === 'https') { + $ssl = 'ssl://'; + } + + // query + $arr['query'] = isset($arr['query']) ? '?' . $arr['query'] : ''; + // port + $port = isset($arr['port']) ? $arr['port'] : ''; + $arr['port'] = $port ? $port : ($ssl ? 443 : 80); + + $url_base = $arr['scheme'] . '://' . $arr['host'] . ($port ? (':' . $port) : ''); + $url_path = isset($arr['path']) ? $arr['path'] : '/'; + $uri = $url_path . $arr['query']; + + $query = $method . ' ' . $uri . " HTTP/1.0\r\n"; + $query .= "Host: " . $arr['host'] . "\r\n"; + $query .= "Accept: */*\r\n"; + $query .= "Connection: close\r\n"; + if (!empty($ua)) $query .= "User-Agent: " . $ua . "\r\n"; + if (!is_null($getSize)) $query .= 'Range: bytes=0-' . ($getSize - 1) . "\r\n"; + + $query .= $headers; + + $query .= "\r\n"; + + $fp = $connect_try_count = 0; + while (!$fp && $connect_try_count < $connect_try) { + + $errno = 0; + $errstr = ""; + $fp = fsockopen( + $ssl . $arr['host'], + $arr['port'], + $errno, $errstr, $connect_timeout); + if ($fp) break; + $connect_try_count++; + if (connection_aborted()) { + throw new elFinderAbortException(); + } + sleep(1); // wait 1sec + } + + if (!$fp) { + return false; + } + + $fwrite = 0; + for ($written = 0; $written < strlen($query); $written += $fwrite) { + $fwrite = fwrite($fp, substr($query, $written)); + if (!$fwrite) { + break; + } + } + + if ($timeout) { + socket_set_timeout($fp, $timeout); + } + + $_response = ''; + $header = ''; + while ($_response !== "\r\n") { + $_response = fgets($fp, $readsize); + $header .= $_response; + }; + + $rccd = array_pad(explode(' ', $header, 2), 2, ''); // array('HTTP/1.1','200') + $rc = (int)$rccd[1]; + + $ret = false; + // Redirect + switch ($rc) { + case 307: // Temporary Redirect + case 303: // See Other + case 302: // Moved Temporarily + case 301: // Moved Permanently + $matches = array(); + if (preg_match('/^Location: (.+?)(#.+)?$/im', $header, $matches) && --$redirect_max > 0) { + $_url = $url; + $url = trim($matches[1]); + if (!preg_match('/^https?:\//', $url)) { // no scheme + if ($url[0] != '/') { // Relative path + // to Absolute path + $url = substr($url_path, 0, strrpos($url_path, '/')) . '/' . $url; + } + // add sheme,host + $url = $url_base . $url; + } + if ($_url === $url) { + sleep(1); + } + fclose($fp); + return $this->fsock_get_contents($url, $timeout, $redirect_max, $ua, $outfp); + } + break; + case 200: + $ret = true; + } + if (!$ret) { + fclose($fp); + return false; + } + + $body = ''; + if (!$outfp) { + $outfp = fopen('php://temp', 'rwb'); + $body = true; + } + while (fwrite($outfp, fread($fp, $readsize))) { + if ($timeout) { + $_status = socket_get_status($fp); + if ($_status['timed_out']) { + fclose($outfp); + fclose($fp); + return false; // Request Time-out + } + } + } + if ($body) { + rewind($outfp); + $body = stream_get_contents($outfp); + fclose($outfp); + $outfp = null; + } + + fclose($fp); + + return $outfp ? $outfp : $body; // Data + } + + /** + * Parse Data URI scheme + * + * @param string $str + * @param array $extTable + * @param array $args + * + * @return array + * @author Naoki Sawada + */ + protected function parse_data_scheme($str, $extTable, $args = null) + { + $data = $name = $mime = ''; + // Scheme 'data://' require `allow_url_fopen` and `allow_url_include` + if ($fp = fopen('data://' . substr($str, 5), 'rb')) { + if ($data = stream_get_contents($fp)) { + $meta = stream_get_meta_data($fp); + $mime = $meta['mediatype']; + } + fclose($fp); + } else if (preg_match('~^data:(.+?/.+?)?(?:;charset=.+?)?;base64,~', substr($str, 0, 128), $m)) { + $data = base64_decode(substr($str, strlen($m[0]))); + if ($m[1]) { + $mime = $m[1]; + } + } + if ($data) { + $ext = ($mime && isset($extTable[$mime])) ? '.' . $extTable[$mime] : ''; + // Set name if name eq 'image.png' and $args has 'name' array, e.g. clipboard data + if (is_array($args['name']) && isset($args['name'][0])) { + $name = $args['name'][0]; + if ($ext) { + $name = preg_replace('/\.[^.]*$/', '', $name); + } + } else { + $name = substr(md5($data), 0, 8); + } + $name .= $ext; + } else { + $data = $name = ''; + } + return array($data, $name); + } + + /** + * Detect file MIME Type by local path + * + * @param string $path Local path + * + * @return string file MIME Type + * @author Naoki Sawada + */ + protected function detectMimeType($path) + { + static $type, $finfo; + if (!$type) { + if (class_exists('finfo', false)) { + $tmpFileInfo = explode(';', finfo_file(finfo_open(FILEINFO_MIME), __FILE__)); + } else { + $tmpFileInfo = false; + } + $regexp = '/text\/x\-(php|c\+\+)/'; + if ($tmpFileInfo && preg_match($regexp, array_shift($tmpFileInfo))) { + $type = 'finfo'; + $finfo = finfo_open(FILEINFO_MIME); + } elseif (function_exists('mime_content_type') + && preg_match($regexp, array_shift(explode(';', mime_content_type(__FILE__))))) { + $type = 'mime_content_type'; + } elseif (function_exists('getimagesize')) { + $type = 'getimagesize'; + } else { + $type = 'none'; + } + } + + $mime = ''; + if ($type === 'finfo') { + $mime = finfo_file($finfo, $path); + } elseif ($type === 'mime_content_type') { + $mime = mime_content_type($path); + } elseif ($type === 'getimagesize') { + if ($img = getimagesize($path)) { + $mime = $img['mime']; + } + } + + if ($mime) { + $mime = explode(';', $mime); + $mime = trim($mime[0]); + + if (in_array($mime, array('application/x-empty', 'inode/x-empty'))) { + // finfo return this mime for empty files + $mime = 'text/plain'; + } elseif ($mime == 'application/x-zip') { + // http://elrte.org/redmine/issues/163 + $mime = 'application/zip'; + } + } + + return $mime ? $mime : 'unknown'; + } + + /** + * Detect file type extension by local path + * + * @param object $volume elFinderVolumeDriver instance + * @param string $path Local path + * @param string $name Filename to save + * + * @return string file type extension with dot + * @author Naoki Sawada + */ + protected function detectFileExtension($volume, $path, $name) + { + $mime = $this->detectMimeType($path); + if ($mime === 'unknown') { + $mime = 'application/octet-stream'; + } + $ext = $volume->getExtentionByMime($volume->mimeTypeNormalize($mime, $name)); + return $ext ? ('.' . $ext) : ''; + } + + /** + * Get temporary directory path + * + * @param string $volumeTempPath + * + * @return string + * @author Naoki Sawada + */ + private function getTempDir($volumeTempPath = null) + { + $testDirs = array(); + if ($this->uploadTempPath) { + $testDirs[] = rtrim(realpath($this->uploadTempPath), DIRECTORY_SEPARATOR); + } + if ($volumeTempPath) { + $testDirs[] = rtrim(realpath($volumeTempPath), DIRECTORY_SEPARATOR); + } + if (elFinder::$commonTempPath) { + $testDirs[] = elFinder::$commonTempPath; + } + $tempDir = ''; + foreach ($testDirs as $testDir) { + if (!$testDir || !is_dir($testDir)) continue; + if (is_writable($testDir)) { + $tempDir = $testDir; + $gc = time() - 3600; + foreach (glob($tempDir . DIRECTORY_SEPARATOR . 'ELF*') as $cf) { + if (filemtime($cf) < $gc) { + unlink($cf); + } + } + break; + } + } + return $tempDir; + } + + /** + * chmod + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author David Bartle + */ + protected function chmod($args) + { + $targets = $args['targets']; + $mode = intval((string)$args['mode'], 8); + + if (!is_array($targets)) { + $targets = array($targets); + } + + $result = array(); + + if (($volume = $this->volume($targets[0])) == false) { + $result['error'] = $this->error(self::ERROR_CONF_NO_VOL); + return $result; + } + + $this->itemLock($targets); + + $files = array(); + $errors = array(); + foreach ($targets as $target) { + elFinder::checkAborted(); + + $file = $volume->chmod($target, $mode); + if ($file) { + $files = array_merge($files, is_array($file) ? $file : array($file)); + } else { + $errors = array_merge($errors, $volume->error()); + } + } + + if ($files) { + $result['changed'] = $files; + if ($errors) { + $result['warning'] = $this->error($errors); + } + } else { + $result['error'] = $this->error($errors); + } + + return $result; + } + + /** + * Check chunked upload files + * + * @param string $tmpname uploaded temporary file path + * @param string $chunk uploaded chunk file name + * @param string $cid uploaded chunked file id + * @param string $tempDir temporary dirctroy path + * @param null $volume + * + * @return array|null + * @throws elFinderAbortException + * @author Naoki Sawada + */ + private function checkChunkedFile($tmpname, $chunk, $cid, $tempDir, $volume = null) + { + /* @var elFinderVolumeDriver $volume */ + if (preg_match('/^(.+)(\.\d+_(\d+))\.part$/s', $chunk, $m)) { + $fname = $m[1]; + $encname = md5($cid . '_' . $fname); + $base = $tempDir . DIRECTORY_SEPARATOR . 'ELF' . $encname; + $clast = intval($m[3]); + if (is_null($tmpname)) { + ignore_user_abort(true); + // chunked file upload fail + foreach (glob($base . '*') as $cf) { + unlink($cf); + } + ignore_user_abort(false); + return null; + } + + $range = isset($_POST['range']) ? trim($_POST['range']) : ''; + if ($range && preg_match('/^(\d+),(\d+),(\d+)$/', $range, $ranges)) { + $start = $ranges[1]; + $len = $ranges[2]; + $size = $ranges[3]; + $tmp = $base . '.part'; + $csize = filesize($tmpname); + + $tmpExists = is_file($tmp); + if (!$tmpExists) { + // check upload max size + $uploadMaxSize = $volume ? $volume->getUploadMaxSize() : 0; + if ($uploadMaxSize > 0 && $size > $uploadMaxSize) { + return array(self::ERROR_UPLOAD_FILE_SIZE, false); + } + // make temp file + $ok = false; + if ($fp = fopen($tmp, 'wb')) { + flock($fp, LOCK_EX); + $ok = ftruncate($fp, $size); + flock($fp, LOCK_UN); + fclose($fp); + touch($base); + } + if (!$ok) { + unlink($tmp); + return array(self::ERROR_UPLOAD_TEMP, false); + } + } else { + // wait until makeing temp file (for anothor session) + $cnt = 1200; // Time limit 120 sec + while (!is_file($base) && --$cnt) { + usleep(100000); // wait 100ms + } + if (!$cnt) { + return array(self::ERROR_UPLOAD_TEMP, false); + } + } + + // check size info + if ($len != $csize || $start + $len > $size || ($tmpExists && $size != filesize($tmp))) { + return array(self::ERROR_UPLOAD_TEMP, false); + } + + // write chunk data + $src = fopen($tmpname, 'rb'); + $fp = fopen($tmp, 'cb'); + fseek($fp, $start); + $writelen = stream_copy_to_stream($src, $fp, $len); + fclose($fp); + fclose($src); + + try { + // to check connection is aborted + elFinder::checkAborted(); + } catch (elFinderAbortException $e) { + unlink($tmpname); + is_file($tmp) && unlink($tmp); + is_file($base) && unlink($base); + throw $e; + } + + if ($writelen != $len) { + return array(self::ERROR_UPLOAD_TEMP, false); + } + + // write counts + file_put_contents($base, "\0", FILE_APPEND | LOCK_EX); + + if (filesize($base) >= $clast + 1) { + // Completion + unlink($base); + return array($tmp, $fname); + } + } else { + // old way + $part = $base . $m[2]; + if (move_uploaded_file($tmpname, $part)) { + chmod($part, 0600); + if ($clast < count(glob($base . '*'))) { + $parts = array(); + for ($i = 0; $i <= $clast; $i++) { + $name = $base . '.' . $i . '_' . $clast; + if (is_readable($name)) { + $parts[] = $name; + } else { + $parts = null; + break; + } + } + if ($parts) { + if (!is_file($base)) { + touch($base); + if ($resfile = tempnam($tempDir, 'ELF')) { + $target = fopen($resfile, 'wb'); + foreach ($parts as $f) { + $fp = fopen($f, 'rb'); + while (!feof($fp)) { + fwrite($target, fread($fp, 8192)); + } + fclose($fp); + unlink($f); + } + fclose($target); + unlink($base); + return array($resfile, $fname); + } + unlink($base); + } + } + } + } + } + } + return array('', ''); + } + + /** + * Save uploaded files + * + * @param array + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function upload($args) + { + $ngReg = '/[\/\\?*:|"<>]/'; + $target = $args['target']; + $volume = $this->volume($target); + $files = isset($args['FILES']['upload']) && is_array($args['FILES']['upload']) ? $args['FILES']['upload'] : array(); + $header = empty($args['html']) ? array() : array('header' => 'Content-Type: text/html; charset=utf-8'); + $result = array_merge(array('added' => array()), $header); + $paths = $args['upload_path'] ? $args['upload_path'] : array(); + $chunk = $args['chunk'] ? $args['chunk'] : ''; + $cid = $args['cid'] ? (int)$args['cid'] : ''; + $mtimes = $args['mtime'] ? $args['mtime'] : array(); + $tmpfname = ''; + + if (!$volume) { + return array_merge(array('error' => $this->error(self::ERROR_UPLOAD, self::ERROR_TRGDIR_NOT_FOUND, '#' . $target)), $header); + } + + // check $chunk + if (strpos($chunk, '/') !== false || strpos($chunk, '\\') !== false) { + return array('error' => $this->error(self::ERROR_UPLOAD)); + } + + if ($args['overwrite'] !== '') { + $volume->setUploadOverwrite($args['overwrite']); + } + + $renames = $hashes = array(); + $suffix = '~'; + if ($args['renames'] && is_array($args['renames'])) { + $renames = array_flip($args['renames']); + if (is_string($args['suffix']) && !preg_match($ngReg, $args['suffix'])) { + $suffix = $args['suffix']; + } + } + if ($args['hashes'] && is_array($args['hashes'])) { + $hashes = array_flip($args['hashes']); + } + + $this->itemLock($target); + + // file extentions table by MIME + $extTable = array_flip(array_unique($volume->getMimeTable())); + + if (empty($files)) { + if (isset($args['upload']) && is_array($args['upload']) && ($tempDir = $this->getTempDir($volume->getTempPath()))) { + $names = array(); + foreach ($args['upload'] as $i => $url) { + // check chunked file upload commit + if ($chunk) { + if ($url === 'chunkfail' && $args['mimes'] === 'chunkfail') { + $this->checkChunkedFile(null, $chunk, $cid, $tempDir); + if (preg_match('/^(.+)(\.\d+_(\d+))\.part$/s', $chunk, $m)) { + $result['warning'] = $this->error(self::ERROR_UPLOAD_FILE, $m[1], self::ERROR_UPLOAD_TEMP); + } + return $result; + } else { + $tmpfname = $tempDir . '/' . $chunk; + $files['tmp_name'][$i] = $tmpfname; + $files['name'][$i] = $url; + $files['error'][$i] = 0; + $GLOBALS['elFinderTempFiles'][$tmpfname] = true; + break; + } + } + + $tmpfname = $tempDir . DIRECTORY_SEPARATOR . 'ELF_FATCH_' . md5($url . microtime(true)); + $GLOBALS['elFinderTempFiles'][$tmpfname] = true; + + $_name = ''; + // check is data: + if (substr($url, 0, 5) === 'data:') { + list($data, $args['name'][$i]) = $this->parse_data_scheme($url, $extTable, $args); + } else { + $fp = fopen($tmpfname, 'wb'); + if ($data = $this->get_remote_contents($url, 30, 5, 'Mozilla/5.0', $fp)) { + // to check connection is aborted + try { + elFinder::checkAborted(); + } catch(elFinderAbortException $e) { + fclose($fp); + throw $e; + } + $_name = preg_replace('~^.*?([^/#?]+)(?:\?.*)?(?:#.*)?$~', '$1', rawurldecode($url)); + // Check `Content-Disposition` response header + if (($headers = get_headers($url, true)) && !empty($headers['Content-Disposition'])) { + if (preg_match('/filename\*=(?:([a-zA-Z0-9_-]+?)\'\')"?([a-z0-9_.~%-]+)"?/i', $headers['Content-Disposition'], $m)) { + $_name = rawurldecode($m[2]); + if ($m[1] && strtoupper($m[1]) !== 'UTF-8' && function_exists('mb_convert_encoding')) { + $_name = mb_convert_encoding($_name, 'UTF-8', $m[1]); + } + } else if (preg_match('/filename="?([ a-z0-9_.~%-]+)"?/i', $headers['Content-Disposition'], $m)) { + $_name = rawurldecode($m[1]); + } + } + } else { + fclose($fp); + } + } + if ($data) { + if (isset($args['name'][$i])) { + $_name = $args['name'][$i]; + } + if ($_name) { + $_ext = ''; + if (preg_match('/(\.[a-z0-9]{1,7})$/', $_name, $_match)) { + $_ext = $_match[1]; + } + if ((is_resource($data) && fclose($data)) || file_put_contents($tmpfname, $data)) { + $GLOBALS['elFinderTempFiles'][$tmpfname] = true; + $_name = preg_replace($ngReg, '_', $_name); + list($_a, $_b) = array_pad(explode('.', $_name, 2), 2, ''); + if ($_b === '') { + if ($_ext) { + rename($tmpfname, $tmpfname . $_ext); + $tmpfname = $tmpfname . $_ext; + } + $_b = $this->detectFileExtension($volume, $tmpfname, $_name); + $_name = $_a . $_b; + } else { + $_b = '.' . $_b; + } + if (isset($names[$_name])) { + $_name = $_a . '_' . $names[$_name]++ . $_b; + } else { + $names[$_name] = 1; + } + $files['tmp_name'][$i] = $tmpfname; + $files['name'][$i] = $_name; + $files['error'][$i] = 0; + // set to auto rename + $volume->setUploadOverwrite(false); + } else { + unlink($tmpfname); + } + } + } + } + } + if (empty($files)) { + return array_merge(array('error' => $this->error(self::ERROR_UPLOAD, self::ERROR_UPLOAD_NO_FILES)), $header); + } + } + + $addedDirs = array(); + $errors = array(); + foreach ($files['name'] as $i => $name) { + if (($error = $files['error'][$i]) > 0) { + $result['warning'] = $this->error(self::ERROR_UPLOAD_FILE, $name, $error == UPLOAD_ERR_INI_SIZE || $error == UPLOAD_ERR_FORM_SIZE ? self::ERROR_UPLOAD_FILE_SIZE : self::ERROR_UPLOAD_TRANSFER, $error); + $this->uploadDebug = 'Upload error code: ' . $error; + break; + } + + $tmpname = $files['tmp_name'][$i]; + $thash = ($paths && isset($paths[$i])) ? $paths[$i] : $target; + $mtime = isset($mtimes[$i]) ? $mtimes[$i] : 0; + if ($name === 'blob') { + if ($chunk) { + if ($tempDir = $this->getTempDir($volume->getTempPath())) { + list($tmpname, $name) = $this->checkChunkedFile($tmpname, $chunk, $cid, $tempDir, $volume); + if ($tmpname) { + if ($name === false) { + preg_match('/^(.+)(\.\d+_(\d+))\.part$/s', $chunk, $m); + $result['error'] = $this->error(self::ERROR_UPLOAD_FILE, $m[1], $tmpname); + $result['_chunkfailure'] = true; + $this->uploadDebug = 'Upload error: ' . $tmpname; + } else if ($name) { + $result['_chunkmerged'] = basename($tmpname); + $result['_name'] = $name; + $result['_mtime'] = $mtime; + } + } + } else { + $result['error'] = $this->error(self::ERROR_UPLOAD_FILE, $chunk, self::ERROR_UPLOAD_TEMP); + $this->uploadDebug = 'Upload error: unable open tmp file'; + } + return $result; + } else { + // for form clipboard with Google Chrome or Opera + $name = 'image.png'; + } + } + + // Set name if name eq 'image.png' and $args has 'name' array, e.g. clipboard data + if (strtolower(substr($name, 0, 5)) === 'image' && is_array($args['name']) && isset($args['name'][$i])) { + $type = $files['type'][$i]; + $name = $args['name'][$i]; + $ext = isset($extTable[$type]) ? '.' . $extTable[$type] : ''; + if ($ext) { + $name = preg_replace('/\.[^.]*$/', '', $name); + } + $name .= $ext; + } + + // do hook function 'upload.presave' + try { + $this->trigger('upload.presave', array(&$thash, &$name, $tmpname, $this, $volume), $errors); + } catch (elFinderTriggerException $e) { + if (!is_uploaded_file($tmpname) && unlink($tmpname) && $tmpfname) { + unset($GLOBALS['elFinderTempFiles'][$tmpfname]); + } + continue; + } + + clearstatcache(); + if ($mtime && is_file($tmpname)) { + // for keep timestamp option in the LocalFileSystem volume + touch($tmpname, $mtime); + } + + $fp = null; + if (!is_file($tmpname) || ($fp = fopen($tmpname, 'rb')) === false) { + $errors = array_merge($errors, array(self::ERROR_UPLOAD_FILE, $name, ($fp === false? self::ERROR_UPLOAD_TEMP : self::ERROR_UPLOAD_TRANSFER))); + $this->uploadDebug = 'Upload error: unable open tmp file'; + if (!is_uploaded_file($tmpname)) { + if (unlink($tmpname) && $tmpfname) unset($GLOBALS['elFinderTempFiles'][$tmpfname]); + continue; + } + break; + } + $rnres = array(); + if ($thash !== '' && $thash !== $target) { + if ($dir = $volume->dir($thash)) { + $_target = $thash; + if (!isset($addedDirs[$thash])) { + $addedDirs[$thash] = true; + $result['added'][] = $dir; + // to support multi-level directory creation + $_phash = isset($dir['phash']) ? $dir['phash'] : null; + while ($_phash && !isset($addedDirs[$_phash]) && $_phash !== $target) { + if ($_dir = $volume->dir($_phash)) { + $addedDirs[$_phash] = true; + $result['added'][] = $_dir; + $_phash = isset($_dir['phash']) ? $_dir['phash'] : null; + } else { + break; + } + } + } + } else { + $result['error'] = $this->error(self::ERROR_UPLOAD, self::ERROR_TRGDIR_NOT_FOUND, 'hash@' . $thash); + break; + } + } else { + $_target = $target; + // file rename for backup + if (isset($renames[$name])) { + $dir = $volume->realpath($_target); + if (isset($hashes[$name])) { + $hash = $hashes[$name]; + } else { + $hash = $volume->getHash($dir, $name); + } + $rnres = $this->rename(array('target' => $hash, 'name' => $volume->uniqueName($dir, $name, $suffix, true, 0))); + if (!empty($rnres['error'])) { + $result['warning'] = $rnres['error']; + if (!is_array($rnres['error'])) { + $errors = array_push($errors, $rnres['error']); + } else { + $errors = array_merge($errors, $rnres['error']); + } + continue; + } + } + } + if (!$_target || ($file = $volume->upload($fp, $_target, $name, $tmpname, ($_target === $target) ? $hashes : array())) === false) { + $errors = array_merge($errors, $this->error(self::ERROR_UPLOAD_FILE, $name, $volume->error())); + fclose($fp); + if (!is_uploaded_file($tmpname) && unlink($tmpname)) { + unset($GLOBALS['elFinderTempFiles'][$tmpname]); + } + continue; + } + + is_resource($fp) && fclose($fp); + if (!is_uploaded_file($tmpname)) { + clearstatcache(); + if (!is_file($tmpname) || unlink($tmpname)) { + unset($GLOBALS['elFinderTempFiles'][$tmpname]); + } + } + $result['added'][] = $file; + if ($rnres) { + $result = array_merge_recursive($result, $rnres); + } + } + + if ($errors) { + $result['warning'] = $errors; + } + + if ($GLOBALS['elFinderTempFiles']) { + foreach (array_keys($GLOBALS['elFinderTempFiles']) as $_temp) { + is_file($_temp) && is_writable($_temp) && unlink($_temp); + } + } + $result['removed'] = $volume->removed(); + + if (!empty($args['node'])) { + $result['callback'] = array( + 'node' => $args['node'], + 'bind' => 'upload' + ); + } + return $result; + } + + /** + * Copy/move files into new destination + * + * @param array command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function paste($args) + { + $dst = $args['dst']; + $targets = is_array($args['targets']) ? $args['targets'] : array(); + $cut = !empty($args['cut']); + $error = $cut ? self::ERROR_MOVE : self::ERROR_COPY; + $result = array('changed' => array(), 'added' => array(), 'removed' => array(), 'warning' => array()); + + if (($dstVolume = $this->volume($dst)) == false) { + return array('error' => $this->error($error, '#' . $targets[0], self::ERROR_TRGDIR_NOT_FOUND, '#' . $dst)); + } + + $this->itemLock($dst); + + $hashes = $renames = array(); + $suffix = '~'; + if (!empty($args['renames'])) { + $renames = array_flip($args['renames']); + if (is_string($args['suffix']) && !preg_match('/[\/\\?*:|"<>]/', $args['suffix'])) { + $suffix = $args['suffix']; + } + } + if (!empty($args['hashes'])) { + $hashes = array_flip($args['hashes']); + } + + foreach ($targets as $target) { + elFinder::checkAborted(); + + if (($srcVolume = $this->volume($target)) == false) { + $result['warning'] = array_merge($result['warning'], $this->error($error, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + continue; + } + + $rnres = array(); + if ($renames) { + $file = $srcVolume->file($target); + if (isset($renames[$file['name']])) { + $dir = $dstVolume->realpath($dst); + $dstName = $file['name']; + if ($srcVolume !== $dstVolume) { + $errors = array(); + try { + $this->trigger('paste.copyfrom', array(&$dst, &$dstName, '', $this, $dstVolume), $errors); + } catch (elFinderTriggerException $e) { + $result['warning'] = array_merge($result['warning'], $errors); + continue; + } + } + if (isset($hashes[$file['name']])) { + $hash = $hashes[$file['name']]; + } else { + $hash = $dstVolume->getHash($dir, $dstName); + } + $rnres = $this->rename(array('target' => $hash, 'name' => $dstVolume->uniqueName($dir, $dstName, $suffix, true, 0))); + if (!empty($rnres['error'])) { + $result['warning'] = array_merge($result['warning'], $rnres['error']); + continue; + } + } + } + + if ($cut && $this->itemLocked($target)) { + $rm = $srcVolume->file($target); + $result['warning'] = array_merge($result['warning'], $this->error(self::ERROR_LOCKED, $rm['name'])); + continue; + } + + if (($file = $dstVolume->paste($srcVolume, $target, $dst, $cut, $hashes)) == false) { + $result['warning'] = array_merge($result['warning'], $this->error($dstVolume->error())); + continue; + } + + if ($error = $dstVolume->error()) { + $result['warning'] = array_merge($result['warning'], $this->error($error)); + } + + if ($rnres) { + $result = array_merge_recursive($result, $rnres); + } + } + if (count($result['warning']) < 1) { + unset($result['warning']); + } else { + $result['sync'] = true; + } + + return $result; + } + + /** + * Return file content + * + * @param array $args command arguments + * + * @return array + * @author Dmitry (dio) Levashov + **/ + protected function get($args) + { + $target = $args['target']; + $volume = $this->volume($target); + $enc = false; + + if (!$volume || ($file = $volume->file($target)) == false) { + return array('error' => $this->error(self::ERROR_OPEN, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + } + + if ($volume->commandDisabled('get')) { + return array('error' => $this->error(self::ERROR_OPEN, '#' . $target, self::ERROR_ACCESS_DENIED)); + } + + if (($content = $volume->getContents($target)) === false) { + return array('error' => $this->error(self::ERROR_OPEN, $volume->path($target), $volume->error())); + } + + $mime = isset($file['mime']) ? $file['mime'] : ''; + if ($mime && (strtolower(substr($mime, 0, 4)) === 'text' || in_array(strtolower($mime), self::$textMimes))) { + $enc = ''; + if ($content !== '') { + if (!$args['conv'] || $args['conv'] == '1') { + // detect encoding + if (function_exists('mb_detect_encoding')) { + if ($enc = mb_detect_encoding($content, mb_detect_order(), true)) { + $encu = strtoupper($enc); + if ($encu === 'UTF-8' || $encu === 'ASCII') { + $enc = ''; + } + } else { + $enc = 'unknown'; + } + } else if (!preg_match('//u', $content)) { + $enc = 'unknown'; + } + if ($enc === 'unknown') { + $enc = $volume->getOption('encoding'); + if (!$enc || strtoupper($enc) === 'UTF-8') { + $enc = 'unknown'; + } + } + // call callbacks 'get.detectencoding' + if (!empty($this->listeners['get.detectencoding'])) { + foreach ($this->listeners['get.detectencoding'] as $handler) { + call_user_func_array($handler, array('get', &$enc, array_merge($args, array('content' => $content)), $this, $volume)); + } + } + if ($enc && $enc !== 'unknown') { + $errlev = error_reporting(); + error_reporting($errlev ^ E_NOTICE); + $utf8 = iconv($enc, 'UTF-8', $content); + if ($utf8 === false && function_exists('mb_convert_encoding')) { + error_reporting($errlev ^ E_WARNING); + $utf8 = mb_convert_encoding($content, 'UTF-8', $enc); + if (mb_convert_encoding($utf8, $enc, 'UTF-8') !== $content) { + $enc = 'unknown'; + } + } else { + if ($utf8 === false || iconv('UTF-8', $enc, $utf8) !== $content) { + $enc = 'unknown'; + } + } + error_reporting($errlev); + if ($enc !== 'unknown') { + $content = $utf8; + } + } + if ($enc) { + if ($args['conv'] == '1') { + $args['conv'] = ''; + if ($enc === 'unknown') { + $content = false; + } + } else if ($enc === 'unknown') { + return array('doconv' => $enc); + } + } + if ($args['conv'] == '1') { + $args['conv'] = ''; + } + } + if ($args['conv']) { + $enc = $args['conv']; + if (strtoupper($enc) !== 'UTF-8') { + $_content = $content; + $errlev = error_reporting(); + $this->setToastErrorHandler(array( + 'prefix' => 'Notice: ' + )); + error_reporting($errlev | E_NOTICE | E_WARNING); + $content = iconv($enc, 'UTF-8//TRANSLIT', $content); + if ($content === false && function_exists('mb_convert_encoding')) { + $content = mb_convert_encoding($_content, 'UTF-8', $enc); + } + error_reporting($errlev); + $this->setToastErrorHandler(false); + } else { + $enc = ''; + } + } + } + } else { + $content = 'data:' . ($mime ? $mime : 'application/octet-stream') . ';base64,' . base64_encode($content); + } + + if ($enc !== false) { + $json = false; + if ($content !== false) { + $json = json_encode($content); + } + if ($content === false || $json === false || strlen($json) < strlen($content)) { + return array('doconv' => 'unknown'); + } + } + + $res = array( + 'header' => array( + 'Content-Type: application/json' + ), + 'content' => $content + ); + + // add cache control headers + if ($cacheHeaders = $volume->getOption('cacheHeaders')) { + $res['header'] = array_merge($res['header'], $cacheHeaders); + } + + if ($enc) { + $res['encoding'] = $enc; + } + return $res; + } + + /** + * Save content into text file + * + * @param $args + * + * @return array + * @author Dmitry (dio) Levashov + */ + protected function put($args) + { + $target = $args['target']; + $encoding = isset($args['encoding']) ? $args['encoding'] : ''; + + if (($volume = $this->volume($target)) == false + || ($file = $volume->file($target)) == false) { + return array('error' => $this->error(self::ERROR_SAVE, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + } + + $this->itemLock($target); + + if ($encoding === 'scheme') { + if (preg_match('~^https?://~i', $args['content'])) { + /** @var resource $fp */ + $fp = $this->get_remote_contents($args['content'], 30, 5, 'Mozilla/5.0', $volume->tmpfile()); + if (!$fp) { + return array('error' => self::ERROR_SAVE, $args['content'], self::ERROR_FILE_NOT_FOUND); + } + $fmeta = stream_get_meta_data($fp); + $mime = $this->detectMimeType($fmeta['uri']); + if ($mime === 'unknown') { + $mime = 'application/octet-stream'; + } + $mime = $volume->mimeTypeNormalize($mime, $file['name']); + $args['content'] = 'data:' . $mime . ';base64,' . base64_encode(file_get_contents($fmeta['uri'])); + } + $encoding = ''; + $args['content'] = "\0" . $args['content']; + } else if ($encoding === 'hash') { + $_hash = $args['content']; + if ($_src = $this->getVolume($_hash)) { + if ($_file = $_src->file($_hash)) { + if ($_data = $_src->getContents($_hash)) { + $args['content'] = 'data:' . $file['mime'] . ';base64,' . base64_encode($_data); + } + } + } + $encoding = ''; + $args['content'] = "\0" . $args['content']; + } + if ($encoding) { + $content = iconv('UTF-8', $encoding, $args['content']); + if ($content === false && function_exists('mb_detect_encoding')) { + $content = mb_convert_encoding($args['content'], $encoding, 'UTF-8'); + } + if ($content !== false) { + $args['content'] = $content; + } + } + if (($file = $volume->putContents($target, $args['content'])) == false) { + return array('error' => $this->error(self::ERROR_SAVE, $volume->path($target), $volume->error())); + } + + return array('changed' => array($file)); + } + + /** + * Extract files from archive + * + * @param array $args command arguments + * + * @return array + * @author Dmitry (dio) Levashov, + * @author Alexey Sukhotin + **/ + protected function extract($args) + { + $target = $args['target']; + $makedir = isset($args['makedir']) ? (bool)$args['makedir'] : null; + + if (($volume = $this->volume($target)) == false + || ($file = $volume->file($target)) == false) { + return array('error' => $this->error(self::ERROR_EXTRACT, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + } + + $res = array(); + if ($file = $volume->extract($target, $makedir)) { + $res['added'] = isset($file['read']) ? array($file) : $file; + if ($err = $volume->error()) { + $res['warning'] = $err; + } + } else { + $res['error'] = $this->error(self::ERROR_EXTRACT, $volume->path($target), $volume->error()); + } + return $res; + } + + /** + * Create archive + * + * @param array $args command arguments + * + * @return array + * @throws Exception + * @author Dmitry (dio) Levashov, + * @author Alexey Sukhotin + */ + protected function archive($args) + { + $targets = isset($args['targets']) && is_array($args['targets']) ? $args['targets'] : array(); + $name = isset($args['name']) ? $args['name'] : ''; + + if (($volume = $this->volume($targets[0])) == false) { + return $this->error(self::ERROR_ARCHIVE, self::ERROR_TRGDIR_NOT_FOUND); + } + + foreach ($targets as $target) { + $this->itemLock($target); + } + + return ($file = $volume->archive($targets, $args['type'], $name)) + ? array('added' => array($file)) + : array('error' => $this->error(self::ERROR_ARCHIVE, $volume->error())); + } + + /** + * Search files + * + * @param array $args command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry Levashov + */ + protected function search($args) + { + $q = trim($args['q']); + $mimes = !empty($args['mimes']) && is_array($args['mimes']) ? $args['mimes'] : array(); + $target = !empty($args['target']) ? $args['target'] : null; + $type = !empty($args['type']) ? $args['type'] : null; + $result = array(); + $errors = array(); + + if ($target) { + if ($volume = $this->volume($target)) { + $result = $volume->search($q, $mimes, $target, $type); + $errors = array_merge($errors, $volume->error()); + } + } else { + foreach ($this->volumes as $volume) { + $result = array_merge($result, $volume->search($q, $mimes, null, $type)); + $errors = array_merge($errors, $volume->error()); + } + } + + $result = array('files' => $result); + if ($errors) { + $result['warning'] = $errors; + } + return $result; + } + + /** + * Return file info (used by client "places" ui) + * + * @param array $args command arguments + * + * @return array + * @throws elFinderAbortException + * @author Dmitry Levashov + */ + protected function info($args) + { + $files = array(); + $compare = null; + // long polling mode + if ($args['compare'] && count($args['targets']) === 1) { + $compare = intval($args['compare']); + $hash = $args['targets'][0]; + if ($volume = $this->volume($hash)) { + $standby = (int)$volume->getOption('plStandby'); + $_compare = false; + if (($syncCheckFunc = $volume->getOption('syncCheckFunc')) && is_callable($syncCheckFunc)) { + $_compare = call_user_func_array($syncCheckFunc, array($volume->realpath($hash), $standby, $compare, $volume, $this)); + } + if ($_compare !== false) { + $compare = $_compare; + } else { + $sleep = max(1, (int)$volume->getOption('tsPlSleep')); + $limit = max(1, $standby / $sleep) + 1; + do { + elFinder::extendTimeLimit(30 + $sleep); + $volume->clearstatcache(); + if (($info = $volume->file($hash)) != false) { + if ($info['ts'] != $compare) { + $compare = $info['ts']; + break; + } + } else { + $compare = 0; + break; + } + if (--$limit) { + sleep($sleep); + } + } while ($limit); + } + } + } else { + foreach ($args['targets'] as $hash) { + elFinder::checkAborted(); + if (($volume = $this->volume($hash)) != false + && ($info = $volume->file($hash)) != false) { + $info['path'] = $volume->path($hash); + $files[] = $info; + } + } + } + + $result = array('files' => $files); + if (!is_null($compare)) { + $result['compare'] = strval($compare); + } + return $result; + } + + /** + * Return image dimensions + * + * @param array $args command arguments + * + * @return array + * @throws ImagickException + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + */ + protected function dim($args) + { + $res = array(); + $target = $args['target']; + + if (($volume = $this->volume($target)) != false) { + if ($dim = $volume->dimensions($target, $args)) { + if (is_array($dim) && isset($dim['dim'])) { + $res = $dim; + } else { + $res = array('dim' => $dim); + if ($subImgLink = $volume->getSubstituteImgLink($target, explode('x', $dim))) { + $res['url'] = $subImgLink; + } + } + } + } + + return $res; + } + + /** + * Resize image + * + * @param array command arguments + * + * @return array + * @throws ImagickException + * @throws elFinderAbortException + * @author Dmitry (dio) Levashov + * @author Alexey Sukhotin + */ + protected function resize($args) + { + $target = $args['target']; + $width = (int)$args['width']; + $height = (int)$args['height']; + $x = (int)$args['x']; + $y = (int)$args['y']; + $mode = $args['mode']; + $bg = $args['bg']; + $degree = (int)$args['degree']; + $quality = (int)$args['quality']; + + if (($volume = $this->volume($target)) == false + || ($file = $volume->file($target)) == false) { + return array('error' => $this->error(self::ERROR_RESIZE, '#' . $target, self::ERROR_FILE_NOT_FOUND)); + } + + if ($mode !== 'rotate' && ($width < 1 || $height < 1)) { + return array('error' => $this->error(self::ERROR_RESIZESIZE)); + } + return ($file = $volume->resize($target, $width, $height, $x, $y, $mode, $bg, $degree, $quality)) + ? (!empty($file['losslessRotate']) ? $file : array('changed' => array($file))) + : array('error' => $this->error(self::ERROR_RESIZE, $volume->path($target), $volume->error())); + } + + /** + * Return content URL + * + * @param array $args command arguments + * + * @return array + * @author Naoki Sawada + **/ + protected function url($args) + { + $target = $args['target']; + $options = isset($args['options']) ? $args['options'] : array(); + if (($volume = $this->volume($target)) != false) { + if (!$volume->commandDisabled('url')) { + $url = $volume->getContentUrl($target, $options); + return $url ? array('url' => $url) : array(); + } + } + return array(); + } + + /** + * Output callback result with JavaScript that control elFinder + * or HTTP redirect to callbackWindowURL + * + * @param array command arguments + * + * @throws elFinderAbortException + * @author Naoki Sawada + */ + protected function callback($args) + { + $checkReg = '/[^a-zA-Z0-9;._-]/'; + $node = (isset($args['node']) && !preg_match($checkReg, $args['node'])) ? $args['node'] : ''; + $json = (isset($args['json']) && json_decode($args['json'])) ? $args['json'] : '{}'; + $bind = (isset($args['bind']) && !preg_match($checkReg, $args['bind'])) ? $args['bind'] : ''; + $done = (!empty($args['done'])); + + while (ob_get_level()) { + if (!ob_end_clean()) { + break; + } + } + + if ($done || !$this->callbackWindowURL) { + $script = ''; + if ($node) { + if ($bind) { + $trigger = 'elf.trigger(\'' . $bind . '\', data);'; + $triggerdone = 'elf.trigger(\'' . $bind . 'done\');'; + $triggerfail = 'elf.trigger(\'' . $bind . 'fail\', data);'; + } else { + $trigger = $triggerdone = $triggerfail = ''; + } + $origin = isset($_SERVER['HTTP_ORIGIN'])? str_replace('\'', '\\\'', $_SERVER['HTTP_ORIGIN']) : '*'; + $script .= ' +var go = function() { + var w = window.opener || window.parent || window, + close = function(){ + window.open("about:blank","_self").close(); + return false; + }; + try { + var elf = w.document.getElementById(\'' . $node . '\').elfinder; + if (elf) { + var data = ' . $json . '; + if (data.error) { + ' . $triggerfail . ' + elf.error(data.error); + } else { + data.warning && elf.error(data.warning); + data.removed && data.removed.length && elf.remove(data); + data.added && data.added.length && elf.add(data); + data.changed && data.changed.length && elf.change(data); + ' . $trigger . ' + ' . $triggerdone . ' + data.sync && elf.sync(); + } + } + } catch(e) { + // for CORS + w.postMessage && w.postMessage(JSON.stringify({bind:\'' . $bind . '\',data:' . $json . '}), \'' . $origin . '\'); + } + close(); + setTimeout(function() { + var msg = document.getElementById(\'msg\'); + msg.style.display = \'inline\'; + msg.onclick = close; + }, 100); +}; +'; + } + + $out = '