Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user