Files

492 lines
21 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 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()