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