first commit
This commit is contained in:
commit
4646dad2b8
18 files changed
+1971
No files matched your search
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Зависимости
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Сборка
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Окружение
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Редактор / ОС
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Cross-Origin-Embedder-Policy" content="require-corp" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<title>JapLearn — интервальные повторения</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>JapLearn</h1>
|
||||||
|
<p class="subtitle">Интервальные повторения · офлайн-первая</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card" aria-labelledby="sync-title">
|
||||||
|
<h2 id="sync-title">Сохранение</h2>
|
||||||
|
<p class="card-note">
|
||||||
|
База данных хранится локально на устройстве. Токен связывает её
|
||||||
|
с копией на сервере — скачивайте и выгружайте файл по мере
|
||||||
|
необходимости.
|
||||||
|
</p>
|
||||||
|
<form id="sync-form" novalidate>
|
||||||
|
<div class="field">
|
||||||
|
<label for="save-token">Токен сохранения</label>
|
||||||
|
<input
|
||||||
|
id="save-token"
|
||||||
|
name="save-token"
|
||||||
|
type="text"
|
||||||
|
maxlength="24"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="characters"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="24 буквы или цифры"
|
||||||
|
aria-describedby="token-hint token-error"
|
||||||
|
/>
|
||||||
|
<p id="token-hint" class="hint">
|
||||||
|
Последовательность из 24 символов: латинские буквы (A–Z)
|
||||||
|
и цифры (0–9).
|
||||||
|
</p>
|
||||||
|
<p id="token-error" class="error" hidden>
|
||||||
|
Нужно ровно 24 символа — латинские буквы и цифры, без пробелов
|
||||||
|
и знаков.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="btn-download" class="btn btn-primary" disabled>
|
||||||
|
Скачать
|
||||||
|
</button>
|
||||||
|
<button type="button" id="btn-upload" class="btn btn-secondary" disabled>
|
||||||
|
Выгрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p id="sync-status" class="status" role="status" aria-live="polite"></p>
|
||||||
|
<p id="db-state" class="status" role="status" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p id="sqlite-status" class="footer-status"></p>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1219
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "japlearn",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Мобильное PWA для интервальных повторений (offline-first, OPFS + wa-sqlite)",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vite": "^8.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hpcc-js/wasm-zstd": "^1.16.0",
|
||||||
|
"wa-sqlite": "^1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#4f46e5"/>
|
||||||
|
<text x="16" y="22" font-size="16" text-anchor="middle" fill="#ffffff" font-family="sans-serif">J</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,15 @@
|
|||||||
|
// Бэкенд для переноса сохранения между устройствами.
|
||||||
|
// Единственная сетевая функция приложения — скачивание и выгрузка
|
||||||
|
// бинарного файла .sqlite по токену. Протокол менять нельзя.
|
||||||
|
|
||||||
|
export const BACKEND_BASE_URL = 'https://learning.micialware.ru';
|
||||||
|
|
||||||
|
/** URL скачивания сохранения: https://…/download/{id} */
|
||||||
|
export function downloadUrl(token: string): string {
|
||||||
|
return `${BACKEND_BASE_URL}/download/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL выгрузки сохранения: https://…/upload/stream/{id} */
|
||||||
|
export function uploadUrl(token: string): string {
|
||||||
|
return `${BACKEND_BASE_URL}/upload/stream/${token}`;
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import './style.css';
|
||||||
|
|
||||||
|
import * as sqlite from 'wa-sqlite';
|
||||||
|
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
|
||||||
|
import { SyncPanel } from './sync-panel';
|
||||||
|
|
||||||
|
// Смоук-тест: запускаем SQLite (сборка wa-sqlite) в памяти.
|
||||||
|
// Запись и чтение БД идут через OPFS (src/storage.ts); подключение
|
||||||
|
// SQLite к файлу из OPFS сделаем при работе с самими карточками.
|
||||||
|
const sqliteStatus = document.querySelector<HTMLParagraphElement>('#sqlite-status');
|
||||||
|
|
||||||
|
async function initSqlite(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 1. Загрузчик сам скачивает wa-sqlite.wasm (рядом с wa-sqlite.mjs).
|
||||||
|
const module = await SQLiteESMFactory();
|
||||||
|
// 2. Оборачиваем Emscripten-модуль в JS API.
|
||||||
|
const sqlite3 = sqlite.Factory(module);
|
||||||
|
// 3. Открываем базу в памяти и выполняем запрос.
|
||||||
|
const db = await sqlite3.open_v2(':memory:');
|
||||||
|
let result = 'нет данных';
|
||||||
|
await sqlite3.exec(db, "SELECT 'Привет, мир!'", (row) => {
|
||||||
|
result = String(row[0]);
|
||||||
|
});
|
||||||
|
await sqlite3.close(db);
|
||||||
|
|
||||||
|
if (sqliteStatus) sqliteStatus.textContent = `SQLite работает: ${result}`;
|
||||||
|
} catch (error) {
|
||||||
|
if (sqliteStatus) sqliteStatus.textContent = `SQLite: ошибка инициализации — ${error}`;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главный экран: токен сохранения + кнопки «Скачать» / «Выгрузить».
|
||||||
|
// HTTP-обвязка появится на следующем шаге.
|
||||||
|
const syncPanel = new SyncPanel(document);
|
||||||
|
syncPanel.init();
|
||||||
|
|
||||||
|
void initSqlite();
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
// Минимальные типы OPFS (Origin Private File System) по WICG
|
||||||
|
// «File System Access», в том виде, как они используются в wa-sqlite v1.0.0.
|
||||||
|
// Используем собственные имена (Opfs*), чтобы не пересекаться с типами
|
||||||
|
// из DOM-библиотеки TypeScript.
|
||||||
|
|
||||||
|
/** Файл в OPFS. */
|
||||||
|
interface OpfsFileHandle {
|
||||||
|
getFile(): Promise<File>;
|
||||||
|
createWritable(): Promise<OpfsWritableFileHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Записываемый файловый дескриптор OPFS. */
|
||||||
|
interface OpfsWritableFileHandle {
|
||||||
|
write(data: Uint8Array, at?: number): Promise<number>;
|
||||||
|
truncate?(newSize: number): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каталог OPFS (корень приватного хранилища origin). */
|
||||||
|
interface OpfsDirectoryHandle {
|
||||||
|
getFileHandle(
|
||||||
|
name: string,
|
||||||
|
options?: { create?: boolean },
|
||||||
|
): Promise<OpfsFileHandle | null>;
|
||||||
|
removeEntry(name: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpfsStorage {
|
||||||
|
getDirectory(id?: string): Promise<OpfsDirectoryHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Navigator {
|
||||||
|
readonly storage?: OpfsStorage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Небольшие пользовательские предпочтения, переживающие перезагрузку.
|
||||||
|
// Храним ТОЛЬКО состояние интерфейса (токен для авто-заполнения поля),
|
||||||
|
// а не данные приложения. Сама база данных живёт в OPFS (src/storage.ts).
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'japlearn.token';
|
||||||
|
|
||||||
|
/** localStorage с защитой от недоступного хранилища (приватный режим и т.п.). */
|
||||||
|
function memory(): Storage | null {
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сохранённый ранее токен (null, если его нет). */
|
||||||
|
export function loadToken(): string | null {
|
||||||
|
return memory()?.getItem(TOKEN_KEY) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запоминаем токен, как ввёл пользователь (для восстановления в поле). */
|
||||||
|
export function saveToken(raw: string): void {
|
||||||
|
memory()?.setItem(TOKEN_KEY, raw);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Хранение бинарного файла базы данных в Origin Private File System (OPFS).
|
||||||
|
// Файл живёт в приватном хранилище origin браузера и не теряется при
|
||||||
|
// перезагрузке — ровно тот же «настоящий» .sqlite, что и в десктопной версии.
|
||||||
|
|
||||||
|
/** Имя файла базы данных в OPFS. */
|
||||||
|
export const DB_FILENAME = 'japlearn.sqlite';
|
||||||
|
|
||||||
|
export interface DatabaseInfo {
|
||||||
|
/** Есть ли файл БД. */
|
||||||
|
exists: boolean;
|
||||||
|
/** Размер файла в байтах (null, если файла нет). */
|
||||||
|
size: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поддерживает ли браузер OPFS (navigator.storage). */
|
||||||
|
export function isOpfsSupported(): boolean {
|
||||||
|
return typeof navigator.storage?.getDirectory === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rootDirectory(): Promise<OpfsDirectoryHandle> {
|
||||||
|
if (!isOpfsSupported()) {
|
||||||
|
throw new Error(
|
||||||
|
'OPFS (navigator.storage) недоступен — откройте приложение в Chrome (десктоп или Android)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// У root есть стрелка вниз: тип зафиксирован в opfs.d.ts.
|
||||||
|
return navigator.storage!.getDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текущее состояние файла БД в OPFS. */
|
||||||
|
export async function statDatabase(): Promise<DatabaseInfo> {
|
||||||
|
const dir = await rootDirectory();
|
||||||
|
const fileHandle = await dir.getFileHandle(DB_FILENAME);
|
||||||
|
if (!fileHandle) {
|
||||||
|
return { exists: false, size: null };
|
||||||
|
}
|
||||||
|
const file = await fileHandle.getFile();
|
||||||
|
return { exists: true, size: file.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полностью перезаписывает файл БД в OPFS содержимым database. */
|
||||||
|
export async function writeDatabase(database: Uint8Array): Promise<DatabaseInfo> {
|
||||||
|
const dir = await rootDirectory();
|
||||||
|
const fileHandle = await dir.getFileHandle(DB_FILENAME, { create: true });
|
||||||
|
if (!fileHandle) {
|
||||||
|
throw new Error('не удалось создать файл БД в OPFS');
|
||||||
|
}
|
||||||
|
|
||||||
|
const writable = await fileHandle.createWritable();
|
||||||
|
try {
|
||||||
|
// Записываем с явным смещением и проверяем прогресс (write может
|
||||||
|
// вернуть число реально записанных байт).
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < database.length) {
|
||||||
|
const written = await writable.write(database.subarray(offset), offset);
|
||||||
|
if (written === 0) {
|
||||||
|
throw new Error('OPFS: запись не продвигается');
|
||||||
|
}
|
||||||
|
offset += written;
|
||||||
|
}
|
||||||
|
// Если новый файл короче старого, убираем хвост.
|
||||||
|
writable.truncate?.(database.length);
|
||||||
|
} finally {
|
||||||
|
await writable.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { exists: true, size: database.length };
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--text: #1f2430;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-shadow: 0 1px 3px rgb(0 0 0 / 0.12);
|
||||||
|
--border: #9ca3af;
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #e5e7eb;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--danger: #f87171;
|
||||||
|
--surface: #111827;
|
||||||
|
--surface-shadow: 0 1px 3px rgb(0 0 0 / 0.45);
|
||||||
|
--border: #4b5563;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Шапка */
|
||||||
|
.app-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Карточка сохранения */
|
||||||
|
.card {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--surface-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-note {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#save-token {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 16px; /* 16px — чтобы iOS не зумила страницу */
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#save-token:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#save-token[aria-invalid='true'] {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 48px; /* комфортная область касания */
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border: none;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
border: 1.5px solid var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Статусы и подвал */
|
||||||
|
.status {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
min-height: 1.5em;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-status {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { downloadSave, type DownloadedSave } from './sync';
|
||||||
|
import { isValidToken, normalizeToken } from './token';
|
||||||
|
import { loadToken, saveToken } from './prefs';
|
||||||
|
import { DB_FILENAME, isOpfsSupported, statDatabase, writeDatabase } from './storage';
|
||||||
|
|
||||||
|
/** Находит элемент по селектору и бросает ошибку, если его нет. */
|
||||||
|
function element<T extends Element>(doc: Document, selector: string): T {
|
||||||
|
const el = doc.querySelector(selector);
|
||||||
|
if (!el) {
|
||||||
|
throw new Error(`Элемент не найден: ${selector}`);
|
||||||
|
}
|
||||||
|
return el as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человекочитаемый размер в байтах. */
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
const kib = bytes / 1024;
|
||||||
|
if (kib < 1024) return `${kib.toFixed(1)} КБ`;
|
||||||
|
return `${(kib / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контроллер главного экрана: токен сохранения (запоминается в
|
||||||
|
* localStorage), скачивание + запись БД в OPFS, состояние базы данных
|
||||||
|
* на экране. Выгрузка — HTTP-обвязка на следующем шаге.
|
||||||
|
*/
|
||||||
|
export class SyncPanel {
|
||||||
|
readonly #input: HTMLInputElement;
|
||||||
|
readonly #error: HTMLElement;
|
||||||
|
readonly #downloadBtn: HTMLButtonElement;
|
||||||
|
readonly #uploadBtn: HTMLButtonElement;
|
||||||
|
readonly #status: HTMLElement;
|
||||||
|
readonly #dbState: HTMLElement;
|
||||||
|
|
||||||
|
/** Идёт ли сейчас сетевая операция (блокирует кнопки). */
|
||||||
|
#busy = false;
|
||||||
|
|
||||||
|
/** Последнее скачанное и распакованное сохранение. */
|
||||||
|
#lastSave: DownloadedSave | null = null;
|
||||||
|
|
||||||
|
constructor(doc: Document) {
|
||||||
|
this.#input = element<HTMLInputElement>(doc, '#save-token');
|
||||||
|
this.#error = element(doc, '#token-error');
|
||||||
|
this.#downloadBtn = element<HTMLButtonElement>(doc, '#btn-download');
|
||||||
|
this.#uploadBtn = element<HTMLButtonElement>(doc, '#btn-upload');
|
||||||
|
this.#status = element(doc, '#sync-status');
|
||||||
|
this.#dbState = element(doc, '#db-state');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
// Восстанавливаем сохранённый токен в поле ввода.
|
||||||
|
const savedToken = loadToken();
|
||||||
|
if (savedToken) {
|
||||||
|
this.#input.value = savedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#input.addEventListener('input', () => this.#onTokenChanged());
|
||||||
|
this.#downloadBtn.addEventListener('click', () => void this.#onDownload());
|
||||||
|
this.#uploadBtn.addEventListener('click', () => void this.#onUpload());
|
||||||
|
this.#onTokenChanged();
|
||||||
|
|
||||||
|
// Состояние БД в OPFS при загрузке.
|
||||||
|
void this.#refreshDatabaseState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Канонический токен из поля ввода (uppercase, без пробелов). */
|
||||||
|
get token(): string {
|
||||||
|
return normalizeToken(this.#input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Последнее успешно скачанное и распакованное сохранение. */
|
||||||
|
get lastSave(): DownloadedSave | null {
|
||||||
|
return this.#lastSave;
|
||||||
|
}
|
||||||
|
|
||||||
|
#onTokenChanged(): void {
|
||||||
|
saveToken(this.#input.value);
|
||||||
|
|
||||||
|
const valid = isValidToken(this.#input.value);
|
||||||
|
this.#error.hidden = valid;
|
||||||
|
this.#input.setAttribute('aria-invalid', valid ? 'false' : 'true');
|
||||||
|
this.#updateButtons();
|
||||||
|
if (this.#status.textContent) this.#status.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateButtons(): void {
|
||||||
|
const enabled = isValidToken(this.#input.value) && !this.#busy;
|
||||||
|
this.#downloadBtn.disabled = !enabled;
|
||||||
|
this.#uploadBtn.disabled = !enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #refreshDatabaseState(): Promise<void> {
|
||||||
|
if (!isOpfsSupported()) {
|
||||||
|
this.#dbState.textContent =
|
||||||
|
'База данных: OPFS недоступен в этом браузере — нужен Chrome';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const info = await statDatabase();
|
||||||
|
this.#dbState.textContent = info.exists
|
||||||
|
? `База данных: ${DB_FILENAME} · ${formatBytes(info.size ?? 0)}`
|
||||||
|
: 'База данных: отсутствует';
|
||||||
|
} catch (error) {
|
||||||
|
this.#dbState.textContent = `База данных: ошибка — ${error}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #onDownload(): Promise<void> {
|
||||||
|
if (this.#busy || !isValidToken(this.#input.value)) return;
|
||||||
|
|
||||||
|
this.#busy = true;
|
||||||
|
this.#updateButtons();
|
||||||
|
this.#status.textContent = `Скачивание «${this.token}»…`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.#lastSave = await downloadSave(this.token);
|
||||||
|
const info = await writeDatabase(this.#lastSave.database);
|
||||||
|
this.#dbState.textContent = `База данных: ${DB_FILENAME} · ${formatBytes(info.size ?? 0)}`;
|
||||||
|
this.#status.textContent =
|
||||||
|
`Скачано ${formatBytes(this.#lastSave.compressedBytes)} → ` +
|
||||||
|
`распаковано и записано в OPFS ${formatBytes(this.#lastSave.database.length)}.`;
|
||||||
|
} catch (error) {
|
||||||
|
this.#lastSave = null;
|
||||||
|
this.#status.textContent = `Ошибка скачивания: ${error}`;
|
||||||
|
} finally {
|
||||||
|
this.#busy = false;
|
||||||
|
this.#updateButtons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #onUpload(): Promise<void> {
|
||||||
|
if (this.#busy || !isValidToken(this.#input.value)) return;
|
||||||
|
this.#status.textContent = `Выгрузка «${this.token}» — будет реализована на следующем шаге.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
import { Zstd } from '@hpcc-js/wasm-zstd';
|
||||||
|
import { downloadUrl } from './config';
|
||||||
|
|
||||||
|
// Магический заголовок файла SQLite: "SQLite format 3\0"
|
||||||
|
const SQLITE_MAGIC = new Uint8Array([
|
||||||
|
0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66,
|
||||||
|
0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Магическое число кадра Zstandard: 0x28 B5 2F FD
|
||||||
|
const ZSTD_MAGIC = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]);
|
||||||
|
|
||||||
|
// Zstd.load() — процессный синглтон; держим один экземпляр на всё приложение.
|
||||||
|
let zstdCodec: Promise<Zstd> | null = null;
|
||||||
|
|
||||||
|
function getZstd(): Promise<Zstd> {
|
||||||
|
zstdCodec ??= Zstd.load();
|
||||||
|
return zstdCodec;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadedSave {
|
||||||
|
/** Размер загруженного (сжатого zstd) файла в байтах. */
|
||||||
|
compressedBytes: number;
|
||||||
|
/** Распакованный бинарный файл базы данных SQLite. */
|
||||||
|
database: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Скачивает бинарное тело с сервера. */
|
||||||
|
export async function downloadRaw(url: string): Promise<Uint8Array> {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
redirect: 'follow',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`сервер ответил HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Похоже ли содержимое на кадр Zstandard. */
|
||||||
|
export function isZstdFrame(data: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
data.length >= ZSTD_MAGIC.length &&
|
||||||
|
ZSTD_MAGIC.every((byte, index) => data[index] === byte)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Похоже ли содержимое на файл SQLite (проверка магических байтов). */
|
||||||
|
export function isSqliteDatabase(data: Uint8Array): boolean {
|
||||||
|
return (
|
||||||
|
data.length >= SQLITE_MAGIC.length &&
|
||||||
|
SQLITE_MAGIC.every((byte, index) => data[index] === byte)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивает сохранение по токену и распаковывает zstd-сжатие
|
||||||
|
* (бинарный файл базы данных SQLite).
|
||||||
|
* @param token Токен сохранения (24 латинские буквы).
|
||||||
|
*/
|
||||||
|
export async function downloadSave(token: string): Promise<DownloadedSave> {
|
||||||
|
const compressed = await downloadRaw(downloadUrl(token));
|
||||||
|
|
||||||
|
if (!isZstdFrame(compressed)) {
|
||||||
|
throw new Error(
|
||||||
|
'ответ сервера не похож на zstd-сжатый файл — возможно, токен неверный или сохранение не существует',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const codec = await getZstd();
|
||||||
|
const database = codec.decompress(compressed);
|
||||||
|
|
||||||
|
if (!isSqliteDatabase(database)) {
|
||||||
|
throw new Error(
|
||||||
|
'распакованные данные не похожи на базу данных SQLite (магический заголовок не совпал)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { compressedBytes: compressed.length, database };
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Токен сохранения. Это НЕ JWT и не что-то подобное: последовательность
|
||||||
|
// из ровно 24 символов — буквы латинского алфавита (a–z, A–Z) и цифры (0–9),
|
||||||
|
// по которой бэкенд отдаёт/принимает бинарный файл базы данных.
|
||||||
|
|
||||||
|
export const TOKEN_LENGTH = 24;
|
||||||
|
|
||||||
|
const TOKEN_RE = /^[A-Za-z0-9]{24}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Приводит сырой ввод пользователя к каноническому виду токена:
|
||||||
|
* обрезает пробелы и приводит к верхнему регистру.
|
||||||
|
*/
|
||||||
|
export function normalizeToken(raw: string): string {
|
||||||
|
return raw.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Валиден ли токен: ровно 24 латинские буквы или цифры. */
|
||||||
|
export function isValidToken(raw: string): boolean {
|
||||||
|
return TOKEN_RE.test(normalizeToken(raw));
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
// Типы для ESM-загрузчика wa-sqlite: в пакете он лежит как .mjs без
|
||||||
|
// собственных деклараций. Загрузчик сам подгружает wa-sqlite.wasm.
|
||||||
|
declare module 'wa-sqlite/dist/wa-sqlite.mjs' {
|
||||||
|
/**
|
||||||
|
* Загружает Emscripten-модуль SQLite.
|
||||||
|
* @param options Опции загрузчика (необязательны).
|
||||||
|
* @returns Promise с модулем для передачи в SQLite.Factory(module).
|
||||||
|
*/
|
||||||
|
const SQLiteESMFactory: (options?: Record<string, unknown>) => Promise<any>;
|
||||||
|
export default SQLiteESMFactory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// Настройка проекта под wa-sqlite (SQLite в браузере через WebAssembly).
|
||||||
|
export default defineConfig({
|
||||||
|
// Файлы .wasm (сборка SQLite из пакета wa-sqlite) считаем статическими
|
||||||
|
// ассетами: Vite копирует их в dist/ без трансформации и отдаёт в dev
|
||||||
|
// как есть. Импорт вида `import url from '...wasm?url'` работает всегда.
|
||||||
|
assetsInclude: ['**/*.wasm'],
|
||||||
|
|
||||||
|
optimizeDeps: {
|
||||||
|
// wa-sqlite сам находит свой .wasm-бинарник по соседству с .mjs-загрузчиком
|
||||||
|
// (new URL("wa-sqlite.wasm", import.meta.url)). Если позволить Vite
|
||||||
|
// пре-баблить пакет в .vite/deps, import.meta.url укажет не туда и загрузка
|
||||||
|
// SQLite упадёт. Поэтому пакет исключаем из пре-баблинга.
|
||||||
|
exclude: ['wa-sqlite'],
|
||||||
|
},
|
||||||
|
|
||||||
|
server: {
|
||||||
|
headers: {
|
||||||
|
// Требуется для OPFS (Origin Private File System): без этого заголовка
|
||||||
|
// браузер блокирует доступ к OPFS из встроенного (cross-origin)
|
||||||
|
// контекста. Дублируется через <meta> в index.html.
|
||||||
|
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user