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

492 lines
21 KiB
Python
Raw Permalink 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 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()