Initial commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dinlo
2026-05-31 18:45:49 +08:00
commit 5dc00d6e70
21 changed files with 103910 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
import os
import re
import tokenize
import io
import time
import random
import threading
import textwrap
import shutil
import urllib.request
import json
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, engine="Google Translate", ollama_model=""):
super().__init__()
self.path = path
self.is_file = is_file
self.recursive = recursive
self.extensions =[ext.strip().lower() for ext in extensions.split(',')]
self.only_cjk = only_cjk
self.save_as_new = save_as_new
self.create_bak = create_bak
self.engine = engine
self.ollama_model = ollama_model
self.target_lang = target_lang
# Маппинг кодов языков в их английские названия для промпта Ollama
self.lang_map = {
"ru": "Russian",
"en": "English"
}
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_with_ollama(self, text):
url = "http://localhost:11434/api/chat"
lang_name = self.lang_map.get(self.target_lang, "Russian")
system_prompt = (
f"You are a professional translator. Your task is to translate any text provided by the user into {lang_name}. "
"CRITICAL RULES: "
f"1. Output ONLY the translated {lang_name} text. "
"2. DO NOT add any introductions, notes, reasoning, or explanations. "
"3. Maintain exactly the original formatting, punctuation, line breaks, and special characters. "
"4. DO NOT translate variables or code syntax, translate only the readable text content."
)
data = {
"model": self.ollama_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text}
],
"stream": False,
"options": {
"temperature": 0.1,
"num_ctx": 8192
}
}
# Делаем 2 попытки на случай сбоя Ollama
for attempt in range(2):
try:
# Увеличен таймаут до 600 секунд (10 минут) для медленных генераций или инициализации модели в VRAM
req = urllib.request.Request(url, data=json.dumps(data).encode('utf-8'), headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, timeout=600) as response:
result = json.loads(response.read().decode('utf-8'))
translated = result.get('message', {}).get('content', '').strip()
if translated:
return translated
except Exception as e:
if attempt == 1:
self.log(f"Ошибка Ollama (модель '{self.ollama_model}'): {e}")
time.sleep(2)
return text
def process_chunk(self, chunk):
"""Единый метод перевода для одного куска (чанка) текста"""
if self.engine == "Ollama":
return self.translate_with_ollama(chunk)
else:
self.check_limits()
return self.translator.translate(chunk)
def translate_text(self, text):
if not text.strip() or not self.has_target_chars(text):
return text
try:
# Для Google Translate мы можем отправлять куски по 4000 символов
# Для Ollama куски нужно делать меньше (~1500 символов), чтобы избежать таймаутов,
# потери контекста и ускорить пошаговую генерацию.
max_chunk_size = 4000 if self.engine == "Google Translate" else 1500
if len(text) > max_chunk_size:
chunks = textwrap.wrap(text, max_chunk_size, break_long_words=False, replace_whitespace=False)
translated_chunks = []
for chunk in chunks:
res = self.process_chunk(chunk)
translated_chunks.append(res)
translated = " ".join(translated_chunks)
else:
translated = self.process_chunk(text)
return self.fix_orthography(translated)
except RequestError:
# Отрабатывает только при обрыве сети Google Translate
if self.engine == "Google Translate":
self.log("Ошибка сети Google. Повторная попытка через 3 сек...")
time.sleep(3)
return self.translate_text(text)
return 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("900x700")
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()
self.refresh_ollama_models()
def get_ollama_models(self):
try:
req = urllib.request.Request("http://localhost:11434/api/tags")
with urllib.request.urlopen(req, timeout=2) as response:
data = json.loads(response.read().decode('utf-8'))
models = [m['name'] for m in data.get('models', [])]
return models if models else ["Нет доступных моделей"]
except Exception:
return ["Ollama не запущена"]
def refresh_ollama_models(self):
models = self.get_ollama_models()
self.opt_ollama.configure(values=models)
if models and models[0] not in ["Нет доступных моделей", "Ollama не запущена"]:
self.opt_ollama.set(models[0])
else:
self.opt_ollama.set(models[0] if models else "")
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="Создать новый файл (суффикс языка)", 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_engine = ctk.CTkOptionMenu(self.frame_settings, values=["Google Translate", "Ollama"], command=self.toggle_engine)
self.opt_engine.pack(pady=5, padx=10, fill="x")
# Блок Ollama
frame_ollama = ctk.CTkFrame(self.frame_settings, fg_color="transparent")
frame_ollama.pack(fill="x", pady=(5, 0), padx=5)
self.lbl_ollama = ctk.CTkLabel(frame_ollama, text="Модель Ollama:")
self.lbl_ollama.pack(side="left", padx=5)
self.btn_refresh_models = ctk.CTkButton(frame_ollama, text="🔄 Обновить", width=80, command=self.refresh_ollama_models)
self.btn_refresh_models.pack(side="right", padx=5)
self.opt_ollama = ctk.CTkOptionMenu(self.frame_settings, values=["Загрузка..."])
self.opt_ollama.pack(pady=5, padx=10, fill="x")
# Язык
self.lbl_lang = ctk.CTkLabel(self.frame_settings, text="Целевой язык:")
self.lbl_lang.pack(pady=(10, 0), padx=10, anchor="w")
self.opt_lang = ctk.CTkOptionMenu(self.frame_settings, values=["ru (Русский)", "en (Английский)", "uk (Украинский)"])
self.opt_lang.set("ru (Русский)") # По умолчанию русский язык
self.opt_lang.pack(pady=5, padx=10, fill="x")
# Инициализация интерфейса (скрытие/блокировка лишних полей)
self.toggle_engine("Google Translate")
# Ограничение по 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_engine(self, choice):
if choice == "Ollama":
self.opt_ollama.configure(state="normal")
self.btn_refresh_models.configure(state="normal")
else:
self.opt_ollama.configure(state="disabled")
self.btn_refresh_models.configure(state="disabled")
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.opt_engine.configure(state="disabled")
self.opt_ollama.configure(state="disabled")
self.btn_refresh_models.configure(state="disabled")
self.opt_lang.configure(state="disabled")
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,
engine=self.opt_engine.get(),
ollama_model=self.opt_ollama.get()
)
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.opt_engine.configure(state="normal")
self.opt_lang.configure(state="normal")
self.toggle_engine(self.opt_engine.get()) # Восстанавливаем состояние полей
self.toggle_backup_chk() # Восстанавливаем состояние чекбокса бэкапов
self.btn_stop.configure(state="disabled")
self.after(0, update)
if __name__ == "__main__":
app = App()
app.mainloop()