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

733 lines
30 KiB
JavaScript

// =======================================================================
// Notes Application - Dictionary Module (Native ES Module)
// =======================================================================
import { api } from './api.js';
import { escapeHtml, showToast, openModal, closeModal } from './ui.js';
export let allCards = [];
export let collectionsList = ['General', 'Разговорный', 'Работа & IT', 'Книги & Сериалы', 'Идиомы'];
export let selectedTag = '';
export let selectedCollection = '';
export let searchQuery = '';
let currentLookupData = null;
let foundDuplicateCard = null;
let duplicateCheckTimer = null;
let isBatchMode = false;
let selectedIds = new Set();
export function getAllCards() {
return allCards;
}
export function initDictionary() {
// Elements - Search & Capture
const termInput = document.getElementById('term-input');
const clearSearchBtn = document.getElementById('clear-search-btn');
const translateBtn = document.getElementById('translate-btn');
const duplicateWarningBox = document.getElementById('duplicate-warning-box');
const dupTermLabel = document.getElementById('dup-term-label');
const dupTransLabel = document.getElementById('dup-trans-label');
const dupOpenBtn = document.getElementById('dup-open-btn');
const lookupCard = document.getElementById('lookup-card');
const resTerm = document.getElementById('res-term');
const resPhonetic = document.getElementById('res-phonetic');
const resInLibrary = document.getElementById('res-in-library');
const audioPlayBtn = document.getElementById('audio-play-btn');
const resPos = document.getElementById('res-pos');
const resDefinitionWrap = document.getElementById('res-definition-wrap');
const resDefinition = document.getElementById('res-definition');
const editTranslation = document.getElementById('edit-translation');
const editContext = document.getElementById('edit-context');
const editCollection = document.getElementById('edit-collection');
const collectionsDatalist = document.getElementById('collections-datalist');
const editTags = document.getElementById('edit-tags');
const editNotes = document.getElementById('edit-notes');
const saveCardBtn = document.getElementById('save-card-btn');
// Elements - Vocabulary List
const filterInput = document.getElementById('filter-input');
const cardsCountLabel = document.getElementById('cards-count-label');
const collectionsFilterBar = document.getElementById('collections-filter-bar');
const newCollectionBtn = document.getElementById('new-collection-btn');
const tagsFilterBar = document.getElementById('tags-filter-bar');
const cardsList = document.getElementById('cards-list');
const emptyState = document.getElementById('empty-state');
// Elements - Batch Action Bar
const batchToggleBtn = document.getElementById('batch-toggle-btn');
const batchActionBar = document.getElementById('batch-action-bar');
const batchSelectAll = document.getElementById('batch-select-all');
const batchCountLabel = document.getElementById('batch-count-label');
const batchMoveBtn = document.getElementById('batch-move-btn');
const batchTagBtn = document.getElementById('batch-tag-btn');
const batchDeleteBtn = document.getElementById('batch-delete-btn');
const batchCancelBtn = document.getElementById('batch-cancel-btn');
// Elements - Modals
const editModal = document.getElementById('edit-card-modal');
const closeModalBtn = document.getElementById('close-modal-btn');
const modalCancelBtn = document.getElementById('modal-cancel-btn');
const modalSaveBtn = document.getElementById('modal-save-btn');
const modalCardId = document.getElementById('modal-card-id');
const modalTerm = document.getElementById('modal-term');
const modalTranslation = document.getElementById('modal-translation');
const modalContext = document.getElementById('modal-context');
const modalPhonetic = document.getElementById('modal-phonetic');
const modalPos = document.getElementById('modal-pos');
const modalDef = document.getElementById('modal-def');
const modalCollection = document.getElementById('modal-collection');
const modalTags = document.getElementById('modal-tags');
const modalNotes = document.getElementById('modal-notes');
const newColModal = document.getElementById('new-col-modal');
const newColNameInput = document.getElementById('new-col-name-input');
const closeNewColModalBtn = document.getElementById('close-new-col-modal-btn');
const newColCancelBtn = document.getElementById('new-col-cancel-btn');
const newColSaveBtn = document.getElementById('new-col-save-btn');
const moveColModal = document.getElementById('move-col-modal');
const moveColInput = document.getElementById('move-col-input');
const moveColQuickList = document.getElementById('move-col-quick-list');
const closeMoveModalBtn = document.getElementById('close-move-modal-btn');
const moveColCancelBtn = document.getElementById('move-col-cancel-btn');
const moveColConfirmBtn = document.getElementById('move-col-confirm-btn');
const exportOpenBtn = document.getElementById('export-open-btn');
const exportModal = document.getElementById('export-modal');
const closeExportModalBtn = document.getElementById('close-export-modal-btn');
const exportCancelBtn = document.getElementById('export-cancel-btn');
// -----------------------------------------------------------------------
// Duplicate Detection & Search Term
// -----------------------------------------------------------------------
termInput.addEventListener('input', () => {
const val = termInput.value.trim();
clearSearchBtn.classList.toggle('hidden', val.length === 0);
clearTimeout(duplicateCheckTimer);
if (val.length >= 2) {
duplicateCheckTimer = setTimeout(async () => {
try {
const resp = await fetch(`/api/cards/check-duplicate?term=${encodeURIComponent(val)}`);
if (resp.ok) {
const res = await resp.json();
if (res.exists && res.card) {
foundDuplicateCard = res.card;
dupTermLabel.textContent = res.card.term;
dupTransLabel.textContent = res.card.translation;
duplicateWarningBox.classList.remove('hidden');
} else {
foundDuplicateCard = null;
duplicateWarningBox.classList.add('hidden');
}
}
} catch (e) {}
}, 300);
} else {
foundDuplicateCard = null;
duplicateWarningBox.classList.add('hidden');
}
});
dupOpenBtn.addEventListener('click', () => {
if (foundDuplicateCard) {
openEditModal(foundDuplicateCard);
}
});
clearSearchBtn.addEventListener('click', () => {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
duplicateWarningBox.classList.add('hidden');
lookupCard.classList.add('hidden');
termInput.focus();
});
// -----------------------------------------------------------------------
// Dictionary Lookup & Capture
// -----------------------------------------------------------------------
translateBtn.addEventListener('click', performLookup);
termInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
performLookup();
} else if (e.key === 'Escape') {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
duplicateWarningBox.classList.add('hidden');
lookupCard.classList.add('hidden');
}
});
async function performLookup() {
const term = termInput.value.trim();
if (!term) return;
translateBtn.disabled = true;
translateBtn.textContent = 'Ищем...';
try {
const data = await api.cards.lookup(term);
currentLookupData = data;
displayLookupResult(data);
} catch (e) {
console.error('Lookup error:', e);
showToast('Не удалось получить перевод', 'error');
} finally {
translateBtn.disabled = false;
translateBtn.textContent = 'Перевести';
}
}
function displayLookupResult(data) {
resTerm.textContent = data.term;
resPhonetic.textContent = data.phonetic || '';
resPhonetic.style.display = data.phonetic ? 'inline-block' : 'none';
resPos.textContent = data.part_of_speech || 'Phrase';
audioPlayBtn.classList.add('hidden');
if (data.definition_en) {
resDefinition.textContent = data.definition_en;
resDefinitionWrap.classList.remove('hidden');
} else {
resDefinitionWrap.classList.add('hidden');
}
const inLib = Boolean(data.in_library || foundDuplicateCard);
if (resInLibrary) {
resInLibrary.classList.toggle('hidden', !inLib);
}
if (inLib) {
saveCardBtn.innerHTML = '⭐ Обновить в словаре <span class="btn-subtext">(Ctrl + Enter)</span>';
const lib = data.library_card || foundDuplicateCard;
if (lib) {
if (lib.translation) editTranslation.value = lib.translation;
if (lib.context_sentence) editContext.value = lib.context_sentence;
if (lib.collection) editCollection.value = lib.collection;
if (lib.tags) editTags.value = lib.tags;
if (lib.notes) editNotes.value = lib.notes;
} else {
editTranslation.value = data.translation || '';
}
} else {
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
editTranslation.value = data.translation || '';
editContext.value = '';
editNotes.value = '';
editCollection.value = selectedCollection || 'General';
}
lookupCard.classList.remove('hidden');
editTranslation.focus();
}
// Save Card Action
saveCardBtn.addEventListener('click', saveNewCard);
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
if (!lookupCard.classList.contains('hidden')) {
e.preventDefault();
saveNewCard();
}
}
});
async function saveNewCard() {
if (!currentLookupData) return;
const term = currentLookupData.term.trim();
const translation = editTranslation.value.trim();
if (!translation) {
alert('Пожалуйста, укажите русский перевод');
editTranslation.focus();
return;
}
const payload = {
term: term,
translation: translation,
context_sentence: editContext.value.trim(),
phonetic: currentLookupData.phonetic || '',
audio_url: currentLookupData.audio_url || '',
part_of_speech: currentLookupData.part_of_speech || '',
definition_en: currentLookupData.definition_en || '',
tags: editTags.value.trim() || 'general',
notes: editNotes.value.trim(),
collection: editCollection.value.trim() || 'General',
};
saveCardBtn.disabled = true;
saveCardBtn.textContent = 'Сохраняем...';
try {
const resp = await api.cards.create(payload);
if (resp.ok) {
termInput.value = '';
clearSearchBtn.classList.add('hidden');
duplicateWarningBox.classList.add('hidden');
lookupCard.classList.add('hidden');
currentLookupData = null;
showToast(`Слово "${term}" сохранено в словарь!`, 'success');
await loadInitialData();
termInput.focus();
} else {
const err = await resp.json();
alert('Ошибка сохранения: ' + (err.detail || 'Не удалось сохранить'));
}
} catch (e) {
alert('Сетевая ошибка при сохранении');
} finally {
saveCardBtn.disabled = false;
saveCardBtn.innerHTML = '💾 Сохранить в словарь <span class="btn-subtext">(Ctrl + Enter)</span>';
}
}
// -----------------------------------------------------------------------
// Cards List & Filtering
// -----------------------------------------------------------------------
filterInput.addEventListener('input', () => {
searchQuery = filterInput.value.trim();
fetchCards();
});
function renderCollectionsFilter(cols) {
collectionsFilterBar.innerHTML = '';
const allBtn = document.createElement('button');
allBtn.className = `col-chip ${selectedCollection === '' ? 'active' : ''}`;
allBtn.textContent = 'Все коллекции';
allBtn.onclick = () => {
selectedCollection = '';
updateFilterChips();
fetchCards();
};
collectionsFilterBar.appendChild(allBtn);
cols.forEach(c => {
const chip = document.createElement('button');
chip.className = `col-chip ${selectedCollection === c.name ? 'active' : ''}`;
chip.textContent = `${c.name} (${c.count})`;
chip.onclick = () => {
selectedCollection = c.name;
updateFilterChips();
fetchCards();
};
collectionsFilterBar.appendChild(chip);
});
}
function renderTagsFilter(tags) {
tagsFilterBar.innerHTML = '';
const allBtn = document.createElement('button');
allBtn.className = `filter-chip ${selectedTag === '' ? 'active' : ''}`;
allBtn.textContent = 'Все теги';
allBtn.onclick = () => {
selectedTag = '';
updateFilterChips();
fetchCards();
};
tagsFilterBar.appendChild(allBtn);
tags.slice(0, 15).forEach(t => {
const chip = document.createElement('button');
chip.className = `filter-chip ${selectedTag === t.tag ? 'active' : ''}`;
chip.textContent = `#${t.tag} (${t.count})`;
chip.onclick = () => {
selectedTag = selectedTag === t.tag ? '' : t.tag;
updateFilterChips();
fetchCards();
};
tagsFilterBar.appendChild(chip);
});
}
function updateFilterChips() {
document.querySelectorAll('.col-chip').forEach(el => {
el.classList.toggle('active', el.textContent.startsWith(selectedCollection || 'Все коллекции'));
});
document.querySelectorAll('.filter-chip').forEach(el => {
el.classList.toggle('active', el.textContent.startsWith(`#${selectedTag}`) || (!selectedTag && el.textContent.startsWith('Все теги')));
});
}
function renderCardsList() {
cardsCountLabel.textContent = `(${allCards.length} записей)`;
cardsList.innerHTML = '';
if (allCards.length === 0) {
emptyState.classList.remove('hidden');
cardsList.appendChild(emptyState);
return;
}
emptyState.classList.add('hidden');
allCards.forEach(c => {
const cardEl = document.createElement('div');
cardEl.className = `vocab-card ${isBatchMode && selectedIds.has(c.id) ? 'is-selected' : ''}`;
const srsStateClass = c.srs_state === 'mastered' ? 'dot-mastered' : (c.srs_state === 'review' ? 'dot-review' : (c.srs_state === 'learning' ? 'dot-learning' : 'dot-new'));
cardEl.innerHTML = `
<div class="card-top">
<div class="card-term-group">
${isBatchMode ? `<input type="checkbox" class="card-select-checkbox batch-card-check" data-id="${c.id}" ${selectedIds.has(c.id) ? 'checked' : ''}>` : ''}
<span class="dot ${srsStateClass}" title="SRS статус: ${c.srs_state || 'new'}"></span>
<span class="card-term">${escapeHtml(c.term)}</span>
${c.phonetic ? `<span class="card-phonetic">${escapeHtml(c.phonetic)}</span>` : ''}
${c.part_of_speech ? `<span class="card-pos">${escapeHtml(c.part_of_speech)}</span>` : ''}
</div>
<div class="card-actions">
<button class="icon-btn edit-card-btn" data-id="${c.id}" title="Редактировать">✏️</button>
<button class="icon-btn delete-card-btn" data-id="${c.id}" title="Удалить">🗑️</button>
</div>
</div>
<div class="card-translation">${escapeHtml(c.translation)}</div>
${c.context_sentence ? `<div class="card-context">“${escapeHtml(c.context_sentence)}”</div>` : ''}
<div class="card-bottom">
<div class="card-tags">
<span class="card-tag">📁 ${escapeHtml(c.collection || 'General')}</span>
${c.tags ? c.tags.split(',').map(t => `<span class="card-tag">#${escapeHtml(t.trim())}</span>`).join(' ') : ''}
</div>
${c.notes ? `<div class="card-notes">💡 ${escapeHtml(c.notes)}</div>` : ''}
</div>
`;
// Edit button
cardEl.querySelector('.edit-card-btn').addEventListener('click', (e) => {
e.stopPropagation();
openEditModal(c);
});
// Delete button
cardEl.querySelector('.delete-card-btn').addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Удалить слово "${c.term}" из словаря?`)) {
await api.cards.delete(c.id);
showToast(`Слово "${c.term}" удалено`, 'info');
await loadInitialData();
}
});
// Batch selection click
if (isBatchMode) {
cardEl.addEventListener('click', () => {
toggleBatchSelection(c.id);
});
}
cardsList.appendChild(cardEl);
});
}
// -----------------------------------------------------------------------
// Batch Operations
// -----------------------------------------------------------------------
batchToggleBtn.addEventListener('click', () => {
isBatchMode = !isBatchMode;
batchToggleBtn.textContent = isBatchMode ? '✓ Завершить выбор' : '☑️ Выбрать';
batchToggleBtn.classList.toggle('btn-primary', isBatchMode);
batchToggleBtn.classList.toggle('btn-outline', !isBatchMode);
batchActionBar.classList.toggle('hidden', !isBatchMode);
if (!isBatchMode) {
selectedIds.clear();
}
renderCardsList();
updateBatchCount();
});
batchCancelBtn.addEventListener('click', () => {
isBatchMode = false;
batchToggleBtn.textContent = '☑️ Выбрать';
batchToggleBtn.classList.remove('btn-primary');
batchToggleBtn.classList.add('btn-outline');
batchActionBar.classList.add('hidden');
selectedIds.clear();
renderCardsList();
});
function toggleBatchSelection(id) {
if (selectedIds.has(id)) selectedIds.delete(id);
else selectedIds.add(id);
updateBatchCount();
renderCardsList();
}
batchSelectAll.addEventListener('change', () => {
if (batchSelectAll.checked) {
allCards.forEach(c => selectedIds.add(c.id));
} else {
selectedIds.clear();
}
updateBatchCount();
renderCardsList();
});
function updateBatchCount() {
batchCountLabel.textContent = `Выбрано: ${selectedIds.size}`;
batchSelectAll.checked = selectedIds.size > 0 && selectedIds.size === allCards.length;
}
batchDeleteBtn.addEventListener('click', async () => {
if (selectedIds.size === 0) return;
if (confirm(`Удалить выбранные карточки (${selectedIds.size} шт.)?`)) {
await api.cards.batch({
card_ids: Array.from(selectedIds),
action: 'delete'
});
showToast(`Удалено карточек: ${selectedIds.size}`, 'info');
selectedIds.clear();
await loadInitialData();
}
});
batchMoveBtn.addEventListener('click', () => {
if (selectedIds.size === 0) {
alert('Сначала выберите хотя бы одну карточку');
return;
}
openModal(moveColModal);
});
batchTagBtn.addEventListener('click', async () => {
if (selectedIds.size === 0) return;
const tagVal = prompt('Введите тег(и) для добавления (через запятую):');
if (tagVal && tagVal.trim()) {
await api.cards.batch({
card_ids: Array.from(selectedIds),
action: 'add_tags',
value: tagVal.trim()
});
showToast(`Теги добавлены`, 'success');
await loadInitialData();
}
});
// -----------------------------------------------------------------------
// Edit Card Modal
// -----------------------------------------------------------------------
function openEditModal(card) {
modalCardId.value = card.id;
modalTerm.value = card.term;
modalTranslation.value = card.translation;
modalContext.value = card.context_sentence || '';
modalPhonetic.value = card.phonetic || '';
modalPos.value = card.part_of_speech || '';
modalDef.value = card.definition_en || '';
modalCollection.value = card.collection || 'General';
modalTags.value = card.tags || '';
modalNotes.value = card.notes || '';
openModal(editModal);
}
closeModalBtn.addEventListener('click', () => closeModal(editModal));
modalCancelBtn.addEventListener('click', () => closeModal(editModal));
modalSaveBtn.addEventListener('click', async () => {
const id = modalCardId.value;
const payload = {
term: modalTerm.value.trim(),
translation: modalTranslation.value.trim(),
context_sentence: modalContext.value.trim(),
phonetic: modalPhonetic.value.trim(),
part_of_speech: modalPos.value.trim(),
definition_en: modalDef.value.trim(),
collection: modalCollection.value.trim() || 'General',
tags: modalTags.value.trim(),
notes: modalNotes.value.trim(),
};
const resp = await api.cards.update(id, payload);
if (resp.ok) {
closeModal(editModal);
showToast('Карточка успешно обновлена', 'success');
await loadInitialData();
} else {
alert('Ошибка при сохранении карточки');
}
});
// -----------------------------------------------------------------------
// Export Modal
// -----------------------------------------------------------------------
if (exportOpenBtn) {
exportOpenBtn.addEventListener('click', () => openModal(exportModal));
}
closeExportModalBtn.addEventListener('click', () => closeModal(exportModal));
exportCancelBtn.addEventListener('click', () => closeModal(exportModal));
document.querySelectorAll('.export-tile').forEach(tile => {
tile.addEventListener('click', () => {
const format = tile.dataset.format;
let url = `/api/export/${format}`;
if (selectedCollection) url += `?collection=${encodeURIComponent(selectedCollection)}`;
window.location.href = url;
});
});
// -----------------------------------------------------------------------
// Collections Management
// -----------------------------------------------------------------------
newCollectionBtn.addEventListener('click', () => {
openModal(newColModal);
newColNameInput.value = '';
newColNameInput.focus();
});
closeNewColModalBtn.addEventListener('click', () => closeModal(newColModal));
newColCancelBtn.addEventListener('click', () => closeModal(newColModal));
newColSaveBtn.addEventListener('click', () => {
const name = newColNameInput.value.trim();
if (name) {
selectedCollection = name;
closeModal(newColModal);
fetchCards();
}
});
closeMoveModalBtn.addEventListener('click', () => closeModal(moveColModal));
moveColCancelBtn.addEventListener('click', () => closeModal(moveColModal));
moveColConfirmBtn.addEventListener('click', async () => {
const col = moveColInput.value.trim();
if (col && selectedIds.size > 0) {
await api.cards.batch({
card_ids: Array.from(selectedIds),
action: 'set_collection',
value: col,
});
closeModal(moveColModal);
selectedIds.clear();
showToast(`Перемещено в "${col}"`, 'success');
await loadInitialData();
}
});
function populateDatalists() {
collectionsDatalist.innerHTML = '';
const dl = document.getElementById('move-collections-datalist');
if (dl) dl.innerHTML = '';
collectionsList.forEach(c => {
const opt = document.createElement('option');
opt.value = c;
collectionsDatalist.appendChild(opt);
if (dl) dl.appendChild(opt.cloneNode(true));
});
if (moveColQuickList) {
moveColQuickList.innerHTML = '';
collectionsList.forEach(c => {
const btn = document.createElement('button');
btn.className = 'btn btn-xs btn-outline';
btn.textContent = c;
btn.onclick = () => { moveColInput.value = c; };
moveColQuickList.appendChild(btn);
});
}
}
// -----------------------------------------------------------------------
// Data Loading Functions
// -----------------------------------------------------------------------
async function fetchCards() {
try {
const params = { limit: 500 };
if (selectedCollection) params.collection = selectedCollection;
if (selectedTag) params.tag = selectedTag;
if (searchQuery) params.search = searchQuery;
const data = await api.cards.getAll(params);
allCards = data.cards || [];
renderCardsList();
} catch (e) {
console.error('Error fetching cards:', e);
}
}
async function fetchCollections() {
try {
const cols = await api.collections.getAll();
collectionsList = cols.map(c => c.name);
renderCollectionsFilter(cols);
populateDatalists();
} catch (e) {
console.error('Error fetching collections:', e);
}
}
async function fetchTags() {
try {
const tags = await api.tags.getAll();
renderTagsFilter(tags);
} catch (e) {
console.error('Error fetching tags:', e);
}
}
async function fetchStats() {
try {
const data = await api.stats.getStats();
if (data) {
const statTotal = document.getElementById('stat-total');
const statToday = document.getElementById('stat-today');
if (statTotal) statTotal.textContent = data.total_cards;
if (statToday) statToday.textContent = `+${data.today_added}`;
}
} catch (e) {}
}
async function fetchSrsStats() {
try {
const stats = await api.stats.getSrsStats();
if (stats) {
const due = stats.due_cards || 0;
const srsBadge = document.getElementById('srs-badge');
if (srsBadge) srsBadge.textContent = due;
const tabSrsBadge = document.getElementById('tab-srs-badge');
if (tabSrsBadge) {
tabSrsBadge.textContent = due;
tabSrsBadge.classList.toggle('hidden', due === 0);
}
}
} catch (e) {}
}
async function loadInitialData() {
await Promise.all([
fetchCards(),
fetchCollections(),
fetchTags(),
fetchStats(),
fetchSrsStats(),
]);
}
// Attach to module exports
initDictionary.fetchCards = fetchCards;
initDictionary.loadInitialData = loadInitialData;
initDictionary.openEditModal = openEditModal;
}
export async function loadInitialData() {
if (initDictionary.loadInitialData) {
return await initDictionary.loadInitialData();
}
}
export async function fetchCards() {
if (initDictionary.fetchCards) {
return await initDictionary.fetchCards();
}
}
export function openEditModal(card) {
if (initDictionary.openEditModal) {
initDictionary.openEditModal(card);
}
}