Files

420 lines
18 KiB
Python
Raw Permalink Normal View History

2026-05-31 18:45:49 +08:00
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()