import os import re import tokenize import io import time import random import threading import textwrap import shutil 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): super().__init__() self.path = path self.is_file = is_file self.recursive = recursive self.extensions =[ext.strip().lower() for ext in extensions.split(',')] self.target_lang = target_lang self.only_cjk = only_cjk self.save_as_new = save_as_new self.create_bak = create_bak 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_text(self, text): if not text.strip() or not self.has_target_chars(text): return text try: if len(text) > 4000: chunks = textwrap.wrap(text, 4000, break_long_words=False, replace_whitespace=False) translated_chunks =[] for chunk in chunks: self.check_limits() res = self.translator.translate(chunk) translated_chunks.append(res) translated = " ".join(translated_chunks) else: self.check_limits() translated = self.translator.translate(text) return self.fix_orthography(translated) except RequestError: self.log("Ошибка сети. Повторная попытка через 3 сек...") time.sleep(3) return self.translate_text(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("900x650") 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() 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="Создать новый файл (суффикс _ru)", 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_lang = ctk.CTkOptionMenu(self.frame_settings, values=["ru (Русский)", "en (Английский)", "uk (Украинский)"]) self.opt_lang.pack(pady=5, padx=10, fill="x") # Ограничение по 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_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.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 ) 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.toggle_backup_chk() # Восстанавливаем состояние чекбокса бэкапов self.btn_stop.configure(state="disabled") self.after(0, update) if __name__ == "__main__": app = App() app.mainloop()