Files
notes-qpid-pro/app/static/js/modules/grammar.js
T

624 lines
26 KiB
JavaScript
Raw 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.
// =======================================================================
// Murphy Grammar Engine & Handwritten Journaling Module (Native ES Module)
// Essential Grammar in Use (A1-A2) & English Grammar in Use (B1-B2)
// Evening Deep Work Mode, Contrast Method & Paper Notebook Journaling
// =======================================================================
import { api } from './api.js';
import { showToast, escapeHtml, renderMarkdownTable, renderMarkdownNotes } from './ui.js';
let allUnits = [];
let currentFilter = 'all';
let currentUnit = null;
let currentExerciseIdx = 0;
let isExerciseAnswered = false;
let sessionMistakes = 0;
let sessionCorrect = 0;
let sessionIngestedCards = [];
// -----------------------------------------------------------------------
// Initializer
// -----------------------------------------------------------------------
export function initGrammar() {
const backBtn = document.getElementById('grammar-back-btn');
const tabTheoryBtn = document.getElementById('grammar-tab-theory-btn');
const tabPracticeBtn = document.getElementById('grammar-tab-practice-btn');
const toPracticeBtn = document.getElementById('grammar-to-practice-btn');
const exNextBtn = document.getElementById('grammar-ex-next-btn');
const sumRepeatBtn = document.getElementById('grammar-sum-repeat-btn');
const sumUnitsBtn = document.getElementById('grammar-sum-units-btn');
const sumSrsBtn = document.getElementById('grammar-sum-srs-btn');
const handwrittenToggle = document.getElementById('grammar-handwritten-toggle');
const cheatsheetToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
const cheatsheetDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
if (cheatsheetToggle && cheatsheetDrawer) {
cheatsheetToggle.addEventListener('click', () => {
const isNowHidden = !cheatsheetDrawer.classList.contains('hidden');
cheatsheetDrawer.classList.toggle('hidden', isNowHidden);
cheatsheetToggle.innerHTML = isNowHidden
? '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>'
: '<span>📋 Скрыть шпаргалку</span><i class="ph-caret-up"></i>';
});
}
if (backBtn) {
backBtn.addEventListener('click', () => {
showGrammarScreen('directory');
window.location.hash = '#grammar';
loadGrammarUnits();
});
}
if (tabTheoryBtn && tabPracticeBtn) {
tabTheoryBtn.addEventListener('click', () => switchLessonTab('theory'));
tabPracticeBtn.addEventListener('click', () => switchLessonTab('practice'));
}
if (toPracticeBtn) {
toPracticeBtn.addEventListener('click', () => switchLessonTab('practice'));
}
if (exNextBtn) {
exNextBtn.addEventListener('click', () => advanceExercise());
}
if (sumRepeatBtn) {
sumRepeatBtn.addEventListener('click', () => {
if (currentUnit) {
startUnitPractice();
}
});
}
if (sumUnitsBtn) {
sumUnitsBtn.addEventListener('click', () => {
showGrammarScreen('directory');
window.location.hash = '#grammar';
loadGrammarUnits();
});
}
if (sumSrsBtn) {
sumSrsBtn.addEventListener('click', () => {
window.location.hash = '#srs/grammar';
});
}
if (handwrittenToggle) {
handwrittenToggle.addEventListener('change', async (e) => {
if (!currentUnit) return;
const isChecked = e.target.checked;
try {
await api.grammar.toggleHandwritten(currentUnit.id, isChecked);
currentUnit.handwritten_logged = isChecked;
if (isChecked) {
showToast('✍️ Отметка сохранена: записано от руки в тетрадь!', 'success');
} else {
showToast('Отметка тетради снята', 'info');
}
} catch (err) {
console.error(err);
showToast('Ошибка сохранения статуса', 'error');
e.target.checked = !isChecked;
}
});
}
// Filter pills in directory
const filterPills = document.querySelectorAll('.grammar-filter-pill');
filterPills.forEach(pill => {
pill.addEventListener('click', () => {
filterPills.forEach(p => p.classList.remove('active'));
pill.classList.add('active');
currentFilter = pill.dataset.filter || 'all';
renderUnitsGrid();
});
});
// Keyboard shortcuts (1-4 for exercise choices, Enter/Space for next)
window.addEventListener('keydown', (e) => {
const grammarView = document.getElementById('grammar-view');
const lessonScreen = document.getElementById('grammar-lesson-screen');
const practiceTab = document.getElementById('grammar-tab-practice-content');
if (!grammarView || grammarView.classList.contains('hidden')) return;
if (!lessonScreen || lessonScreen.classList.contains('hidden')) return;
if (!practiceTab || practiceTab.classList.contains('hidden')) return;
if (['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) return;
if (!isExerciseAnswered) {
if (['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
const idx = parseInt(e.key, 10) - 1;
const btns = document.querySelectorAll('.grammar-opt-btn');
if (btns[idx]) btns[idx].click();
}
} else {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
advanceExercise();
}
}
});
}
// -----------------------------------------------------------------------
// Screen Switcher inside #grammar-view
// -----------------------------------------------------------------------
export function showGrammarScreen(screenName) {
const dir = document.getElementById('grammar-directory-screen');
const lesson = document.getElementById('grammar-lesson-screen');
if (dir) dir.classList.toggle('hidden', screenName !== 'directory');
if (lesson) lesson.classList.toggle('hidden', screenName !== 'lesson');
}
// -----------------------------------------------------------------------
// Units Directory Loader
// -----------------------------------------------------------------------
export async function loadGrammarUnits() {
showGrammarScreen('directory');
const grid = document.getElementById('grammar-units-grid');
if (grid) {
grid.innerHTML = '<div class="grammar-loading"><div class="spinner"></div><p>Загрузка каталога грамматики Мерфи...</p></div>';
}
try {
const res = await api.grammar.getUnits();
allUnits = res.units || [];
updateDirectoryStats();
renderUnitsGrid();
} catch (err) {
console.error('Error loading grammar units:', err);
if (grid) {
grid.innerHTML = `<div class="empty-state"><p>Ошибка загрузки юнитов: ${escapeHtml(err.message)}</p></div>`;
}
}
}
function updateDirectoryStats() {
const totalCount = allUnits.length;
const completedCount = allUnits.filter(u => u.is_completed).length;
const handwrittenCount = allUnits.filter(u => u.handwritten_logged).length;
const totalEl = document.getElementById('grammar-stat-total');
const completedEl = document.getElementById('grammar-stat-completed');
const handwrittenEl = document.getElementById('grammar-stat-handwritten');
if (totalEl) totalEl.textContent = totalCount;
if (completedEl) completedEl.textContent = completedCount;
if (handwrittenEl) handwrittenEl.textContent = handwrittenCount;
}
function renderUnitsGrid() {
const grid = document.getElementById('grammar-units-grid');
if (!grid) return;
let filtered = allUnits;
if (currentFilter === 'A1-A2') {
filtered = allUnits.filter(u => u.level === 'A1-A2');
} else if (currentFilter === 'B1-B2') {
filtered = allUnits.filter(u => u.level === 'B1-B2');
} else if (currentFilter === 'completed') {
filtered = allUnits.filter(u => u.is_completed);
} else if (currentFilter === 'handwritten') {
filtered = allUnits.filter(u => u.handwritten_logged);
} else if (currentFilter !== 'all') {
filtered = allUnits.filter(u => u.category.toLowerCase().includes(currentFilter.toLowerCase()));
}
if (filtered.length === 0) {
grid.innerHTML = '<div class="empty-state"><p>Нет юнитов в этой категории</p></div>';
return;
}
grid.innerHTML = filtered.map(unit => {
const levelBadgeClass = unit.level === 'A1-A2' ? 'badge-a1' : 'badge-b1';
const isComp = unit.is_completed;
const isHw = unit.handwritten_logged;
return `
<div class="grammar-unit-card ${isComp ? 'is-completed' : ''}" data-unit-id="${unit.id}">
<div class="grammar-card-header">
<span class="grammar-unit-number">Юнит ${unit.unit_num}</span>
<span class="grammar-level-pill ${levelBadgeClass}">${escapeHtml(unit.level)}</span>
</div>
<h3 class="grammar-card-title">${escapeHtml(unit.title)}</h3>
<p class="grammar-card-sub">${escapeHtml(unit.subtitle)}</p>
<div class="grammar-card-meta">
<span class="grammar-card-cat"><i class="ph-folder-simple"></i> ${escapeHtml(unit.category)}</span>
</div>
<div class="grammar-card-footer">
<div class="grammar-badges">
${isComp ? `<span class="badge-status-completed"><i class="ph-check-circle-fill"></i> ${unit.score || 100}%</span>` : '<span class="badge-status-new">Новый</span>'}
${isHw ? '<span class="badge-status-handwritten" title="Записано от руки в тетрадь"><i class="ph-pen-nib-fill"></i> В тетради</span>' : ''}
</div>
<button class="btn btn-sm btn-primary grammar-open-btn" data-unit-id="${unit.id}">
${isComp ? 'Повторить' : 'Начать'} <i class="ph-arrow-right"></i>
</button>
</div>
</div>
`;
}).join('');
// Attach click handlers to cards
grid.querySelectorAll('.grammar-unit-card').forEach(card => {
card.addEventListener('click', (e) => {
const unitId = parseInt(card.dataset.unitId, 10);
window.location.hash = `#grammar/unit/${unitId}`;
});
});
}
// -----------------------------------------------------------------------
// Unit Lesson Loader
// -----------------------------------------------------------------------
export async function openGrammarUnit(unitId) {
showGrammarScreen('lesson');
const lessonScreen = document.getElementById('grammar-lesson-screen');
if (!lessonScreen) return;
const titleEl = document.getElementById('grammar-lesson-title');
if (titleEl) titleEl.textContent = 'Загрузка юнита...';
try {
currentUnit = await api.grammar.getUnit(unitId);
renderUnitTheory();
switchLessonTab('theory');
} catch (err) {
console.error('Error loading grammar unit:', err);
showToast('Не удалось загрузить урок: ' + err.message, 'error');
window.location.hash = '#grammar';
}
}
function switchLessonTab(tab) {
const tabTheoryBtn = document.getElementById('grammar-tab-theory-btn');
const tabPracticeBtn = document.getElementById('grammar-tab-practice-btn');
const tabTheoryContent = document.getElementById('grammar-tab-theory-content');
const tabPracticeContent = document.getElementById('grammar-tab-practice-content');
const summaryCard = document.getElementById('grammar-summary-card');
if (tab === 'theory') {
if (tabTheoryBtn) tabTheoryBtn.classList.add('active');
if (tabPracticeBtn) tabPracticeBtn.classList.remove('active');
if (tabTheoryContent) tabTheoryContent.classList.remove('hidden');
if (tabPracticeContent) tabPracticeContent.classList.add('hidden');
if (summaryCard) summaryCard.classList.add('hidden');
} else if (tab === 'practice') {
if (tabTheoryBtn) tabTheoryBtn.classList.remove('active');
if (tabPracticeBtn) tabPracticeBtn.classList.add('active');
if (tabTheoryContent) tabTheoryContent.classList.add('hidden');
if (summaryCard) summaryCard.classList.add('hidden');
if (tabPracticeContent) tabPracticeContent.classList.remove('hidden');
startUnitPractice();
} else if (tab === 'summary') {
if (tabTheoryBtn) tabTheoryBtn.classList.remove('active');
if (tabPracticeBtn) tabPracticeBtn.classList.remove('active');
if (tabTheoryContent) tabTheoryContent.classList.add('hidden');
if (tabPracticeContent) tabPracticeContent.classList.add('hidden');
if (summaryCard) summaryCard.classList.remove('hidden');
}
}
function renderUnitTheory() {
if (!currentUnit) return;
// Header info
const numEl = document.getElementById('grammar-lesson-num');
const levelEl = document.getElementById('grammar-lesson-level');
const titleEl = document.getElementById('grammar-lesson-title');
const subEl = document.getElementById('grammar-lesson-sub');
const catEl = document.getElementById('grammar-lesson-cat');
const handwrittenToggle = document.getElementById('grammar-handwritten-toggle');
if (numEl) numEl.textContent = `Юнит ${currentUnit.unit_num}`;
if (levelEl) {
levelEl.textContent = currentUnit.level;
levelEl.className = `grammar-level-pill ${currentUnit.level === 'A1-A2' ? 'badge-a1' : 'badge-b1'}`;
}
if (titleEl) titleEl.textContent = currentUnit.title;
if (subEl) subEl.textContent = currentUnit.subtitle || '';
if (catEl) catEl.textContent = currentUnit.category;
if (handwrittenToggle) {
handwrittenToggle.checked = Boolean(currentUnit.handwritten_logged);
}
// Cheatsheet Markdown Table
const cheatsheetSec = document.getElementById('grammar-cheatsheet-section');
const cheatsheetBox = document.getElementById('grammar-cheatsheet-content');
if (cheatsheetSec && cheatsheetBox) {
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
cheatsheetBox.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
cheatsheetSec.classList.remove('hidden');
} else {
cheatsheetBox.innerHTML = '';
cheatsheetSec.classList.add('hidden');
}
}
// Detailed Murphy Notes
const notesSec = document.getElementById('grammar-detailed-notes-section');
const notesBox = document.getElementById('grammar-detailed-notes-content');
if (notesSec && notesBox) {
if (currentUnit.detailed_notes && currentUnit.detailed_notes.trim()) {
notesBox.innerHTML = renderMarkdownNotes(currentUnit.detailed_notes);
notesSec.classList.remove('hidden');
} else {
notesBox.innerHTML = '';
notesSec.classList.add('hidden');
}
}
// Practice Cheatsheet Drawer content
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
const practiceToggleWrap = document.querySelector('.practice-cheatsheet-toggle-wrap');
if (practiceDrawer && practiceToggleWrap) {
if (currentUnit.cheatsheet_table && currentUnit.cheatsheet_table.trim()) {
practiceDrawer.innerHTML = renderMarkdownTable(currentUnit.cheatsheet_table);
practiceToggleWrap.classList.remove('hidden');
} else {
practiceDrawer.innerHTML = '';
practiceToggleWrap.classList.add('hidden');
}
practiceDrawer.classList.add('hidden');
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
if (practiceToggle) {
practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
}
}
// Golden formula
const formulaEl = document.getElementById('grammar-golden-formula-text');
if (formulaEl) {
formulaEl.textContent = currentUnit.golden_formula;
}
// Journaling prompt
const promptEl = document.getElementById('grammar-journaling-prompt-text');
if (promptEl) {
promptEl.textContent = currentUnit.journaling_prompt;
}
// Contrasts
const contrastsContainer = document.getElementById('grammar-contrasts-list');
if (contrastsContainer) {
const contrasts = currentUnit.contrasts || [];
if (contrasts.length === 0) {
contrastsContainer.innerHTML = '';
} else {
contrastsContainer.innerHTML = contrasts.map(c => `
<div class="grammar-contrast-card">
<div class="contrast-col contrast-a">
<div class="contrast-header"><span class="contrast-pill a">Ситуация А</span> ${escapeHtml(c.situation_a)}</div>
<div class="contrast-example">${escapeHtml(c.example_a)}</div>
<div class="contrast-trans">${escapeHtml(c.trans_a)}</div>
</div>
<div class="contrast-divider">
<span class="vs-badge">VS</span>
</div>
<div class="contrast-col contrast-b">
<div class="contrast-header"><span class="contrast-pill b">Ситуация Б</span> ${escapeHtml(c.situation_b)}</div>
<div class="contrast-example">${escapeHtml(c.example_b)}</div>
<div class="contrast-trans">${escapeHtml(c.trans_b)}</div>
</div>
<div class="contrast-key-diff">
<strong>💡 Ключевая разница:</strong> ${escapeHtml(c.key_difference)}
</div>
</div>
`).join('');
}
}
}
// -----------------------------------------------------------------------
// Cloze Tap Practice Engine (5 micro-exercises)
// -----------------------------------------------------------------------
function startUnitPractice() {
if (!currentUnit) return;
currentExerciseIdx = 0;
isExerciseAnswered = false;
sessionMistakes = 0;
sessionCorrect = 0;
sessionIngestedCards = [];
// Ensure cheatsheet drawer is collapsed at practice start
const practiceDrawer = document.getElementById('grammar-practice-cheatsheet-drawer');
const practiceToggle = document.getElementById('grammar-practice-cheatsheet-toggle');
if (practiceDrawer) practiceDrawer.classList.add('hidden');
if (practiceToggle) practiceToggle.innerHTML = '<span>📋 Шпаргалка по теме</span><i class="ph-caret-down"></i>';
renderExercise();
}
function renderExercise() {
if (!currentUnit || !currentUnit.exercises) return;
const exercises = currentUnit.exercises;
if (currentExerciseIdx >= exercises.length) {
finishUnitPractice();
return;
}
const ex = exercises[currentExerciseIdx];
isExerciseAnswered = false;
// Progress bar & dots
const progressText = document.getElementById('grammar-practice-progress');
const dotsContainer = document.getElementById('grammar-practice-dots');
if (progressText) {
progressText.textContent = `Задание ${currentExerciseIdx + 1} из ${exercises.length}`;
}
if (dotsContainer) {
dotsContainer.innerHTML = exercises.map((_, i) => {
let dotClass = 'dot';
if (i < currentExerciseIdx) dotClass += ' dot-done';
if (i === currentExerciseIdx) dotClass += ' dot-active';
return `<span class="${dotClass}"></span>`;
}).join('');
}
// Sentence with cloze blank
const sentenceEl = document.getElementById('grammar-cloze-sentence');
const clueEl = document.getElementById('grammar-cloze-clue');
const feedbackBox = document.getElementById('grammar-exercise-feedback');
const nextBtn = document.getElementById('grammar-ex-next-btn');
if (feedbackBox) feedbackBox.classList.add('hidden');
if (nextBtn) nextBtn.classList.add('hidden');
if (sentenceEl) {
const parts = ex.sentence.split('___');
sentenceEl.innerHTML = `${escapeHtml(parts[0] || '')}<span class="cloze-blank" id="current-cloze-blank">______</span>${escapeHtml(parts[1] || '')}`;
}
if (clueEl) {
clueEl.textContent = ex.clue ? `💡 Подсказка: ${ex.clue}` : '';
}
// Tap options
const optionsContainer = document.getElementById('grammar-options-container');
if (optionsContainer) {
optionsContainer.innerHTML = ex.options.map((opt, i) => `
<button class="btn btn-outline grammar-opt-btn" data-option="${escapeHtml(opt)}">
<span class="opt-num">${i + 1}</span>
<span class="opt-label">${escapeHtml(opt)}</span>
</button>
`).join('');
optionsContainer.querySelectorAll('.grammar-opt-btn').forEach(btn => {
btn.addEventListener('click', () => {
if (isExerciseAnswered) return;
handleOptionSelect(btn.dataset.option);
});
});
}
}
async function handleOptionSelect(selectedOption) {
if (isExerciseAnswered || !currentUnit) return;
isExerciseAnswered = true;
const blank = document.getElementById('current-cloze-blank');
if (blank) blank.textContent = selectedOption;
const allBtns = document.querySelectorAll('.grammar-opt-btn');
allBtns.forEach(b => b.disabled = true);
try {
const result = await api.grammar.submitAnswer(currentUnit.id, currentExerciseIdx, selectedOption);
const feedbackBox = document.getElementById('grammar-exercise-feedback');
const nextBtn = document.getElementById('grammar-ex-next-btn');
allBtns.forEach(btn => {
if (btn.dataset.option === result.correct_option) {
btn.classList.add('btn-correct');
} else if (btn.dataset.option === selectedOption && !result.correct) {
btn.classList.add('btn-wrong');
}
});
if (blank) {
blank.textContent = result.correct_option;
blank.classList.add(result.correct ? 'blank-correct' : 'blank-wrong');
}
if (result.correct) {
sessionCorrect++;
if (feedbackBox) {
feedbackBox.className = 'grammar-feedback-box feedback-correct';
feedbackBox.innerHTML = `
<div class="feedback-title"><i class="ph-check-circle-fill"></i> Верно!</div>
<div class="feedback-text">${escapeHtml(result.explanation)}</div>
`;
feedbackBox.classList.remove('hidden');
}
} else {
sessionMistakes++;
if (result.card_created || result.card_updated) {
sessionIngestedCards.push(result.card_id);
}
if (feedbackBox) {
feedbackBox.className = 'grammar-feedback-box feedback-wrong';
feedbackBox.innerHTML = `
<div class="feedback-title"><i class="ph-x-circle-fill"></i> Неверно (правильно: <b>${escapeHtml(result.correct_option)}</b>)</div>
<div class="feedback-text">${escapeHtml(result.explanation)}</div>
<div class="feedback-srs-ingested">
<i class="ph-lightning-fill"></i> Грамматический паттерн сохранён в ваши карточки для утреннего интервального повторения (SRS).
</div>
`;
feedbackBox.classList.remove('hidden');
}
}
if (nextBtn) {
nextBtn.classList.remove('hidden');
nextBtn.focus();
}
} catch (err) {
console.error('Error checking grammar answer:', err);
showToast('Ошибка проверки ответа', 'error');
allBtns.forEach(b => b.disabled = false);
isExerciseAnswered = false;
}
}
function advanceExercise() {
currentExerciseIdx++;
renderExercise();
}
async function finishUnitPractice() {
if (!currentUnit) return;
const total = currentUnit.exercises.length;
const score = Math.round((sessionCorrect / total) * 100);
// Save completion
try {
await api.grammar.completeUnit(currentUnit.id, score, Boolean(currentUnit.handwritten_logged));
currentUnit.is_completed = true;
currentUnit.score = Math.max(currentUnit.score || 0, score);
} catch (err) {
console.error('Error saving unit completion:', err);
}
// Switch to summary tab
switchLessonTab('summary');
const scoreValEl = document.getElementById('grammar-sum-score-val');
const scoreTextEl = document.getElementById('grammar-sum-score-text');
const ingestedEl = document.getElementById('grammar-sum-ingested-count');
const srsBtn = document.getElementById('grammar-sum-srs-btn');
const hwCard = document.getElementById('grammar-sum-hw-reminder');
if (scoreValEl) scoreValEl.textContent = `${score}%`;
if (scoreTextEl) {
if (score === 100) {
scoreTextEl.textContent = '🎉 Идеально! Все 5 конструкций освоены без ошибок.';
} else if (score >= 80) {
scoreTextEl.textContent = '👍 Отличный результат! Конструкция хорошо понята.';
} else {
scoreTextEl.textContent = '💪 Хорошая практика! Ошибки зафиксированы для повторения.';
}
}
if (ingestedEl) {
ingestedEl.textContent = sessionIngestedCards.length;
}
if (srsBtn) {
srsBtn.classList.toggle('hidden', sessionIngestedCards.length === 0);
}
if (hwCard) {
if (!currentUnit.handwritten_logged) {
hwCard.classList.remove('hidden');
} else {
hwCard.classList.add('hidden');
}
}
}