Grouping works
This commit is contained in:
+70
-1
@@ -9,6 +9,11 @@ export interface Word {
|
|||||||
group_id: number;
|
group_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WordGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CardSetRecord {
|
export interface CardSetRecord {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,6 +30,16 @@ export interface CardStatRecord {
|
|||||||
last_opened: number;
|
last_opened: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagGroup {
|
||||||
|
tag: string;
|
||||||
|
words: Word[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupedWords {
|
||||||
|
group: WordGroup;
|
||||||
|
tags: TagGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Парсит last_opened из SQLite.
|
* Парсит last_opened из SQLite.
|
||||||
* SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00",
|
* SQLite может вернуть ISO-строку "2026-05-22 10:52:06.823259+00:00",
|
||||||
@@ -129,10 +144,64 @@ export async function updateCardStat(
|
|||||||
score: number,
|
score: number,
|
||||||
lastOpened: number,
|
lastOpened: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Убеждаемся, что score — число >= 1
|
|
||||||
const safeScore = Math.max(1, Math.round(score || 1));
|
const safeScore = Math.max(1, Math.round(score || 1));
|
||||||
await executeVoid(
|
await executeVoid(
|
||||||
`UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`,
|
`UPDATE card_stats SET score = ?, last_opened = ? WHERE id = ?`,
|
||||||
[safeScore, lastOpened, statId],
|
[safeScore, lastOpened, statId],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает все группы (word_group).
|
||||||
|
*/
|
||||||
|
export async function getWordGroups(): Promise<WordGroup[]> {
|
||||||
|
const rows: any[] = await execute(`
|
||||||
|
SELECT id, name FROM word_group ORDER BY id ASC
|
||||||
|
`);
|
||||||
|
return rows.map((r: any[]) => ({ id: r[0], name: r[1] })) as WordGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группирует все слова по группам и тегам.
|
||||||
|
* Если у слова несколько тегов (через пробел), оно попадает в каждую теговую группу.
|
||||||
|
*/
|
||||||
|
export async function getGroupedWords(): Promise<GroupedWords[]> {
|
||||||
|
const groups = await getWordGroups();
|
||||||
|
const words = await getWordsForReview();
|
||||||
|
|
||||||
|
const result: GroupedWords[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const groupWords = words.filter((w) => w.group_id === group.id);
|
||||||
|
|
||||||
|
// Собираем все уникальные теги в этой группе
|
||||||
|
const tagMap = new Map<string, Word[]>();
|
||||||
|
|
||||||
|
for (const word of groupWords) {
|
||||||
|
if (!word.tags || word.tags.trim() === '') {
|
||||||
|
// Слова без тегов — в группу "без тега"
|
||||||
|
const tag = '(без тега)';
|
||||||
|
if (!tagMap.has(tag)) tagMap.set(tag, []);
|
||||||
|
tagMap.get(tag)!.push(word);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = word.tags.split(',').map((t) => t.trim()).filter(Boolean);
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!tagMap.has(tag)) tagMap.set(tag, []);
|
||||||
|
tagMap.get(tag)!.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagEntries: TagGroup[] = [];
|
||||||
|
for (const [tag, tagWords] of tagMap) {
|
||||||
|
tagEntries.push({ tag, words: tagWords });
|
||||||
|
}
|
||||||
|
// Сортируем теги: сначала с наибольшим количеством слов
|
||||||
|
tagEntries.sort((a, b) => b.words.length - a.words.length);
|
||||||
|
|
||||||
|
result.push({ group, tags: tagEntries });
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|||||||
+87
-34
@@ -1,9 +1,9 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts';
|
import { initDB, closeDb, getDatabaseFile, replaceDatabaseFile } from './sqlite-manager.ts';
|
||||||
import { uploadDatabase, downloadDatabase } from './sync.ts';
|
import { uploadDatabase, downloadDatabase } from './sync.ts';
|
||||||
import { getWordsForReview, getCardSets, getWordsAndStatsForSet } from './db-manager.ts';
|
import { getCardSets, getWordsAndStatsForSet, getGroupedWords } from './db-manager.ts';
|
||||||
|
import type { GroupedWords } from './db-manager.ts';
|
||||||
import type { CardSetRecord } from './db-manager.ts';
|
import type { CardSetRecord } from './db-manager.ts';
|
||||||
import type { Word } from './db-manager.ts';
|
|
||||||
import { SetOrderMode } from './algorithm.ts';
|
import { SetOrderMode } from './algorithm.ts';
|
||||||
import { TrainingPage } from './training.ts';
|
import { TrainingPage } from './training.ts';
|
||||||
import type { WordItem } from './training.ts';
|
import type { WordItem } from './training.ts';
|
||||||
@@ -40,7 +40,6 @@ function getSyncId(): string {
|
|||||||
const input = document.getElementById('syncId') as HTMLInputElement | null;
|
const input = document.getElementById('syncId') as HTMLInputElement | null;
|
||||||
const id = input?.value.trim();
|
const id = input?.value.trim();
|
||||||
if (!id) throw new Error('Введите ID синхронизации');
|
if (!id) throw new Error('Введите ID синхронизации');
|
||||||
// Сохраняем в localStorage
|
|
||||||
localStorage.setItem(SYNC_ID_STORAGE_KEY, id);
|
localStorage.setItem(SYNC_ID_STORAGE_KEY, id);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -70,37 +69,98 @@ function renderWordsPage() {
|
|||||||
|
|
||||||
<div class="card" style="flex: 1;">
|
<div class="card" style="flex: 1;">
|
||||||
<h2 class="card-title">📚 Слова</h2>
|
<h2 class="card-title">📚 Слова</h2>
|
||||||
<ul id="word-list" class="word-list"></ul>
|
<div id="word-groups"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="status" class="status-bar">Загрузка...</div>
|
<div id="status" class="status-bar">Загрузка...</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWordsList(words: Word[]) {
|
function renderWordGroups(grouped: GroupedWords[]) {
|
||||||
const wordListEl = document.getElementById('word-list') as HTMLUListElement | null;
|
const containerEl = document.getElementById('word-groups');
|
||||||
if (!wordListEl) return;
|
if (!containerEl) return;
|
||||||
|
|
||||||
wordListEl.innerHTML = '';
|
containerEl.innerHTML = '';
|
||||||
|
|
||||||
if (words.length === 0) {
|
if (grouped.length === 0) {
|
||||||
wordListEl.innerHTML = '<li class="word-item word-item-empty">Нет слов для повторения 🎉</li>';
|
containerEl.innerHTML = '<p style="text-align:center;opacity:0.6;padding:1rem;">Нет слов 🎉</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const word of words) {
|
for (const entry of grouped) {
|
||||||
const li = document.createElement('li');
|
const groupEl = document.createElement('div');
|
||||||
li.className = 'word-item';
|
groupEl.className = 'word-group';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'word-group-header';
|
||||||
|
header.innerHTML = `
|
||||||
|
<span class="word-group-name">${escapeHtml(entry.group.name)}</span>
|
||||||
|
<span class="word-group-count">${entry.tags.reduce((s, t) => s + t.words.length, 0)} слов</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Коллапс по клику на заголовке
|
||||||
|
let collapsed = false;
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
collapsed = !collapsed;
|
||||||
|
tagsContainer.style.display = collapsed ? 'none' : '';
|
||||||
|
header.classList.toggle('collapsed', collapsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupEl.appendChild(header);
|
||||||
|
|
||||||
|
const tagsContainer = document.createElement('div');
|
||||||
|
tagsContainer.className = 'word-group-tags';
|
||||||
|
|
||||||
|
for (const tagEntry of entry.tags) {
|
||||||
|
const tagBlock = document.createElement('div');
|
||||||
|
tagBlock.className = 'word-tag-block';
|
||||||
|
|
||||||
|
const tagHeader = document.createElement('div');
|
||||||
|
tagHeader.className = 'word-tag-header';
|
||||||
|
tagHeader.innerHTML = `
|
||||||
|
<span class="word-tag-label">#${escapeHtml(tagEntry.tag)}</span>
|
||||||
|
<span class="word-tag-count">${tagEntry.words.length}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let tagCollapsed = true;
|
||||||
|
const wordsContainer = document.createElement('div');
|
||||||
|
wordsContainer.className = 'word-tag-words';
|
||||||
|
wordsContainer.style.display = 'none';
|
||||||
|
|
||||||
|
tagHeader.addEventListener('click', () => {
|
||||||
|
tagCollapsed = !tagCollapsed;
|
||||||
|
wordsContainer.style.display = tagCollapsed ? 'none' : '';
|
||||||
|
tagHeader.classList.toggle('collapsed', tagCollapsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const word of tagEntry.words) {
|
||||||
|
const wordEl = document.createElement('div');
|
||||||
|
wordEl.className = 'word-item';
|
||||||
|
|
||||||
let reading = word.key;
|
let reading = word.key;
|
||||||
let translation = word.value;
|
let translation = word.value;
|
||||||
|
if (word.more) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(word.more);
|
||||||
|
reading = parsed.reading || word.key;
|
||||||
|
translation = parsed.translation || word.value;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
li.innerHTML = `
|
wordEl.innerHTML = `
|
||||||
<span class="word-key">${reading}</span>
|
<span class="word-key">${escapeHtml(reading)}</span>
|
||||||
<span class="word-value">${escapeHtml(translation)}</span>
|
<span class="word-value">${escapeHtml(translation)}</span>
|
||||||
${word.tags ? `<span class="word-tags">${escapeHtml(word.tags)}</span>` : ''}
|
|
||||||
`;
|
`;
|
||||||
wordListEl.appendChild(li);
|
wordsContainer.appendChild(wordEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
tagBlock.appendChild(tagHeader);
|
||||||
|
tagBlock.appendChild(wordsContainer);
|
||||||
|
tagsContainer.appendChild(tagBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
groupEl.appendChild(tagsContainer);
|
||||||
|
containerEl.appendChild(groupEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +239,6 @@ async function startTraining(setId: number) {
|
|||||||
const modeSelect = document.getElementById('mode-select') as HTMLSelectElement | null;
|
const modeSelect = document.getElementById('mode-select') as HTMLSelectElement | null;
|
||||||
const mode = (modeSelect?.value as SetOrderMode) || SetOrderMode.Default;
|
const mode = (modeSelect?.value as SetOrderMode) || SetOrderMode.Default;
|
||||||
|
|
||||||
// Получаем колоду
|
|
||||||
const sets = await getCardSets();
|
const sets = await getCardSets();
|
||||||
const set = sets.find(s => s.id === setId);
|
const set = sets.find(s => s.id === setId);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
@@ -187,14 +246,13 @@ async function startTraining(setId: number) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем слова и статистику
|
|
||||||
const { words, stats } = await getWordsAndStatsForSet(setId);
|
const { words, stats } = await getWordsAndStatsForSet(setId);
|
||||||
if (words.length === 0) {
|
if (words.length === 0) {
|
||||||
setStatus('Нет слов для тренировки', true);
|
setStatus('Нет слов для тренировки', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей статистики, режим: ${mode}`);
|
console.log(`[Training] Запуск тренировки: ${words.length} слов, ${stats.length} записей, режим: ${mode}`);
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
id: set.id,
|
id: set.id,
|
||||||
@@ -230,7 +288,6 @@ async function startTraining(setId: number) {
|
|||||||
score: s.score,
|
score: s.score,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Показываем страницу тренировки
|
|
||||||
hideNav();
|
hideNav();
|
||||||
currentPage = 'training';
|
currentPage = 'training';
|
||||||
|
|
||||||
@@ -263,7 +320,7 @@ function switchPage(page: 'words' | 'sets') {
|
|||||||
|
|
||||||
if (page === 'words') {
|
if (page === 'words') {
|
||||||
renderWordsPage();
|
renderWordsPage();
|
||||||
loadWords();
|
loadGroupedWords();
|
||||||
} else {
|
} else {
|
||||||
renderSetsPage();
|
renderSetsPage();
|
||||||
loadSets();
|
loadSets();
|
||||||
@@ -278,12 +335,13 @@ function showNav() {
|
|||||||
document.getElementById('app-nav')!.style.display = 'flex';
|
document.getElementById('app-nav')!.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWords() {
|
async function loadGroupedWords() {
|
||||||
try {
|
try {
|
||||||
const words = await getWordsForReview();
|
const grouped = await getGroupedWords();
|
||||||
renderWordsList(words);
|
renderWordGroups(grouped);
|
||||||
const count = words.length;
|
|
||||||
setStatus(`Слов: ${count}`);
|
const totalWords = grouped.reduce((s, g) => s + g.tags.reduce((s2, t) => s2 + t.words.length, 0), 0);
|
||||||
|
setStatus(`Слов: ${totalWords}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setStatus('Ошибка загрузки слов', true);
|
setStatus('Ошибка загрузки слов', true);
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -312,7 +370,7 @@ async function onDownload() {
|
|||||||
setStatus('Скачивание...');
|
setStatus('Скачивание...');
|
||||||
await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB);
|
await downloadDatabase(id, API_BASE_URL, replaceDatabaseFile, initDB);
|
||||||
|
|
||||||
if (currentPage === 'words') await loadWords();
|
if (currentPage === 'words') await loadGroupedWords();
|
||||||
setStatus('База данных скачана.');
|
setStatus('База данных скачана.');
|
||||||
} catch {
|
} catch {
|
||||||
// ошибка уже обработана в sync.ts
|
// ошибка уже обработана в sync.ts
|
||||||
@@ -332,7 +390,7 @@ async function onUpload() {
|
|||||||
await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile);
|
await uploadDatabase(id, API_BASE_URL, closeDb, getDatabaseFile);
|
||||||
|
|
||||||
await initDB();
|
await initDB();
|
||||||
if (currentPage === 'words') await loadWords();
|
if (currentPage === 'words') await loadGroupedWords();
|
||||||
setStatus('База данных загружена.');
|
setStatus('База данных загружена.');
|
||||||
} catch {
|
} catch {
|
||||||
// ошибка уже обработана в sync.ts
|
// ошибка уже обработана в sync.ts
|
||||||
@@ -349,7 +407,6 @@ async function main() {
|
|||||||
await initDB();
|
await initDB();
|
||||||
console.log('[App] База данных инициализирована.');
|
console.log('[App] База данных инициализирована.');
|
||||||
|
|
||||||
// Навигация
|
|
||||||
navBtns.forEach(btn => {
|
navBtns.forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (currentPage !== 'training') {
|
if (currentPage !== 'training') {
|
||||||
@@ -358,13 +415,9 @@ async function main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Стартовая страница
|
|
||||||
switchPage('words');
|
switchPage('words');
|
||||||
|
|
||||||
// Восстанавливаем сохранённый ID синхронизации
|
|
||||||
restoreSyncId();
|
restoreSyncId();
|
||||||
|
|
||||||
// Глобальные обработчики
|
|
||||||
container.addEventListener('click', async (e) => {
|
container.addEventListener('click', async (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.id === 'btnDownload') await onDownload();
|
if (target.id === 'btnDownload') await onDownload();
|
||||||
|
|||||||
+108
@@ -300,6 +300,114 @@ body {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──── Группировка слов ──── */
|
||||||
|
|
||||||
|
.word-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.15s;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-header:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-header::after {
|
||||||
|
content: '▾';
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-header.collapsed::after {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-group-tags {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-block {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-header:hover {
|
||||||
|
background: var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-header::after {
|
||||||
|
content: '▸';
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-header.collapsed::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--accent-light);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-words {
|
||||||
|
padding: 0.25rem 0 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-words .word-item {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-tag-words .word-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ──── Навигация ──── */
|
/* ──── Навигация ──── */
|
||||||
|
|
||||||
.app-nav {
|
.app-nav {
|
||||||
|
|||||||
Reference in New Issue
Block a user