displaing words

This commit is contained in:
micialware committed 2026-09-15 10:41:24 +03:00
1 parent 8a0834abac
commit ca18ee8cc7
14 files changed
+540 -90

No files matched your search

+133 -20
View File
@@ -7,7 +7,7 @@
// т.е. количество дней с 1970-01-01, без времени;
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
import { query } from "./sqlite-api";
import { query } from "./sqlite-api.ts";
export interface CustomField {
id: number;
@@ -37,32 +37,145 @@ export interface Word {
id: number;
key: string;
value: string;
/** Сырое значение колонки tags (как лежит в БД). */
tags: string;
/** Распарсенные теги для UI (split по запятой/пробелам/точке с запятой). */
tagsList: string[];
/**
* Доп. поля: more хранится как чередование пар через \u001f:
* "fieldId\u001fvalue\u001ffieldId\u001fvalue...".
* Ключ — id из custom_fields, значение — текст.
*/
more: Map<number, string>;
group_id: number;
}
export async function wordList() {
let rows = await query("SELECT id, key, value, tags, more, group_id FROM words");
return rows.map((row: any) => {
let more : string = row.more.split('');
let dict = new Map();
for (let i = 0; i < more.length / 2; i++) {
dict.set(more[i * 2], more[i * 2 + 1]);
}
let w = {
id: row.id,
key: row.key,
value: row.value,
tags: row.tags,
more: dict,
group_id: row.group_id,
}
return w;
}) as Word[];
export interface WordListFilter {
/** Подстрока для поиска по key/value/tags (LIKE %q%, без учёта регистра). */
search?: string;
/** Фильтр по группе. undefined/null = все группы. */
groupId?: number | null;
limit?: number;
offset?: number;
}
/** Разбирает сырое поле tags в список чипсов. */
export function parseTags(raw: unknown): string[] {
if (raw == null) return [];
const s = String(raw).trim();
if (!s) return [];
return s
.split(/[,\s;]+/)
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
/**
* Разбирает сырое поле more в Map<fieldId, value>.
* Формат десктопа: пары через \u001f (Unit Separator):
* "3\u001fчтение\u001f5\u001fпример". Нечисловые ключи игнорируем,
* висячий хвост без пары отбрасываем, пустые значения пропускаем.
*/
export function parseMore(raw: unknown): Map<number, string> {
const dict = new Map<number, string>();
if (raw == null) return dict;
const s = String(raw);
if (!s) return dict;
const parts = s.split("");
for (let i = 0; i + 1 < parts.length; i += 2) {
const id = Number(parts[i]);
const val = parts[i + 1] ?? "";
if (!Number.isFinite(id) || !Number.isInteger(id)) continue;
if (!val) continue;
dict.set(id, val);
}
return dict;
}
function mapRowToWord(row: Record<string, unknown>): Word {
const id = Number(row["id"] ?? 0);
const key = String(row["key"] ?? "");
const value = String(row["value"] ?? "");
const tagsRaw = row["tags"] == null ? "" : String(row["tags"]);
const group_id = Number(row["group_id"] ?? 1);
return {
id,
key,
value,
tags: tagsRaw,
tagsList: parseTags(tagsRaw),
more: parseMore(row["more"]),
group_id: Number.isFinite(group_id) ? group_id : 1,
};
}
function buildWhere(filter: WordListFilter): { clause: string; params: unknown[] } {
const conds: string[] = [];
const params: unknown[] = [];
const search = (filter.search ?? "").trim();
if (search) {
// Простой LIKE-поиск по трём колонкам. more не трогаем (там id+значения вперемешку).
conds.push("(key LIKE ? ESCAPE '\\' OR value LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')");
const like = `%${search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`;
params.push(like, like, like);
}
if (filter.groupId != null) {
conds.push("group_id = ?");
params.push(filter.groupId);
}
return { clause: conds.length > 0 ? `WHERE ${conds.join(" AND ")}` : "", params };
}
/** Сколько всего слов подходит под фильтр (для пагинации/счётчика). */
export async function countWords(filter: WordListFilter = {}): Promise<number> {
const { clause, params } = buildWhere(filter);
const rows = await query(`SELECT COUNT(*) as cnt FROM words ${clause}`, params as never[]);
const cnt = (rows[0] as Record<string, unknown> | undefined)?.["cnt"];
return typeof cnt === "number" ? cnt : Number(cnt ?? 0);
}
/**
* Визуализация слов для главной страницы: поиск + фильтр по группе + пагинация.
* Сортировка — по id (порядок добавления), чтобы список был стабильным.
*/
export async function getWords(filter: WordListFilter = {}): Promise<Word[]> {
const { clause, params } = buildWhere(filter);
const limit = Math.max(1, Math.min(filter.limit ?? 50, 500));
const offset = Math.max(0, filter.offset ?? 0);
const rows = await query(
`SELECT id, key, value, tags, more, group_id FROM words ${clause} ORDER BY id LIMIT ? OFFSET ?`,
[...(params as unknown[]), limit, offset] as never[],
);
return (rows as Record<string, unknown>[]).map(mapRowToWord);
}
/**
* Легаси-обёртка: все слова без пагинации (для десктоп-совместимости / отладки).
* Для UI используй getWords() + countWords().
*/
export async function wordList(): Promise<Word[]> {
const rows = await query("SELECT id, key, value, tags, more, group_id FROM words ORDER BY id");
return (rows as Record<string, unknown>[]).map(mapRowToWord);
}
export async function getWordGroups(): Promise<WordGroup[]> {
const rows = await query("SELECT id, name FROM word_groups ORDER BY id");
return (rows as Record<string, unknown>[]).map((r) => ({
id: Number(r["id"]),
name: String(r["name"] ?? ""),
}));
}
export async function getCustomFields(): Promise<CustomField[]> {
const rows = await query("SELECT id, name, identifier FROM custom_fields ORDER BY id");
return (rows as Record<string, unknown>[]).map((r) => ({
id: Number(r["id"]),
name: String(r["name"] ?? ""),
identifier: String(r["identifier"] ?? ""),
}));
}
export interface HistoryEntry {
id: number;
deck_id: number;
+5 -5
View File
@@ -1,5 +1,5 @@
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
import { DB_FILENAME } from './opfs';
import { DB_FILENAME } from './opfs.ts';
let db: any | null = null;
@@ -7,7 +7,6 @@ self.onmessage = async (e) => {
const { type, id, sql, params } = e.data;
try {
console.log(e.data.type)
switch (type) {
case 'start': {
if (db != null) {
@@ -30,14 +29,14 @@ self.onmessage = async (e) => {
}
case 'query': {
if (db === null) throw new Error('БД не инициализирована');
const result = db.selectObjects(sql, params);
const result = db.selectObjects(sql, params ?? []);
self.postMessage({ type: 'success', id, data: result });
break;
}
case 'command': {
if (db === null) throw new Error('БД не инициализирована');
db.exec(sql, params);
db.exec(sql, params ?? []);
self.postMessage({ type: 'success', id, data: null });
break;
}
@@ -45,11 +44,12 @@ self.onmessage = async (e) => {
if (db === null) throw new Error('БД не инициализирована');
db.close();
db = null;
self.postMessage({ type: 'success', id, data: null });
break;
}
}
} catch (err: any) {
console.error(err);
self.postMessage({ type: 'error', message: err.message , id});
self.postMessage({ type: 'error', error: err?.message ?? String(err), id });
}
};
+177 -7
View File
@@ -1,8 +1,8 @@
// Главная страница: статус локальной базы + быстрый переход к синхронизации.
// Никакой БД-логики кроме проверки наличия файла в OPFS.
// Главная страница: статус локальной базы + визуализация слов (getWords).
// Поиск по key/value/tags, фильтр по группе, пагинация «Показать ещё».
import { wordList } from './db-types.ts';
import { worker } from './main.ts';
import { countWords, getCustomFields, getWordGroups, getWords } from './db-types.ts';
import type { CustomField, Word, WordGroup } from './db-types.ts';
import { hasDatabaseFile, readDatabaseFile } from './opfs.ts';
import { looksLikeSQLite } from './zstd.ts';
@@ -10,6 +10,36 @@ export type HomeNav = {
goSync: () => void;
};
const PAGE_SIZE = 30;
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function wordCardHtml(w: Word, gname: string | undefined, fmap: Map<number, CustomField>): string {
const tags = w.tagsList.map((t) => `<span class="tag-chip">${escapeHtml(t)}</span>`).join('');
const mrows = [...w.more.entries()]
.map(([fid, val]) => {
const f = fmap.get(fid);
const label = f ? `${escapeHtml(f.name)} <span class="word-more-id">#${fid}</span>` : `Поле #${fid}`;
return `<div class="word-more-row"><dt>${label}</dt><dd>${escapeHtml(val)}</dd></div>`;
})
.join('');
const more = mrows
? `<details class="word-more"><summary>Дополнительно (${w.more.size})</summary><dl class="word-more-list">${mrows}</dl></details>`
: '';
return `<article class="word-card" data-word-id="${w.id}">
<div class="word-card-top"><div class="word-key">${escapeHtml(w.key)}</div>`
+ (gname ? `<span class="word-group">${escapeHtml(gname)}</span>` : '')
+ `</div><div class="word-value">${escapeHtml(w.value)}</div>`
+ (tags ? `<div class="tag-row">${tags}</div>` : '') + more + `</article>`;
}
export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
root.innerHTML = `
<main class="card" aria-labelledby="home-title">
@@ -23,23 +53,150 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
<button id="btn-start" class="btn" type="button">▶ Начать повторение</button>
</div>
</main>
<section class="card" aria-labelledby="words-title">
<h2 id="words-title" class="card-title">📚 Слова</h2>
<div class="words-toolbar">
<input id="words-search" class="words-search" type="search" placeholder="Поиск: ключ, перевод, тег…"
autocomplete="off" aria-label="Поиск по словам" />
<select id="words-group" class="words-group" aria-label="Фильтр по группе">
<option value="">Все группы</option>
</select>
</div>
<p id="words-count" class="card-hint" role="status" aria-live="polite"></p>
<div id="words-status" class="status-bar" role="status">База ещё не проверена.</div>
<div id="words-list" class="words-list" aria-live="polite"></div>
<div class="btn-row"><button id="words-more" class="btn" type="button" hidden>Показать ещё</button></div>
</section>
`;
const statusEl = root.querySelector<HTMLElement>('#home-db-status')!;
const goSync = root.querySelector<HTMLButtonElement>('#btn-go-sync')!;
const actions = root.querySelector<HTMLElement>('#home-actions')!;
const btnStart = root.querySelector<HTMLButtonElement>('#btn-start')!;
const searchInput = root.querySelector<HTMLInputElement>('#words-search')!;
const groupSelect = root.querySelector<HTMLSelectElement>('#words-group')!;
const countEl = root.querySelector<HTMLElement>('#words-count')!;
const wordsStatus = root.querySelector<HTMLElement>('#words-status')!;
const listEl = root.querySelector<HTMLElement>('#words-list')!;
const moreBtn = root.querySelector<HTMLButtonElement>('#words-more')!;
goSync.addEventListener('click', () => nav.goSync());
btnStart.addEventListener('click', () => {
statusEl.textContent = 'Экран повторений — следующий шаг.';
});
// --- Состояние списка слов ---
let dbReady = false;
let gnames = new Map<number, string>();
let fmap = new Map<number, CustomField>();
let total = 0;
let loaded: Word[] = [];
let offset = 0;
let loading = false;
let reqSeq = 0;
let debounce = 0;
function wordsFilter(): { search: string; groupId: number | null } {
const v = groupSelect.value;
if (v === '') return { search: searchInput.value.trim(), groupId: null };
const n = Number(v);
return { search: searchInput.value.trim(), groupId: Number.isFinite(n) ? n : null };
}
function refreshMoreBtn(): void {
moreBtn.hidden = !(dbReady && loaded.length < total);
if (!moreBtn.hidden) moreBtn.textContent = `Показать ещё (${total - loaded.length} осталось)`;
}
function fullRender(): void {
listEl.innerHTML = loaded.map((w) => wordCardHtml(w, gnames.get(w.group_id), fmap)).join('');
countEl.textContent = total > 0 ? `Показано ${loaded.length} из ${total}` : '';
refreshMoreBtn();
}
function setWStatus(msg: string): void {
wordsStatus.textContent = msg;
wordsStatus.hidden = msg === '';
}
async function reloadWords(): Promise<void> {
if (!dbReady || loading) return;
loading = true;
const my = ++reqSeq;
const f = wordsFilter();
moreBtn.disabled = true;
if (loaded.length === 0) setWStatus('Загрузка слов…');
try {
const [cnt, words] = await Promise.all([
countWords({ search: f.search, groupId: f.groupId ?? undefined }),
getWords({ search: f.search, groupId: f.groupId ?? undefined, limit: PAGE_SIZE, offset: 0 }),
]);
if (my !== reqSeq) return;
total = cnt;
loaded = words;
offset = words.length;
fullRender();
setWStatus(words.length === 0 ? 'По этому запросу слов нет.' : '');
if (words.length === 0 && total === 0) countEl.textContent = 'Слов в базе пока нет.';
} catch (err) {
if (my !== reqSeq) return;
setWStatus(`Не удалось загрузить слова: ${err instanceof Error ? err.message : String(err)}`);
} finally {
if (my === reqSeq) {
loading = false;
moreBtn.disabled = false;
refreshMoreBtn();
}
}
}
async function loadMore(): Promise<void> {
if (!dbReady || loading || loaded.length >= total) return;
loading = true;
const my = ++reqSeq;
const f = wordsFilter();
moreBtn.disabled = true;
moreBtn.textContent = 'Загрузка…';
try {
const words = await getWords({ search: f.search, groupId: f.groupId ?? undefined, limit: PAGE_SIZE, offset });
if (my !== reqSeq) return;
loaded = loaded.concat(words);
offset += words.length;
listEl.insertAdjacentHTML('beforeend', words.map((w) => wordCardHtml(w, gnames.get(w.group_id), fmap)).join(''));
countEl.textContent = `Показано ${loaded.length} из ${total}`;
refreshMoreBtn();
} catch (err) {
if (my !== reqSeq) return;
setWStatus(`Не удалось догрузить: ${err instanceof Error ? err.message : String(err)}`);
} finally {
if (my === reqSeq) {
loading = false;
moreBtn.disabled = false;
refreshMoreBtn();
}
}
}
searchInput.addEventListener('input', () => {
window.clearTimeout(debounce);
debounce = window.setTimeout(() => void reloadWords(), 300);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
window.clearTimeout(debounce);
void reloadWords();
}
});
groupSelect.addEventListener('change', () => void reloadWords());
moreBtn.addEventListener('click', () => void loadMore());
void (async () => {
const has = await hasDatabaseFile();
if (!has) {
statusEl.textContent = 'Локальной базы нет. Скачай сохранение на странице «Синхронизация».';
actions.hidden = true;
setWStatus('Нет локальной базы — скачай сохранение на странице «Синхронизация».');
countEl.textContent = '';
return;
}
try {
@@ -49,9 +206,22 @@ export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
? `Локальная база на месте: ${(raw.length / 1024).toFixed(1)} КБ, сигнатура SQLite OK.`
: 'Файл в OPFS есть, но это не похоже на SQLite — перекачай сохранение.';
actions.hidden = !ok;
if (ok) {
let list = await wordList();
console.log(list);
if (!ok) {
setWStatus('Файл в OPFS не похож на SQLite — перекачай сохранение.');
return;
}
try {
const [gs, fields] = await Promise.all([getWordGroups(), getCustomFields()]);
const groups: WordGroup[] = gs;
gnames = new Map(groups.map((g) => [g.id, g.name]));
fmap = new Map(fields.map((f) => [f.id, f]));
groupSelect.innerHTML = '<option value="">Все группы</option>'
+ groups.map((g) => `<option value="${g.id}">${escapeHtml(g.name)}</option>`).join('');
dbReady = true;
await reloadWords();
groupSelect.options[0].textContent = `Все группы (${total})`;
} catch (err) {
setWStatus(`БД есть, но слова не прочитались: ${err instanceof Error ? err.message : String(err)}`);
}
} catch (err) {
statusEl.textContent = `Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`;
-4
View File
@@ -3,10 +3,6 @@ import { renderHomePage } from './home-page.ts';
import { renderSyncPage } from './sync-page.ts';
import { initWorker } from './sqlite-api.ts';
export const worker = new Worker(new URL('./db-worker.ts', import.meta.url), {
type: 'module',
});
// Каркас приложения: топ-бар + бургер-меню + две страницы (Главная / Синхронизация).
type Page = 'home' | 'sync';
+6 -6
View File
@@ -1,4 +1,6 @@
import { worker } from "./main";
const worker = new Worker(new URL('./db-worker.ts', import.meta.url), {
type: 'module',
});
let requestId = 0;
@@ -14,7 +16,6 @@ export function initWorker(): Promise<void> {
initPromise = new Promise((resolve, reject) => {
worker.onmessage = (e: MessageEvent) => {
console.log("Handling worker output");
const msg = e.data;
if (msg.type === 'ready') {
@@ -29,8 +30,7 @@ export function initWorker(): Promise<void> {
if (msg.type == "success") {
pendingReq.resolve(msg.data);
} else {
console.log(msg)
pendingReq.reject(new Error(msg.error || 'Неизвестная ошибка Worker'));
pendingReq.reject(new Error(msg.error ?? msg.message ?? 'Неизвестная ошибка Worker'));
}
};
@@ -63,12 +63,12 @@ function sendToWorker(type: string, payload?: any): Promise<any> {
}
export async function query(sql: string, params?: any[]): Promise<any[]> {
const result = await sendToWorker('query', { sql, params });
const result = await sendToWorker('query', { sql, params: params ?? [] });
return result;
}
export async function command(sql: string, params?: any[]): Promise<void> {
await sendToWorker('command', { sql, params });
await sendToWorker('command', { sql, params: params ?? [] });
}
export async function closeDb(): Promise<void> {
+149
View File
@@ -281,3 +281,152 @@ body.drawer-open {
font-size: 0.95rem;
word-break: break-word;
}
.status-bar[hidden] {
display: none;
}
/* --- Список слов (главная) --- */
.words-toolbar {
display: flex;
gap: 8px;
}
.words-search {
flex: 1;
min-width: 0;
padding: 12px;
font-size: 1rem;
border: 2px solid #d9d2f0;
border-radius: 12px;
outline: none;
background: #fff;
color: inherit;
}
.words-search:focus {
border-color: #7c3aed;
}
.words-group {
flex: none;
max-width: 42%;
padding: 12px 8px;
font-size: 0.95rem;
font-weight: 600;
color: #4c446b;
border: 2px solid #d9d2f0;
border-radius: 12px;
background: #fff;
outline: none;
}
.words-group:focus {
border-color: #7c3aed;
}
.words-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.word-card {
border: 1px solid #eee9fb;
border-radius: 12px;
padding: 12px;
background: #fbfaff;
display: flex;
flex-direction: column;
gap: 8px;
}
.word-card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.word-key {
font-size: 1.2rem;
font-weight: 700;
line-height: 1.3;
word-break: break-word;
}
.word-value {
font-size: 1rem;
color: #4c446b;
word-break: break-word;
}
.word-group {
flex: none;
font-size: 0.75rem;
font-weight: 700;
color: #6d28d9;
background: #f1eafe;
border-radius: 999px;
padding: 4px 10px;
white-space: nowrap;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
font-size: 0.8rem;
font-weight: 600;
color: #5b21b6;
background: #ede9fe;
border-radius: 999px;
padding: 3px 10px;
word-break: break-word;
}
.word-more {
font-size: 0.9rem;
color: #4c446b;
}
.word-more summary {
cursor: pointer;
font-weight: 600;
color: #6d28d9;
touch-action: manipulation;
}
.word-more-list {
margin: 8px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.word-more-row {
background: #fff;
border: 1px solid #eee9fb;
border-radius: 8px;
padding: 8px 10px;
}
.word-more-row dt {
font-size: 0.8rem;
font-weight: 700;
color: #6d6390;
}
.word-more-row dd {
margin: 2px 0 0;
word-break: break-word;
}
.word-more-id {
font-weight: 400;
opacity: 0.7;
}