Files

484 lines
17 KiB
Python
Raw Permalink Normal View History

2026-05-31 18:45:38 +08:00
import customtkinter as ctk
import tkinter as tk
from tkinter import filedialog, messagebox
import subprocess
import re
import threading
from pathlib import Path
import os
import tempfile
class TranslatorApp(ctk.CTk):
def __init__(self):
super().__init__()
# Window configuration
self.title("Ollama Translator - Русский переводчик")
self.geometry("1000x700")
# Set theme
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
# Variables
self.is_translating = False
self.current_file = None
# Create UI
self.create_widgets()
# Enable drag and drop (using tkinterdnd2 if available)
try:
self.setup_drag_drop()
except:
pass # Drag and drop not available, skip it
def create_widgets(self):
# Main container
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# Header
header_frame = ctk.CTkFrame(self, fg_color="transparent")
header_frame.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew")
title_label = ctk.CTkLabel(
header_frame,
text="🌐 Ollama Translator",
font=ctk.CTkFont(size=24, weight="bold")
)
title_label.pack(side="left")
subtitle_label = ctk.CTkLabel(
header_frame,
text="Uncensored перевод на русский язык",
font=ctk.CTkFont(size=12),
text_color="gray"
)
subtitle_label.pack(side="left", padx=10)
# Theme switch
self.theme_switch = ctk.CTkSwitch(
header_frame,
text="🌙 Темная тема",
command=self.toggle_theme,
onvalue="dark",
offvalue="light"
)
self.theme_switch.pack(side="right")
self.theme_switch.select()
# Main content area
content_frame = ctk.CTkFrame(self)
content_frame.grid(row=1, column=0, padx=20, pady=10, sticky="nsew")
content_frame.grid_columnconfigure(0, weight=1)
content_frame.grid_columnconfigure(1, weight=1)
content_frame.grid_rowconfigure(1, weight=1)
# Source text area
source_label = ctk.CTkLabel(
content_frame,
text="📝 Исходный текст",
font=ctk.CTkFont(size=14, weight="bold")
)
source_label.grid(row=0, column=0, padx=10, pady=(10, 5), sticky="w")
self.source_text = ctk.CTkTextbox(
content_frame,
font=ctk.CTkFont(size=13),
wrap="word"
)
self.source_text.grid(row=1, column=0, padx=(10, 5), pady=(0, 10), sticky="nsew")
# Character counter for source
self.source_counter = ctk.CTkLabel(
content_frame,
text="Символов: 0",
font=ctk.CTkFont(size=10),
text_color="gray"
)
self.source_counter.grid(row=2, column=0, padx=10, pady=(0, 5), sticky="w")
# Bind text change event
self.source_text.bind("<KeyRelease>", self.update_char_count)
# Translation text area
translation_label = ctk.CTkLabel(
content_frame,
text="🇷🇺 Перевод на русский",
font=ctk.CTkFont(size=14, weight="bold")
)
translation_label.grid(row=0, column=1, padx=10, pady=(10, 5), sticky="w")
self.translation_text = ctk.CTkTextbox(
content_frame,
font=ctk.CTkFont(size=13),
wrap="word"
)
self.translation_text.grid(row=1, column=1, padx=(5, 10), pady=(0, 10), sticky="nsew")
# Character counter for translation
self.translation_counter = ctk.CTkLabel(
content_frame,
text="Символов: 0",
font=ctk.CTkFont(size=10),
text_color="gray"
)
self.translation_counter.grid(row=2, column=1, padx=10, pady=(0, 5), sticky="w")
# Control buttons
button_frame = ctk.CTkFrame(self, fg_color="transparent")
button_frame.grid(row=2, column=0, padx=20, pady=10, sticky="ew")
# Left side buttons
left_buttons = ctk.CTkFrame(button_frame, fg_color="transparent")
left_buttons.pack(side="left", fill="x", expand=True)
self.open_btn = ctk.CTkButton(
left_buttons,
text="📁 Открыть файл",
command=self.open_file,
width=140,
height=35
)
self.open_btn.pack(side="left", padx=5)
self.paste_btn = ctk.CTkButton(
left_buttons,
text="📋 Вставить",
command=self.paste_text,
width=120,
height=35
)
self.paste_btn.pack(side="left", padx=5)
self.clear_btn = ctk.CTkButton(
left_buttons,
text="🗑️ Очистить",
command=self.clear_all,
width=120,
height=35,
fg_color="gray40",
hover_color="gray30"
)
self.clear_btn.pack(side="left", padx=5)
# Center button (Translate)
self.translate_btn = ctk.CTkButton(
button_frame,
text="🔄 Перевести",
command=self.translate_text,
width=180,
height=40,
font=ctk.CTkFont(size=14, weight="bold"),
fg_color="#1f6aa5",
hover_color="#144870"
)
self.translate_btn.pack(side="left", padx=20)
# Right side buttons
right_buttons = ctk.CTkFrame(button_frame, fg_color="transparent")
right_buttons.pack(side="right", fill="x", expand=True)
self.copy_btn = ctk.CTkButton(
right_buttons,
text="📄 Копировать",
command=self.copy_translation,
width=140,
height=35
)
self.copy_btn.pack(side="right", padx=5)
self.save_btn = ctk.CTkButton(
right_buttons,
text="💾 Сохранить",
command=self.save_translation,
width=140,
height=35
)
self.save_btn.pack(side="right", padx=5)
# Status bar
self.status_frame = ctk.CTkFrame(self, height=30)
self.status_frame.grid(row=3, column=0, padx=20, pady=(0, 20), sticky="ew")
self.status_label = ctk.CTkLabel(
self.status_frame,
text="✅ Готов к работе",
font=ctk.CTkFont(size=11)
)
self.status_label.pack(side="left", padx=10, pady=5)
self.progress_bar = ctk.CTkProgressBar(self.status_frame, width=200)
self.progress_bar.pack(side="right", padx=10, pady=5)
self.progress_bar.set(0)
# Keyboard shortcuts
self.bind("<Control-o>", lambda e: self.open_file())
self.bind("<Control-v>", lambda e: self.paste_text())
self.bind("<Control-s>", lambda e: self.save_translation())
self.bind("<Control-Return>", lambda e: self.translate_text())
self.bind("<F5>", lambda e: self.translate_text())
def setup_drag_drop(self):
"""Setup drag and drop for files (requires tkinterdnd2)"""
try:
from tkinterdnd2 import DND_FILES, TkinterDnD
# This would require tkinterdnd2 package
# For now, we'll skip this feature
pass
except ImportError:
# tkinterdnd2 not available, drag-drop disabled
pass
def on_drop(self, event):
"""Handle file drop"""
files = self.tk.splitlist(event.data)
if files:
self.load_file(files[0])
def toggle_theme(self):
"""Toggle between dark and light theme"""
if self.theme_switch.get() == "dark":
ctk.set_appearance_mode("dark")
else:
ctk.set_appearance_mode("light")
def update_char_count(self, event=None):
"""Update character counter"""
text = self.source_text.get("1.0", "end-1c")
char_count = len(text)
word_count = len(text.split())
self.source_counter.configure(text=f"Символов: {char_count} | Слов: {word_count}")
def update_translation_count(self):
"""Update translation character counter"""
text = self.translation_text.get("1.0", "end-1c")
char_count = len(text)
word_count = len(text.split())
self.translation_counter.configure(text=f"Символов: {char_count} | Слов: {word_count}")
def open_file(self):
"""Open file dialog and load file"""
file_path = filedialog.askopenfilename(
title="Выберите файл для перевода",
filetypes=[
("Текстовые файлы", "*.txt"),
("Markdown файлы", "*.md"),
("Все файлы", "*.*")
]
)
if file_path:
self.load_file(file_path)
def load_file(self, file_path):
"""Load file content"""
try:
# Try different encodings
encodings = ['utf-8', 'cp1251', 'latin-1']
content = None
for encoding in encodings:
try:
with open(file_path, 'r', encoding=encoding) as f:
content = f.read()
break
except UnicodeDecodeError:
continue
if content is None:
messagebox.showerror("Ошибка", "Не удалось прочитать файл")
return
self.source_text.delete("1.0", "end")
self.source_text.insert("1.0", content)
self.current_file = file_path
self.update_char_count()
self.update_status(f"📁 Загружен: {Path(file_path).name}")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось открыть файл:\n{str(e)}")
def paste_text(self):
"""Paste text from clipboard"""
try:
clipboard_text = self.clipboard_get()
self.source_text.delete("1.0", "end")
self.source_text.insert("1.0", clipboard_text)
self.update_char_count()
self.update_status("📋 Текст вставлен из буфера обмена")
except:
messagebox.showwarning("Предупреждение", "Буфер обмена пуст")
def clear_all(self):
"""Clear all text fields"""
self.source_text.delete("1.0", "end")
self.translation_text.delete("1.0", "end")
self.current_file = None
self.update_char_count()
self.update_translation_count()
self.update_status("🗑️ Поля очищены")
def translate_text(self):
"""Translate text using ollama model"""
if self.is_translating:
return
text = self.source_text.get("1.0", "end-1c").strip()
if not text:
messagebox.showwarning("Предупреждение", "Введите текст для перевода")
return
# Run translation in separate thread
thread = threading.Thread(target=self._translate_worker, args=(text,))
thread.daemon = True
thread.start()
def _translate_worker(self, text):
"""Worker thread for translation"""
self.is_translating = True
self.update_status("⏳ Перевод...")
self.translate_btn.configure(state="disabled", text="⏳ Перевожу...")
try:
# Split text into chunks if it's too long
chunk_size = 800 # Characters per chunk
chunks = self._split_text_into_chunks(text, chunk_size)
total_chunks = len(chunks)
translated_parts = []
for i, chunk in enumerate(chunks):
# Update progress
progress = (i + 1) / total_chunks
self.progress_bar.set(progress)
if total_chunks > 1:
self.after(0, lambda p=i+1, t=total_chunks:
self.update_status(f"⏳ Перевод части {p}/{t}..."))
# Translate chunk
result = subprocess.run(
['ollama', 'run', 'translator', chunk],
capture_output=True,
text=True,
encoding='utf-8',
timeout=120
)
# Filter output
output = result.stdout
output = re.sub(r'<think>.*?</think>', '', output, flags=re.DOTALL)
output = '\n'.join(line.strip() for line in output.split('\n') if line.strip())
translated_parts.append(output)
# Combine all parts
final_translation = '\n\n'.join(translated_parts)
# Update UI in main thread
self.after(0, self._update_translation, final_translation)
except subprocess.TimeoutExpired:
self.after(0, lambda: messagebox.showerror("Ошибка", "Превышено время ожидания перевода"))
self.after(0, lambda: self.update_status("❌ Ошибка: таймаут"))
except Exception as e:
self.after(0, lambda: messagebox.showerror("Ошибка", f"Ошибка перевода:\n{str(e)}"))
self.after(0, lambda: self.update_status("❌ Ошибка перевода"))
finally:
self.is_translating = False
self.progress_bar.set(0)
self.translate_btn.configure(state="normal", text="🔄 Перевести")
def _split_text_into_chunks(self, text, chunk_size=800):
"""Split text into chunks, trying to break at sentence boundaries"""
if len(text) <= chunk_size:
return [text]
chunks = []
current_chunk = ""
# Split by sentences (simple approach)
sentences = re.split(r'([.!?]\s+|\n\n)', text)
for i in range(0, len(sentences), 2):
sentence = sentences[i]
separator = sentences[i + 1] if i + 1 < len(sentences) else ""
# If adding this sentence exceeds chunk size, start new chunk
if len(current_chunk) + len(sentence) + len(separator) > chunk_size and current_chunk:
chunks.append(current_chunk.strip())
current_chunk = sentence + separator
else:
current_chunk += sentence + separator
# Add remaining text
if current_chunk.strip():
chunks.append(current_chunk.strip())
# If no sentence boundaries found, split by character count
if not chunks:
chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
return chunks
def _update_translation(self, translation):
"""Update translation text in UI"""
self.translation_text.delete("1.0", "end")
self.translation_text.insert("1.0", translation)
self.update_translation_count()
self.progress_bar.set(1.0)
self.update_status("✅ Перевод завершен")
def copy_translation(self):
"""Copy translation to clipboard"""
translation = self.translation_text.get("1.0", "end-1c")
if translation.strip():
self.clipboard_clear()
self.clipboard_append(translation)
self.update_status("📄 Перевод скопирован в буфер обмена")
else:
messagebox.showwarning("Предупреждение", "Нет текста для копирования")
def save_translation(self):
"""Save translation to file"""
translation = self.translation_text.get("1.0", "end-1c")
if not translation.strip():
messagebox.showwarning("Предупреждение", "Нет текста для сохранения")
return
# Suggest filename
if self.current_file:
default_name = Path(self.current_file).stem + "_ru.txt"
else:
default_name = "translation_ru.txt"
file_path = filedialog.asksaveasfilename(
title="Сохранить перевод",
defaultextension=".txt",
initialfile=default_name,
filetypes=[
("Текстовые файлы", "*.txt"),
("Markdown файлы", "*.md"),
("Все файлы", "*.*")
]
)
if file_path:
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(translation)
self.update_status(f"💾 Сохранено: {Path(file_path).name}")
messagebox.showinfo("Успех", f"Перевод сохранен:\n{file_path}")
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось сохранить файл:\n{str(e)}")
def update_status(self, message):
"""Update status bar message"""
self.status_label.configure(text=message)
if __name__ == "__main__":
app = TranslatorApp()
app.mainloop()