feat: restructure menu and add file import

- Consolidate "Add word" menu with submenu (Manual, Thematic, Import)
- Add file import support (.txt, .md) with AI batch translation
- Add vocabulary pagination with navigation buttons
- Add "Add word" button in tasks for new words mode
- Fix undefined variables bug in vocabulary confirm handler
- Add localization keys for add_menu in ru/en/ja

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-05 20:15:47 +03:00
parent 2097950c60
commit 63e2615243
12 changed files with 883 additions and 47 deletions

View File

@@ -1,4 +1,5 @@
from aiogram import Router, F
import re
from aiogram import Router, F, Bot
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
@@ -14,6 +15,11 @@ from utils.levels import get_user_level_for_language
router = Router()
# Поддерживаемые расширения файлов
SUPPORTED_EXTENSIONS = {'.txt', '.md'}
# Разделители между словом и переводом
WORD_SEPARATORS = re.compile(r'\s*[-–—:=\t]\s*')
class ImportStates(StatesGroup):
"""Состояния для импорта слов из текста"""
@@ -271,3 +277,230 @@ async def close_import(callback: CallbackQuery, state: FSMContext):
await callback.message.delete()
await state.clear()
await callback.answer()
def parse_word_line(line: str) -> dict | None:
"""
Парсит строку формата 'слово - перевод' или 'слово : перевод'
Или просто 'слово' (без перевода)
Возвращает dict с word и translation (может быть None) или None если пустая строка
"""
line = line.strip()
if not line or line.startswith('#'): # Пропускаем пустые и комментарии
return None
# Пробуем разделить по разделителям
parts = WORD_SEPARATORS.split(line, maxsplit=1)
if len(parts) == 2:
word = parts[0].strip()
translation = parts[1].strip()
if word and translation:
return {'word': word, 'translation': translation}
# Если разделителя нет — это просто слово без перевода
word = line.strip()
if word:
return {'word': word, 'translation': None}
return None
def parse_file_content(content: str) -> tuple[list[dict], bool]:
"""
Парсит содержимое файла и возвращает список слов
Возвращает (words, needs_translation) — нужен ли перевод через AI
"""
words = []
seen = set() # Для дедупликации
needs_translation = False
for line in content.split('\n'):
parsed = parse_word_line(line)
if parsed and parsed['word'].lower() not in seen:
words.append(parsed)
seen.add(parsed['word'].lower())
if parsed['translation'] is None:
needs_translation = True
return words, needs_translation
@router.message(F.document)
async def handle_file_import(message: Message, state: FSMContext, bot: Bot):
"""Обработка файлов для импорта слов"""
document = message.document
# Проверяем расширение файла
file_name = document.file_name or ''
file_ext = ''
if '.' in file_name:
file_ext = '.' + file_name.rsplit('.', 1)[-1].lower()
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer(t('ru', 'common.start_first'))
return
lang = get_user_lang(user)
if file_ext not in SUPPORTED_EXTENSIONS:
await message.answer(t(lang, 'import_file.unsupported_format'))
return
# Проверяем размер файла (макс 1MB)
if document.file_size > 1024 * 1024:
await message.answer(t(lang, 'import_file.too_large'))
return
# Скачиваем файл
try:
file = await bot.get_file(document.file_id)
file_content = await bot.download_file(file.file_path)
content = file_content.read().decode('utf-8')
except UnicodeDecodeError:
await message.answer(t(lang, 'import_file.encoding_error'))
return
except Exception:
await message.answer(t(lang, 'import_file.download_error'))
return
# Парсим содержимое
words, needs_translation = parse_file_content(content)
if not words:
await message.answer(t(lang, 'import_file.no_words_found'))
return
# Ограничиваем количество слов
max_words = 50 if needs_translation else 100
if len(words) > max_words:
words = words[:max_words]
await message.answer(t(lang, 'import_file.truncated', n=max_words))
# Если нужен перевод — отправляем в AI
if needs_translation:
processing_msg = await message.answer(t(lang, 'import_file.translating'))
# Получаем переводы от AI
words_to_translate = [w['word'] for w in words]
translations = await ai_service.translate_words_batch(
words=words_to_translate,
source_lang=user.learning_language,
translation_lang=user.language_interface
)
await processing_msg.delete()
# Обновляем слова переводами
if isinstance(translations, list):
for i, word_data in enumerate(words):
if i < len(translations):
tr = translations[i]
word_data['translation'] = tr.get('translation', '')
word_data['transcription'] = tr.get('transcription', '')
if tr.get('reading'): # Фуригана для японского
word_data['reading'] = tr.get('reading')
else:
# Если AI вернул не список — пробуем сопоставить по слову
for word_data in words:
word_data['translation'] = ''
word_data['transcription'] = ''
# Сохраняем данные в состоянии и показываем слова
await state.update_data(
words=words,
user_id=user.id,
level=get_user_level_for_language(user)
)
await state.set_state(ImportStates.viewing_words)
await show_file_words(message, words, lang)
async def show_file_words(message: Message, words: list, lang: str):
"""Показать слова из файла с кнопками для добавления"""
# Показываем первые 20 слов в сообщении
display_words = words[:20]
text = t(lang, 'import_file.found_header', n=len(words)) + "\n\n"
for idx, word_data in enumerate(display_words, 1):
word = word_data['word']
translation = word_data.get('translation', '')
transcription = word_data.get('transcription', '')
line = f"{idx}. <b>{word}</b>"
if transcription:
line += f" [{transcription}]"
if translation:
line += f"{translation}"
text += line + "\n"
if len(words) > 20:
text += f"\n<i>...и ещё {len(words) - 20} слов</i>\n"
text += "\n" + t(lang, 'import_file.choose_action')
# Кнопки действий
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=t(lang, 'import_file.add_all_btn', n=len(words)),
callback_data="import_file_all"
)],
[InlineKeyboardButton(
text=t(lang, 'words.close_btn'),
callback_data="close_import"
)]
])
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data == "import_file_all", ImportStates.viewing_words)
async def import_file_all_words(callback: CallbackQuery, state: FSMContext):
"""Добавить все слова из файла"""
await callback.answer()
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
added_count = 0
skipped_count = 0
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data['word']
)
if existing:
skipped_count += 1
continue
# Добавляем слово
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data['word'],
word_translation=word_data.get('translation', ''),
source_lang=user.learning_language if user else None,
translation_lang=user.language_interface if user else None,
transcription=word_data.get('transcription'),
source=WordSource.IMPORT
)
added_count += 1
lang = get_user_lang(user)
result_text = t(lang, 'import.added_count', n=added_count)
if skipped_count > 0:
result_text += "\n" + t(lang, 'import.skipped_count', n=skipped_count)
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(result_text)
await state.clear()