import os import re import tokenize import io import time import random import threading import textwrap import shutil import urllib.request import json import customtkinter as ctk from tkinter import filedialog, messagebox from deep_translator import GoogleTranslator from deep_translator.exceptions import RequestError ctk.set_appearance_mode("Dark") ctk.set_default_color_theme("blue") class TranslationWorker(threading.Thread): def __init__(self, path, is_file, recursive, extensions, target_lang, only_cjk, save_as_new, create_bak, log_callback, progress_callback, finish_callback, engine="Google Translate", ollama_model=""): super().__init__() self.path = path self.is_file = is_file self.recursive = recursive self.extensions =[ext.strip().lower() for ext in extensions.split(',')] self.only_cjk = only_cjk self.save_as_new = save_as_new self.create_bak = create_bak self.engine = engine self.ollama_model = ollama_model self.target_lang = target_lang # Маппинг кодов языков в их английские названия для промпта Ollama self.lang_map = { "ru": "Russian", "en": "English" } self.log = log_callback self.update_progress = progress_callback self.finish = finish_callback self.translator = GoogleTranslator(source='auto', target=self.target_lang) self.request_count = 0 self.is_running = True def run(self): self.log("Начало сканирования...") files_to_process =[] # Защита от зацикливания: игнорируем файлы, которые уже являются переведенными копиями suffix = f"_{self.target_lang}" if self.is_file: files_to_process.append(self.path) else: if self.recursive: for root, _, files in os.walk(self.path): for file in files: if any(file.lower().endswith(ext) for ext in self.extensions): # Пропускаем уже переведенные файлы name, ext = os.path.splitext(file) if not name.endswith(suffix): files_to_process.append(os.path.join(root, file)) else: for file in os.listdir(self.path): if os.path.isfile(os.path.join(self.path, file)): if any(file.lower().endswith(ext) for ext in self.extensions): name, ext = os.path.splitext(file) if not name.endswith(suffix): files_to_process.append(os.path.join(self.path, file)) total_files = len(files_to_process) self.log(f"Найдено файлов для обработки: {total_files}") if total_files == 0: self.finish() return for idx, filepath in enumerate(files_to_process): if not self.is_running: self.log("Процесс остановлен пользователем.") break self.log(f"Обработка: {os.path.basename(filepath)}") try: if filepath.endswith('.py'): self.process_python_file(filepath) elif filepath.endswith(('.qml', '.js', '.json', '.cpp', '.cs')): self.process_regex_file(filepath) else: self.process_plain_text(filepath) except Exception as e: self.log(f"Ошибка в файле {os.path.basename(filepath)}: {str(e)}") self.update_progress((idx + 1) / total_files) self.log("Перевод завершен!") self.finish() def stop(self): self.is_running = False def check_limits(self): self.request_count += 1 time.sleep(random.uniform(0.5, 1.5)) if self.request_count % 50 == 0: self.log("Пауза 5 сек во избежание блокировки Google API...") time.sleep(random.uniform(5.0, 8.0)) def has_target_chars(self, text): if not self.only_cjk: return re.search(r'[a-zA-Zа-яА-Я\u4e00-\u9fff\u3040-\u30ff]', text) is not None return bool(re.search(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\u3400-\u4dbf]', text)) def fix_orthography(self, text): if not text: return text text = re.sub(r'%\s+([a-zA-Z0-9])', r'%\1', text) text = re.sub(r'\{\s*(.*?)\s*\}', r'{\1}', text) text = re.sub(r'\\ \s*n', r'\\n', text) text = re.sub(r'\\ \s*t', r'\\t', text) text = text.replace('...', '…') return text def translate_with_ollama(self, text): url = "http://localhost:11434/api/chat" lang_name = self.lang_map.get(self.target_lang, "Russian") system_prompt = ( f"You are a professional translator. Your task is to translate any text provided by the user into {lang_name}. " "CRITICAL RULES: " f"1. Output ONLY the translated {lang_name} text. " "2. DO NOT add any introductions, notes, reasoning, or explanations. " "3. Maintain exactly the original formatting, punctuation, line breaks, and special characters. " "4. DO NOT translate variables or code syntax, translate only the readable text content." ) data = { "model": self.ollama_model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": text} ], "stream": False, "options": { "temperature": 0.1, "num_ctx": 8192 } } # Делаем 2 попытки на случай сбоя Ollama for attempt in range(2): try: # Увеличен таймаут до 600 секунд (10 минут) для медленных генераций или инициализации модели в VRAM req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers={'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=600) as response: result = json.loads(response.read().decode('utf-8')) translated = result.get('message', {}).get('content', '').strip() if translated: return translated except Exception as e: if attempt == 1: self.log(f"Ошибка Ollama (модель '{self.ollama_model}'): {e}") time.sleep(2) return text def process_chunk(self, chunk): """Единый метод перевода для одного куска (чанка) текста""" if self.engine == "Ollama": return self.translate_with_ollama(chunk) else: self.check_limits() return self.translator.translate(chunk) def translate_text(self, text): if not text.strip() or not self.has_target_chars(text): return text try: # Для Google Translate мы можем отправлять куски по 4000 символов # Для Ollama куски нужно делать меньше (~1500 символов), чтобы избежать таймаутов, # потери контекста и ускорить пошаговую генерацию. max_chunk_size = 4000 if self.engine == "Google Translate" else 1500 if len(text) > max_chunk_size: chunks = textwrap.wrap(text, max_chunk_size, break_long_words=False, replace_whitespace=False) translated_chunks = [] for chunk in chunks: res = self.process_chunk(chunk) translated_chunks.append(res) translated = " ".join(translated_chunks) else: translated = self.process_chunk(text) return self.fix_orthography(translated) except RequestError: # Отрабатывает только при обрыве сети Google Translate if self.engine == "Google Translate": self.log("Ошибка сети Google. Повторная попытка через 3 сек...") time.sleep(3) return self.translate_text(text) return text except Exception as e: self.log(f"Ошибка перевода: {e}") return text def get_save_path(self, filepath): if self.save_as_new: base, ext = os.path.splitext(filepath) return f"{base}_{self.target_lang}{ext}" return filepath def handle_backup(self, filepath): if self.create_bak and not self.save_as_new: shutil.copy2(filepath, filepath + ".bak") def process_python_file(self, filepath): with open(filepath, 'r', encoding='utf-8') as f: source = f.read() tokens = tokenize.tokenize(io.BytesIO(source.encode('utf-8')).readline) replacements =[] for tok in tokens: if tok.type in (tokenize.STRING, tokenize.COMMENT) and self.has_target_chars(tok.string): if tok.type == tokenize.COMMENT: m = re.match(r'^(#\s*)(.*)', tok.string, re.DOTALL) if m: prefix, content = m.groups() replacements.append((tok.start, tok.end, prefix + self.translate_text(content))) elif tok.type == tokenize.STRING: m = re.match(r'^([a-zA-Z]*)("{1,3}|\'{1,3})(.*)(\2)$', tok.string, re.DOTALL) if m: prefix, quote, content, _ = m.groups() translated = self.translate_text(content) if 'r' not in prefix.lower() and len(quote) == 1: translated = translated.replace(quote, f"\\{quote}") replacements.append((tok.start, tok.end, f"{prefix}{quote}{translated}{quote}")) if replacements: self.handle_backup(filepath) self._apply_replacements(filepath, self.get_save_path(filepath), source, replacements) def process_regex_file(self, filepath): with open(filepath, 'r', encoding='utf-8') as f: source = f.read() pattern = re.compile( r'(/\*.*?\*/)|(//.*?$)|("(?:\\.|[^"\\])*")|(\'(?:\\.|[^\'\\])*\')|(`(?:\\.|[^`\\])*`)', re.DOTALL | re.MULTILINE ) replacements =[] for match in pattern.finditer(source): text = match.group(0) if self.has_target_chars(text): if text.startswith('/*'): replacements.append((match.start(), match.end(), f"/*{self.translate_text(text[2:-2])}*/")) elif text.startswith('//'): replacements.append((match.start(), match.end(), f"//{self.translate_text(text[2:])}")) else: quote = text[0] content = text[1:-1] translated = self.translate_text(content).replace(quote, f"\\{quote}") replacements.append((match.start(), match.end(), f"{quote}{translated}{quote}")) if replacements: self.handle_backup(filepath) for start, end, new_text in reversed(replacements): source = source[:start] + new_text + source[end:] with open(self.get_save_path(filepath), 'w', encoding='utf-8') as f: f.write(source) def process_plain_text(self, filepath): with open(filepath, 'r', encoding='utf-8') as f: source = f.read() if self.has_target_chars(source): self.handle_backup(filepath) translated = self.translate_text(source) with open(self.get_save_path(filepath), 'w', encoding='utf-8') as f: f.write(translated) def _apply_replacements(self, original_filepath, save_filepath, source, replacements): lines = source.splitlines(keepends=True) for start, end, new_text in reversed(replacements): start_row, start_col = start[0] - 1, start[1] end_row, end_col = end[0] - 1, end[1] if start_row == end_row: lines[start_row] = lines[start_row][:start_col] + new_text + lines[start_row][end_col:] else: lines[start_row] = lines[start_row][:start_col] + new_text for r in range(start_row + 1, end_row): lines[r] = "" lines[end_row] = lines[end_row][end_col:] with open(save_filepath, 'w', encoding='utf-8') as f: f.write("".join(lines)) class App(ctk.CTk): def __init__(self): super().__init__() self.title("Code & Text Translator Pro") self.geometry("900x700") self.grid_columnconfigure(0, weight=1) self.grid_columnconfigure(1, weight=2) self.grid_rowconfigure(0, weight=1) self.worker = None self.selected_path = "" self.is_file_mode = False self._build_ui() self.refresh_ollama_models() def get_ollama_models(self): try: req = urllib.request.Request("http://localhost:11434/api/tags") with urllib.request.urlopen(req, timeout=2) as response: data = json.loads(response.read().decode('utf-8')) models = [m['name'] for m in data.get('models', [])] return models if models else ["Нет доступных моделей"] except Exception: return ["Ollama не запущена"] def refresh_ollama_models(self): models = self.get_ollama_models() self.opt_ollama.configure(values=models) if models and models[0] not in ["Нет доступных моделей", "Ollama не запущена"]: self.opt_ollama.set(models[0]) else: self.opt_ollama.set(models[0] if models else "") def _build_ui(self): # Левая панель (Настройки) self.frame_settings = ctk.CTkScrollableFrame(self) self.frame_settings.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") ctk.CTkLabel(self.frame_settings, text="Настройки", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(0, 10)) # Кнопки выбора frame_btns = ctk.CTkFrame(self.frame_settings, fg_color="transparent") frame_btns.pack(fill="x", pady=5, padx=5) self.btn_folder = ctk.CTkButton(frame_btns, text="Выбрать папку", command=self.select_folder) self.btn_folder.pack(side="left", fill="x", expand=True, padx=(0, 2)) self.btn_file = ctk.CTkButton(frame_btns, text="Выбрать файл", command=self.select_file) self.btn_file.pack(side="right", fill="x", expand=True, padx=(2, 0)) self.lbl_path = ctk.CTkLabel(self.frame_settings, text="Ничего не выбрано", text_color="gray", wraplength=250) self.lbl_path.pack(pady=5, padx=10) # Режимы сохранения ctk.CTkLabel(self.frame_settings, text="Безопасность:", font=ctk.CTkFont(weight="bold")).pack(pady=(15, 0), anchor="w", padx=10) self.var_new_file = ctk.BooleanVar(value=True) self.chk_new_file = ctk.CTkCheckBox(self.frame_settings, text="Создать новый файл (суффикс языка)", variable=self.var_new_file, command=self.toggle_backup_chk) self.chk_new_file.pack(pady=5, padx=10, anchor="w") self.var_bak = ctk.BooleanVar(value=False) self.chk_bak = ctk.CTkCheckBox(self.frame_settings, text="Создавать бэкап (.bak)", variable=self.var_bak, state="disabled") self.chk_bak.pack(pady=5, padx=10, anchor="w") # Чекбокс подпапок self.var_recursive = ctk.BooleanVar(value=True) self.chk_recursive = ctk.CTkCheckBox(self.frame_settings, text="Включая подпапки", variable=self.var_recursive) self.chk_recursive.pack(pady=(15, 5), padx=10, anchor="w") # Расширения ctk.CTkLabel(self.frame_settings, text="Расширения файлов (через запятую):").pack(pady=(10, 0), padx=10, anchor="w") self.ent_exts = ctk.CTkEntry(self.frame_settings, placeholder_text=".py, .qml, .txt") self.ent_exts.insert(0, ".py, .qml, .txt, .json") self.ent_exts.pack(pady=5, padx=10, fill="x") # Выбор системы перевода ctk.CTkLabel(self.frame_settings, text="Система перевода:").pack(pady=(10, 0), padx=10, anchor="w") self.opt_engine = ctk.CTkOptionMenu(self.frame_settings, values=["Google Translate", "Ollama"], command=self.toggle_engine) self.opt_engine.pack(pady=5, padx=10, fill="x") # Блок Ollama frame_ollama = ctk.CTkFrame(self.frame_settings, fg_color="transparent") frame_ollama.pack(fill="x", pady=(5, 0), padx=5) self.lbl_ollama = ctk.CTkLabel(frame_ollama, text="Модель Ollama:") self.lbl_ollama.pack(side="left", padx=5) self.btn_refresh_models = ctk.CTkButton(frame_ollama, text="🔄 Обновить", width=80, command=self.refresh_ollama_models) self.btn_refresh_models.pack(side="right", padx=5) self.opt_ollama = ctk.CTkOptionMenu(self.frame_settings, values=["Загрузка..."]) self.opt_ollama.pack(pady=5, padx=10, fill="x") # Язык self.lbl_lang = ctk.CTkLabel(self.frame_settings, text="Целевой язык:") self.lbl_lang.pack(pady=(10, 0), padx=10, anchor="w") self.opt_lang = ctk.CTkOptionMenu(self.frame_settings, values=["ru (Русский)", "en (Английский)", "uk (Украинский)"]) self.opt_lang.set("ru (Русский)") # По умолчанию русский язык self.opt_lang.pack(pady=5, padx=10, fill="x") # Инициализация интерфейса (скрытие/блокировка лишних полей) self.toggle_engine("Google Translate") # Ограничение по CJK self.var_cjk = ctk.BooleanVar(value=True) self.chk_cjk = ctk.CTkCheckBox(self.frame_settings, text="Только Китайский/Японский", variable=self.var_cjk) self.chk_cjk.pack(pady=10, padx=10, anchor="w") # Пространство, чтобы кнопки всегда были внизу ctk.CTkLabel(self.frame_settings, text="").pack(expand=True, fill="both") # Кнопки управления self.btn_start = ctk.CTkButton(self.frame_settings, text="▶ НАЧАТЬ ПЕРЕВОД", fg_color="green", hover_color="darkgreen", command=self.start_translation) self.btn_start.pack(pady=(15, 5), padx=10, fill="x") self.btn_stop = ctk.CTkButton(self.frame_settings, text="ОСТАНОВИТЬ", fg_color="red", hover_color="darkred", state="disabled", command=self.stop_translation) self.btn_stop.pack(pady=5, padx=10, fill="x") # Правая панель (Логи и Прогресс) self.frame_logs = ctk.CTkFrame(self) self.frame_logs.grid(row=0, column=1, padx=10, pady=10, sticky="nsew") self.frame_logs.grid_rowconfigure(1, weight=1) self.frame_logs.grid_columnconfigure(0, weight=1) ctk.CTkLabel(self.frame_logs, text="Журнал работы:", font=ctk.CTkFont(size=14, weight="bold")).grid(row=0, column=0, pady=5, padx=10, sticky="w") self.txt_log = ctk.CTkTextbox(self.frame_logs, state="disabled", wrap="word", font=ctk.CTkFont(family="Consolas", size=12)) self.txt_log.grid(row=1, column=0, padx=10, pady=5, sticky="nsew") self.progress_bar = ctk.CTkProgressBar(self.frame_logs) self.progress_bar.grid(row=2, column=0, padx=10, pady=15, sticky="ew") self.progress_bar.set(0) def toggle_engine(self, choice): if choice == "Ollama": self.opt_ollama.configure(state="normal") self.btn_refresh_models.configure(state="normal") else: self.opt_ollama.configure(state="disabled") self.btn_refresh_models.configure(state="disabled") def toggle_backup_chk(self): """Если включено создание нового файла, бэкап не нужен.""" if self.var_new_file.get(): self.var_bak.set(False) self.chk_bak.configure(state="disabled") else: self.chk_bak.configure(state="normal") def select_folder(self): folder = filedialog.askdirectory() if folder: self.lbl_path.configure(text=folder) self.selected_path = folder self.is_file_mode = False self.chk_recursive.configure(state="normal") def select_file(self): file = filedialog.askopenfilename(filetypes=[("All Supported Files", "*.*")]) if file: self.lbl_path.configure(text=file) self.selected_path = file self.is_file_mode = True self.chk_recursive.configure(state="disabled") def log(self, message): def update(): self.txt_log.configure(state="normal") self.txt_log.insert("end", message + "\n") self.txt_log.see("end") self.txt_log.configure(state="disabled") self.after(0, update) def update_progress(self, value): self.after(0, lambda: self.progress_bar.set(value)) def start_translation(self): if not self.selected_path: messagebox.showwarning("Внимание", "Сначала выберите файл или папку!") return target_lang = self.opt_lang.get().split(" ")[0] extensions = self.ent_exts.get() if not extensions and not self.is_file_mode: messagebox.showwarning("Внимание", "Укажите хотя бы одно расширение!") return # Блокировка UI self.btn_start.configure(state="disabled") self.btn_folder.configure(state="disabled") self.btn_file.configure(state="disabled") self.chk_new_file.configure(state="disabled") self.chk_bak.configure(state="disabled") self.btn_stop.configure(state="normal") self.opt_engine.configure(state="disabled") self.opt_ollama.configure(state="disabled") self.btn_refresh_models.configure(state="disabled") self.opt_lang.configure(state="disabled") self.progress_bar.set(0) self.txt_log.configure(state="normal") self.txt_log.delete("1.0", "end") self.txt_log.configure(state="disabled") self.worker = TranslationWorker( path=self.selected_path, is_file=self.is_file_mode, recursive=self.var_recursive.get(), extensions=extensions, target_lang=target_lang, only_cjk=self.var_cjk.get(), save_as_new=self.var_new_file.get(), create_bak=self.var_bak.get(), log_callback=self.log, progress_callback=self.update_progress, finish_callback=self.translation_finished, engine=self.opt_engine.get(), ollama_model=self.opt_ollama.get() ) self.worker.start() def stop_translation(self): if self.worker: self.worker.stop() self.log("Остановка процессов... Пожалуйста, подождите завершения текущего файла.") self.btn_stop.configure(state="disabled") def translation_finished(self): def update(): self.btn_start.configure(state="normal") self.btn_folder.configure(state="normal") self.btn_file.configure(state="normal") self.chk_new_file.configure(state="normal") self.opt_engine.configure(state="normal") self.opt_lang.configure(state="normal") self.toggle_engine(self.opt_engine.get()) # Восстанавливаем состояние полей self.toggle_backup_chk() # Восстанавливаем состояние чекбокса бэкапов self.btn_stop.configure(state="disabled") self.after(0, update) if __name__ == "__main__": app = App() app.mainloop()