5dc00d6e70
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
492 lines
21 KiB
Python
492 lines
21 KiB
Python
import os
|
||
import re
|
||
import tokenize
|
||
import io
|
||
import time
|
||
import shutil
|
||
import threading
|
||
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox, scrolledtext
|
||
|
||
from deep_translator import GoogleTranslator
|
||
from langdetect import detect, LangDetectException
|
||
|
||
# Получаем все поддерживаемые языки от Google Translate
|
||
LANGUAGES_DICT = GoogleTranslator().get_supported_languages(as_dict=True)
|
||
LANGUAGES_LIST =[lang.capitalize() for lang in LANGUAGES_DICT.keys()]
|
||
|
||
# Настройки для обхода банов
|
||
API_TIMEOUT = 1.5
|
||
MAX_TEXT_LEN = 4800
|
||
|
||
# ==========================================
|
||
# БЭКЭНД: ФУНКЦИИ ПЕРЕВОДА И ПАРСИНГА
|
||
# ==========================================
|
||
|
||
def fix_placeholders(text):
|
||
"""Восстанавливает переменные форматирования кода после перевода."""
|
||
if not text: return text
|
||
text = re.sub(r'%\s+([a-zA-Z])', r'%\1', text)
|
||
text = re.sub(r'%\s+([0-9]+)', r'%\1', text)
|
||
text = re.sub(r'\{\s*(.*?)\s*\}', r'{\1}', text)
|
||
return text
|
||
|
||
def safe_translate(text, target_lang='ru', source_lang='auto'):
|
||
"""Переводит текст с разбивкой на части для обхода лимитов Google."""
|
||
translator = GoogleTranslator(source=source_lang, target=target_lang)
|
||
|
||
if len(text) <= MAX_TEXT_LEN:
|
||
return translator.translate(text)
|
||
|
||
chunks =[]
|
||
current_chunk = ""
|
||
for line in text.splitlines(keepends=True):
|
||
if len(current_chunk) + len(line) < MAX_TEXT_LEN:
|
||
current_chunk += line
|
||
else:
|
||
if current_chunk.strip():
|
||
chunks.append(translator.translate(current_chunk))
|
||
time.sleep(API_TIMEOUT)
|
||
current_chunk = line
|
||
if current_chunk.strip():
|
||
chunks.append(translator.translate(current_chunk))
|
||
|
||
return "".join(chunks)
|
||
|
||
def detect_language(text):
|
||
"""Определяет язык текста локально (без запросов к API)."""
|
||
try:
|
||
clean_text = re.sub(r'[^\w\s]', '', text)
|
||
if not clean_text.strip(): return None
|
||
return detect(clean_text)
|
||
except LangDetectException:
|
||
return None
|
||
|
||
def should_translate(content, config):
|
||
"""
|
||
ГЛАВНЫЙ ФИЛЬТР: Решает, является ли текст системным кодом или интерфейсом.
|
||
"""
|
||
clean_content = content.strip()
|
||
|
||
if not clean_content or not any(c.isalpha() for c in clean_content):
|
||
return False
|
||
|
||
if config.get('skip_technical', True):
|
||
# Если в строке НЕТ пробелов и НЕТ иероглифов/кириллицы -> это тех. строка
|
||
if " " not in clean_content and not re.search(r'[А-Яа-яЁё\u4e00-\u9fff\u3040-\u30ff]', clean_content):
|
||
return False
|
||
|
||
if config['translate_only']:
|
||
lang_code = detect_language(clean_content)
|
||
if lang_code and lang_code.lower() in config['selected_codes']:
|
||
return True
|
||
return False
|
||
|
||
return True
|
||
|
||
def process_file(filepath, config, update_log_cb):
|
||
"""Универсальная функция парсинга."""
|
||
try:
|
||
with open(filepath, 'r', encoding='utf-8') as f:
|
||
source = f.read()
|
||
except UnicodeDecodeError:
|
||
return "error_utf8"
|
||
|
||
replacements =[]
|
||
|
||
if filepath.endswith('.py'):
|
||
tokens = tokenize.tokenize(io.BytesIO(source.encode('utf-8')).readline)
|
||
for tok in tokens:
|
||
if tok.type in (tokenize.STRING, tokenize.COMMENT):
|
||
if tok.type == tokenize.COMMENT:
|
||
m = re.match(r'^(#\s*)(.*)', tok.string, re.DOTALL)
|
||
if m:
|
||
prefix, content = m.groups()
|
||
if should_translate(content, config):
|
||
translated = safe_translate(content, target_lang='ru')
|
||
translated = fix_placeholders(translated)
|
||
replacements.append((tok.start, tok.end, prefix + translated))
|
||
time.sleep(API_TIMEOUT)
|
||
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()
|
||
if should_translate(content, config):
|
||
translated = safe_translate(content, target_lang='ru')
|
||
translated = fix_placeholders(translated)
|
||
if 'r' not in prefix.lower() and len(quote) == 1:
|
||
if quote == '"': translated = translated.replace('"', '\\"')
|
||
elif quote == "'": translated = translated.replace("'", "\\'")
|
||
replacements.append((tok.start, tok.end, f"{prefix}{quote}{translated}{quote}"))
|
||
time.sleep(API_TIMEOUT)
|
||
|
||
else:
|
||
pattern = re.compile(
|
||
r'(/\*.*?\*/)|'
|
||
r'(//.*?$)|'
|
||
r'("(?:\\.|[^"\\])*")|'
|
||
r"('(?:\\.|[^\'\\])*')|"
|
||
r'(`(?:\\.|[^`\\])*`)',
|
||
re.DOTALL | re.MULTILINE
|
||
)
|
||
for match in pattern.finditer(source):
|
||
text = match.group(0)
|
||
is_comment_multi = text.startswith('/*')
|
||
is_comment_single = text.startswith('//')
|
||
|
||
if is_comment_multi: content = text[2:-2]
|
||
elif is_comment_single: content = text[2:]
|
||
else: content = text[1:-1]
|
||
|
||
if should_translate(content, config):
|
||
translated = safe_translate(content, target_lang='ru')
|
||
translated = fix_placeholders(translated)
|
||
|
||
if is_comment_multi: new_text = f"/*{translated}*/"
|
||
elif is_comment_single: new_text = f"//{translated}"
|
||
else:
|
||
quote = text[0]
|
||
if quote == '"': translated = translated.replace('"', '\\"')
|
||
elif quote == "'": translated = translated.replace("'", "\\'")
|
||
elif quote == "`": translated = translated.replace("`", "\\`")
|
||
new_text = f"{quote}{translated}{quote}"
|
||
|
||
replacements.append((match.start(), match.end(), new_text))
|
||
time.sleep(API_TIMEOUT)
|
||
|
||
if not replacements:
|
||
return "skipped"
|
||
|
||
if config['backup_enabled']:
|
||
shutil.copy2(filepath, filepath + ".bak")
|
||
|
||
if filepath.endswith('.py'):
|
||
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:]
|
||
final_source = "".join(lines)
|
||
else:
|
||
for start, end, new_text in reversed(replacements):
|
||
source = source[:start] + new_text + source[end:]
|
||
final_source = source
|
||
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(final_source)
|
||
|
||
return "translated"
|
||
|
||
|
||
# ==========================================
|
||
# ФРОНТЭНД: ГРАФИЧЕСКИЙ ИНТЕРФЕЙС (TKINTER)
|
||
# ==========================================
|
||
|
||
class TranslatorApp:
|
||
def __init__(self, root):
|
||
self.root = root
|
||
self.root.title("PRO Локализатор Кода (Безопасный режим)")
|
||
self.root.geometry("750x700")
|
||
self.root.minsize(650, 650)
|
||
|
||
style = ttk.Style()
|
||
if 'clam' in style.theme_names():
|
||
style.theme_use('clam')
|
||
|
||
self.build_ui()
|
||
|
||
def build_ui(self):
|
||
# --- ВЕРХНЯЯ ПАНЕЛЬ ---
|
||
top_frame = ttk.Frame(self.root, padding=10)
|
||
top_frame.pack(fill=tk.X)
|
||
|
||
ttk.Label(top_frame, text="Настройки безопасного перевода", font=("Arial", 12, "bold")).pack(side=tk.LEFT)
|
||
ttk.Button(top_frame, text="⚡ Быстрый перевод", command=self.open_quick_translate).pack(side=tk.RIGHT)
|
||
|
||
# --- ГЛАВНАЯ ПАНЕЛЬ ---
|
||
main_frame = ttk.LabelFrame(self.root, padding=15)
|
||
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
||
|
||
# 1. Выбор папки
|
||
f_folder = ttk.Frame(main_frame)
|
||
f_folder.pack(fill=tk.X, pady=5)
|
||
ttk.Label(f_folder, text="Папка с кодом:").pack(side=tk.LEFT, padx=(0,5))
|
||
self.folder_var = tk.StringVar()
|
||
ttk.Entry(f_folder, textvariable=self.folder_var, state='readonly').pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
ttk.Button(f_folder, text="Обзор...", command=self.browse_folder).pack(side=tk.LEFT, padx=(5,0))
|
||
|
||
# 2. Расширения файлов
|
||
f_ext = ttk.Frame(main_frame)
|
||
f_ext.pack(fill=tk.X, pady=5)
|
||
ttk.Label(f_ext, text="Расширения файлов:").pack(side=tk.LEFT, padx=(0,5))
|
||
self.ext_var = tk.StringVar(value=".py, .qml, .js")
|
||
ttk.Entry(f_ext, textvariable=self.ext_var).pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
ttk.Label(f_ext, text="(через запятую)", foreground="gray").pack(side=tk.LEFT, padx=5)
|
||
|
||
# 3. Чекбоксы безопасности
|
||
self.backup_var = tk.BooleanVar(value=True)
|
||
ttk.Checkbutton(main_frame, text="Создавать .bak файлы оригиналов перед переводом", variable=self.backup_var).pack(anchor=tk.W, pady=(10,0))
|
||
|
||
self.skip_tech_var = tk.BooleanVar(value=True)
|
||
ttk.Checkbutton(main_frame, text="🛡️ Умный фильтр: НЕ переводить технические строки (ключи, пути, camelCase)", variable=self.skip_tech_var).pack(anchor=tk.W, pady=5)
|
||
|
||
# 4. Настройки языков источника
|
||
f_lang = ttk.Frame(main_frame)
|
||
f_lang.pack(fill=tk.X, pady=5)
|
||
|
||
self.translate_only_var = tk.BooleanVar(value=False)
|
||
cb_only = ttk.Checkbutton(f_lang, text="Переводить ТОЛЬКО с выбранных языков (иначе переведет всё)",
|
||
variable=self.translate_only_var, command=self.toggle_lang_list)
|
||
cb_only.pack(anchor=tk.W)
|
||
|
||
self.langs_listbox = tk.Listbox(f_lang, selectmode=tk.MULTIPLE, height=5, exportselection=False)
|
||
self.langs_listbox.pack(side=tk.LEFT, fill=tk.X, expand=True, pady=5)
|
||
scrollbar = ttk.Scrollbar(f_lang, orient="vertical", command=self.langs_listbox.yview)
|
||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=5)
|
||
self.langs_listbox.config(yscrollcommand=scrollbar.set)
|
||
|
||
for lang in LANGUAGES_LIST:
|
||
self.langs_listbox.insert(tk.END, lang)
|
||
self.langs_listbox.config(state=tk.DISABLED)
|
||
|
||
# Прогресс
|
||
self.progress_var = tk.DoubleVar()
|
||
self.progress_bar = ttk.Progressbar(main_frame, variable=self.progress_var, maximum=100)
|
||
self.progress_bar.pack(fill=tk.X, pady=15)
|
||
|
||
self.lbl_stats = ttk.Label(main_frame, text="Ожидание...")
|
||
self.lbl_stats.pack(anchor=tk.W)
|
||
|
||
# Кнопка старта
|
||
self.btn_start = ttk.Button(main_frame, text="▶ Начать массовый перевод", command=self.start_translation)
|
||
self.btn_start.pack(fill=tk.X, pady=10)
|
||
|
||
# Логи
|
||
self.txt_log = scrolledtext.ScrolledText(main_frame, height=8, state=tk.DISABLED, bg="#f4f4f4")
|
||
self.txt_log.pack(fill=tk.BOTH, expand=True)
|
||
|
||
def toggle_lang_list(self):
|
||
state = tk.NORMAL if self.translate_only_var.get() else tk.DISABLED
|
||
self.langs_listbox.config(state=state)
|
||
|
||
def browse_folder(self):
|
||
folder = filedialog.askdirectory()
|
||
if folder: self.folder_var.set(folder)
|
||
|
||
def log(self, text):
|
||
def append():
|
||
self.txt_log.config(state=tk.NORMAL)
|
||
self.txt_log.insert(tk.END, text + "\n")
|
||
self.txt_log.see(tk.END)
|
||
self.txt_log.config(state=tk.DISABLED)
|
||
self.root.after(0, append)
|
||
|
||
def start_translation(self):
|
||
target_dir = self.folder_var.get()
|
||
if not target_dir: return messagebox.showwarning("Внимание", "Укажите папку проекта!")
|
||
|
||
ext_str = self.ext_var.get()
|
||
exts = tuple(e.strip().lower() for e in ext_str.split(',') if e.strip())
|
||
if not exts: return messagebox.showwarning("Внимание", "Укажите хотя бы одно расширение!")
|
||
|
||
selected_langs =[]
|
||
if self.translate_only_var.get():
|
||
indices = self.langs_listbox.curselection()
|
||
if not indices:
|
||
return messagebox.showwarning("Внимание", "Вы не выбрали ни одного языка в списке!")
|
||
for i in indices:
|
||
lang_name = self.langs_listbox.get(i).lower()
|
||
lang_code = LANGUAGES_DICT.get(lang_name)
|
||
if lang_code: selected_langs.append(lang_code.lower())
|
||
|
||
config = {
|
||
'target_dir': target_dir,
|
||
'exts': exts,
|
||
'backup_enabled': self.backup_var.get(),
|
||
'skip_technical': self.skip_tech_var.get(),
|
||
'translate_only': self.translate_only_var.get(),
|
||
'selected_codes': selected_langs
|
||
}
|
||
|
||
self.btn_start.config(state=tk.DISABLED)
|
||
self.txt_log.config(state=tk.NORMAL)
|
||
self.txt_log.delete(1.0, tk.END)
|
||
self.txt_log.config(state=tk.DISABLED)
|
||
|
||
threading.Thread(target=self.worker, args=(config,), daemon=True).start()
|
||
|
||
def worker(self, config):
|
||
self.log("[i] Сканирование файлов...")
|
||
target_files =[]
|
||
for root, _, files in os.walk(config['target_dir']):
|
||
for file in files:
|
||
if file.lower().endswith(config['exts']):
|
||
target_files.append(os.path.join(root, file))
|
||
|
||
total = len(target_files)
|
||
if total == 0:
|
||
self.log("[!] Файлы с указанными расширениями не найдены.")
|
||
self.root.after(0, lambda: self.btn_start.config(state=tk.NORMAL))
|
||
return
|
||
|
||
translated_c = skipped_c = err_c = 0
|
||
|
||
for i, filepath in enumerate(target_files, 1):
|
||
short_p = os.path.relpath(filepath, config['target_dir'])
|
||
|
||
self.root.after(0, lambda v=i, t=total: self.progress_var.set(v / t * 100))
|
||
self.root.after(0, lambda s=short_p: self.lbl_stats.config(text=f"Обработка: {s}"))
|
||
|
||
res = process_file(filepath, config, self.log)
|
||
if res == "translated":
|
||
translated_c += 1
|
||
self.log(f"[+] Переведено: {short_p}")
|
||
elif res == "skipped":
|
||
skipped_c += 1
|
||
else:
|
||
err_c += 1
|
||
self.log(f"[!] Ошибка кодировки: {short_p}")
|
||
|
||
self.log("="*40)
|
||
self.log(f"ГОТОВО! Переведено: {translated_c} | Пропущено: {skipped_c} | Ошибок: {err_c}")
|
||
self.root.after(0, lambda: self.lbl_stats.config(text="Работа завершена."))
|
||
self.root.after(0, lambda: self.btn_start.config(state=tk.NORMAL))
|
||
|
||
|
||
# ==========================================
|
||
# ОКНО БЫСТРОГО ПЕРЕВОДА
|
||
# ==========================================
|
||
def open_quick_translate(self):
|
||
qt_win = tk.Toplevel(self.root)
|
||
qt_win.title("⚡ Быстрый перевод")
|
||
qt_win.geometry("850x550")
|
||
qt_win.minsize(650, 450)
|
||
|
||
# --- Верхняя панель языков ---
|
||
top_f = ttk.Frame(qt_win, padding=10)
|
||
top_f.pack(fill=tk.X)
|
||
|
||
ttk.Label(top_f, text="Целевой язык:").pack(side=tk.LEFT)
|
||
target_lang_cb = ttk.Combobox(top_f, values=LANGUAGES_LIST, state="readonly", width=20)
|
||
target_lang_cb.set("Russian")
|
||
target_lang_cb.pack(side=tk.LEFT, padx=10)
|
||
|
||
self.lbl_detected = ttk.Label(top_f, text="Исходный: Автоопределение", foreground="blue")
|
||
self.lbl_detected.pack(side=tk.RIGHT)
|
||
|
||
# --- Разделитель текстовых панелей ---
|
||
paned = ttk.PanedWindow(qt_win, orient=tk.HORIZONTAL)
|
||
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
||
|
||
left_f = ttk.Frame(paned)
|
||
right_f = ttk.Frame(paned)
|
||
paned.add(left_f, weight=1)
|
||
paned.add(right_f, weight=1)
|
||
|
||
# -------------------------------------
|
||
# ЛЕВАЯ ЧАСТЬ (ВВОД + ИКОНКА ОЧИСТКИ)
|
||
# -------------------------------------
|
||
left_header = ttk.Frame(left_f)
|
||
left_header.pack(fill=tk.X, pady=(0, 2))
|
||
|
||
ttk.Label(left_header, text="Вставьте текст (Enter - перевод):").pack(side=tk.LEFT)
|
||
|
||
def clear_fields():
|
||
txt_in.delete(1.0, tk.END)
|
||
txt_out.config(state=tk.NORMAL)
|
||
txt_out.delete(1.0, tk.END)
|
||
txt_out.config(state=tk.DISABLED)
|
||
|
||
# Кнопка-иконка Очистки (Корзина)
|
||
btn_clear = ttk.Button(left_header, text="🗑️ Очистить", command=clear_fields)
|
||
btn_clear.pack(side=tk.RIGHT)
|
||
|
||
txt_in = scrolledtext.ScrolledText(left_f, wrap=tk.WORD, font=("Arial", 11))
|
||
txt_in.pack(fill=tk.BOTH, expand=True)
|
||
|
||
# -------------------------------------
|
||
# ПРАВАЯ ЧАСТЬ (ВЫВОД + ИКОНКА СОХРАНЕНИЯ)
|
||
# -------------------------------------
|
||
right_header = ttk.Frame(right_f)
|
||
right_header.pack(fill=tk.X, pady=(0, 2))
|
||
|
||
ttk.Label(right_header, text="Перевод:").pack(side=tk.LEFT)
|
||
|
||
def save_to_file():
|
||
text = txt_out.get(1.0, tk.END).strip()
|
||
if not text: return
|
||
filepath = filedialog.asksaveasfilename(defaultextension=".txt", filetypes=[("Text files", "*.txt")])
|
||
if filepath:
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(text)
|
||
messagebox.showinfo("Сохранено", "Текст успешно сохранен в файл!")
|
||
|
||
# Кнопка-иконка Сохранения (Дискета)
|
||
btn_save = ttk.Button(right_header, text="💾 Сохранить", command=save_to_file)
|
||
btn_save.pack(side=tk.RIGHT)
|
||
|
||
txt_out = scrolledtext.ScrolledText(right_f, wrap=tk.WORD, font=("Arial", 11), bg="#f9f9f9")
|
||
txt_out.pack(fill=tk.BOTH, expand=True)
|
||
|
||
# --- Нижняя панель (Только кнопка старта перевода) ---
|
||
bot_f = ttk.Frame(qt_win, padding=10)
|
||
bot_f.pack(fill=tk.X)
|
||
|
||
def run_translation(event=None):
|
||
src_text = txt_in.get(1.0, tk.END).strip()
|
||
if not src_text:
|
||
return "break" if event else None
|
||
|
||
target_name = target_lang_cb.get().lower()
|
||
target_code = LANGUAGES_DICT.get(target_name, 'ru')
|
||
|
||
btn_trans.config(state=tk.DISABLED, text="Перевод...")
|
||
txt_out.config(state=tk.NORMAL)
|
||
txt_out.delete(1.0, tk.END)
|
||
txt_out.insert(tk.END, "Запрос к серверу...")
|
||
txt_out.config(state=tk.DISABLED)
|
||
|
||
def task():
|
||
det_code = detect_language(src_text)
|
||
det_name = "Неизвестно"
|
||
if det_code:
|
||
for name, code in LANGUAGES_DICT.items():
|
||
if code.lower() == det_code.lower():
|
||
det_name = name.capitalize()
|
||
break
|
||
|
||
try:
|
||
res = safe_translate(src_text, target_lang=target_code, source_lang='auto')
|
||
except Exception as e:
|
||
res = f"Ошибка: {e}"
|
||
|
||
def update_ui():
|
||
self.lbl_detected.config(text=f"Определен язык: {det_name}")
|
||
txt_out.config(state=tk.NORMAL)
|
||
txt_out.delete(1.0, tk.END)
|
||
txt_out.insert(tk.END, res)
|
||
txt_out.config(state=tk.DISABLED)
|
||
btn_trans.config(state=tk.NORMAL, text="Перевести ➔")
|
||
qt_win.after(0, update_ui)
|
||
|
||
threading.Thread(target=task, daemon=True).start()
|
||
|
||
if event:
|
||
return "break"
|
||
|
||
btn_trans = ttk.Button(bot_f, text="Перевести ➔", command=run_translation)
|
||
btn_trans.pack(side=tk.LEFT)
|
||
|
||
# Биндинги клавиш для поля ввода
|
||
txt_in.bind("<Return>", run_translation)
|
||
txt_in.bind("<Shift-Return>", lambda e: None)
|
||
|
||
if __name__ == "__main__":
|
||
root = tk.Tk()
|
||
app = TranslatorApp(root)
|
||
root.mainloop() |