Files
translator/work_translator_app.py
T
dinlo 5dc00d6e70 Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:45:49 +08:00

420 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()