database reading

This commit is contained in:
micialware committed 2026-09-14 14:32:38 +03:00
1 parent 68c24dcec5
commit fc8455e353
418 files changed
+674410 -1561

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
// Бэкенд для переноса сохранения между устройствами.
// Единственная сетевая функция приложения — скачивание и выгрузка
// бинарного файла .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}`;
}
-65
View File
@@ -1,65 +0,0 @@
// Числовые даты в БД JapLearn. Никаких TEXT/DATE-типов: всё — числа.
//
// Два формата:
// 1. «Дата-только» — число дней от 1970-01-01 (UTC).
// Поля: states.fsrs_opened, states.fsrs_open_day.
// Помощники: dateToEpochDay / epochDayToIso / isoToEpochDay.
// 2. Полный таймстамп — миллисекунды от 1970-01-01 (эпоха Unix, UTC).
// Поля: history.timestamp, states.last_opened.
// Помощники: dateToEpochMs / epochMsToIso / isoToEpochMs.
// (Предположение о единице измерения — см. ниже.)
/** Миллисекунд в одних сутках (UTC). */
export const DAY_MS = 86_400_000;
// ---------------------------------------------------------------------------
// Формат «дата-только»: число дней от 1970-01-01.
// ---------------------------------------------------------------------------
/** Количество дней от 1970-01-01 (UTC) до указанной даты. */
export function dateToEpochDay(date: Date): number {
return Math.floor(date.getTime() / DAY_MS);
}
/** Дата (полночь UTC) по номеру дня от 1970-01-01. */
export function epochDayToDate(day: number): Date {
return new Date(day * DAY_MS);
}
/** ISO-формат 'YYYY-MM-DD' по номеру дня от 1970-01-01. */
export function epochDayToIso(day: number): string {
return epochDayToDate(day).toISOString().slice(0, 10);
}
/** Номер дня от 1970-01-01 по ISO-дате 'YYYY-MM-DD'. */
export function isoToEpochDay(iso: string): number {
return Math.floor(Date.parse(`${iso}T00:00:00Z`) / DAY_MS);
}
// ---------------------------------------------------------------------------
// Полный таймстамп: миллисекунды от 1970-01-01.
//
// ВАЖНО: единица измерения не зафиксирована в схеме. Здесь приняты
// миллисекунды (нативный формат JS Date). Если десктопная версия хранит
// секунды — поправить достаточно в одном месте (умножение на 1000).
// ---------------------------------------------------------------------------
/** Таймстамп (мс) для даты. */
export function dateToEpochMs(date: Date): number {
return date.getTime();
}
/** Дата по таймстампу (мс). */
export function epochMsToDate(ms: number): Date {
return new Date(ms);
}
/** ISO-строка (UTC) по таймстампу (мс). */
export function epochMsToIso(ms: number): string {
return epochMsToDate(ms).toISOString();
}
/** Таймстамп (мс) по ISO-строке. */
export function isoToEpochMs(iso: string): number {
return Date.parse(iso);
}
+69
View File
@@ -0,0 +1,69 @@
// Типы строк таблиц — 1:1 по dbschema.sql (корень проекта).
// Все даты/таймстампы в БД — INTEGER, отсчёт от 1970-01-01 (unix epoch).
//
// Соглашение о единицах (подтверждено владельцем десктопа):
// - history.timestamp, states.last_opened: UNIX timestamp в СЕКУНДАХ;
// - states.fsrs_opened, states.fsrs_open_day: ДНИ от epoch (целое число суток),
// т.е. количество дней с 1970-01-01, без времени;
// - states.fsrs_memory: сырое INTEGER-значение FSRS (не дата, не трогаем).
export interface CustomField {
id: number;
name: string;
identifier: string;
}
export interface Deck {
id: number;
name: string;
forward: string;
backward: string;
filter: string;
}
export interface Setting {
id: string;
value: string | null;
}
export interface WordGroup {
id: number;
name: string;
}
export interface Word {
id: number;
key: string;
value: string;
tags: string;
more: string | null;
group_id: number;
}
export interface HistoryEntry {
id: number;
deck_id: number;
word_id: number;
/** UNIX timestamp в секундах. */
timestamp: number;
answer: number;
before: number;
after: number;
fsrs: number;
}
export interface WordState {
id: number;
word_id: number;
/** NULL возможен: FK без NOT NULL в схеме. */
deck_id: number | null;
score: number;
/** UNIX timestamp в секундах. */
last_opened: number;
/** Дни от epoch (целые сутки с 1970-01-01). */
fsrs_opened: number;
/** Сырое значение FSRS-памяти, НЕ дата. */
fsrs_memory: number;
/** Дни от epoch (целые сутки с 1970-01-01). */
fsrs_open_day: number;
}
+50
View File
@@ -0,0 +1,50 @@
import sqlite3InitModule, { OpfsDatabase } from '@sqlite.org/sqlite-wasm';
import { DB_FILENAME } from './opfs';
let db: OpfsDatabase | null = null;
self.onmessage = async (e) => {
try {
switch (e.data.type) {
case 'start': {
try {
const sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error, // Сюда будут падать детали ошибок VFS
});
const filename = '/' + DB_FILENAME;
console.log(filename)
db = new sqlite3.oo1.OpfsDb(filename, 'rwt');
db.exec('PRAGMA foreign_keys = ON;');
self.postMessage({ type: 'success' });
} catch (err: any) {
console.error(err);
self.postMessage({ type: 'error', message: err.message });
}
break;
}
case 'query': {
if (db === null) throw new Error('БД не инициализирована');
const result = db.selectObjects(e.data.sql, e.data.params);
self.postMessage({ type: 'success', result });
break;
}
case 'command': {
if (db === null) throw new Error('БД не инициализирована');
db.exec(e.data.sql, e.data.params);
self.postMessage({ type: 'success' });
break;
}
case 'close': {
if (db === null) throw new Error('БД не инициализирована');
db.close();
break;
}
}
} catch (err: any) {
console.error(err);
self.postMessage({ type: 'error', message: err.message });
}
};
+57
View File
@@ -0,0 +1,57 @@
// Главная страница: статус локальной базы + быстрый переход к синхронизации.
// Никакой БД-логики кроме проверки наличия файла в OPFS.
import { worker } from './main.ts';
import { hasDatabaseFile, readDatabaseFile } from './opfs.ts';
import { looksLikeSQLite } from './zstd.ts';
export type HomeNav = {
goSync: () => void;
};
export function renderHomePage(root: HTMLElement, nav: HomeNav): void {
root.innerHTML = `
<main class="card" aria-labelledby="home-title">
<h2 id="home-title" class="card-title">🏠 Главная</h2>
<p class="card-hint">Локальное сохранение и повторения — всё работает офлайн.</p>
<div id="home-db-status" class="status-bar" role="status">Проверка локальной базы…</div>
<div class="btn-row">
<button id="btn-go-sync" class="btn btn-primary" type="button">📡 К синхронизации</button>
</div>
<div id="home-actions" class="btn-row" hidden>
<button id="btn-start" class="btn" type="button">▶ Начать повторение</button>
</div>
</main>
`;
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')!;
goSync.addEventListener('click', () => nav.goSync());
btnStart.addEventListener('click', () => {
statusEl.textContent = 'Экран повторений — следующий шаг.';
});
void (async () => {
const has = await hasDatabaseFile();
if (!has) {
statusEl.textContent = 'Локальной базы нет. Скачай сохранение на странице «Синхронизация».';
actions.hidden = true;
return;
}
try {
worker.postMessage({ type: 'start' });
const raw = await readDatabaseFile();
const ok = looksLikeSQLite(raw);
statusEl.textContent = ok
? `Локальная база на месте: ${(raw.length / 1024).toFixed(1)} КБ, сигнатура SQLite OK.`
: 'Файл в OPFS есть, но это не похоже на SQLite — перекачай сохранение.';
actions.hidden = !ok;
} catch (err) {
statusEl.textContent = `Не удалось прочитать базу: ${err instanceof Error ? err.message : String(err)}`;
actions.hidden = true;
}
})();
}
+83 -29
View File
@@ -1,38 +1,92 @@
import './style.css';
import { renderHomePage } from './home-page.ts';
import { renderSyncPage } from './sync-page.ts';
import * as sqlite from 'wa-sqlite';
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs';
import { SyncPanel } from './sync-panel';
export const worker = new Worker(new URL('./db-worker.ts', import.meta.url), {
type: 'module',
});
// Смоук-тест: запускаем SQLite (сборка wa-sqlite) в памяти.
// Запись и чтение БД идут через OPFS (src/storage.ts); подключение
// SQLite к файлу из OPFS сделаем при работе с самими карточками.
const sqliteStatus = document.querySelector<HTMLParagraphElement>('#sqlite-status');
worker.onmessage = (e) => {
if (e.data.type === 'success') {
const app = document.getElementById('app');
if (app) {
console.log(e.data.rows)
}
} else if (e.data.type === 'error') {
console.error(e.data.message);
}
};
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);
// Каркас приложения: топ-бар + бургер-меню + две страницы (Главная / Синхронизация).
type Page = 'home' | 'sync';
const PAGE_TITLES: Record<Page, string> = {
home: 'Главная',
sync: 'Синхронизация',
};
function requireEl<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id);
if (!el) throw new Error(`[JapLearn] Не найден элемент #${id}`);
return el as T;
}
const burgerBtn = requireEl<HTMLButtonElement>('burger-btn');
const drawer = requireEl<HTMLElement>('drawer');
const overlay = requireEl<HTMLElement>('drawer-overlay');
const pageTitle = requireEl<HTMLElement>('page-title');
const pageRoot = requireEl<HTMLElement>('page');
let drawerOpen = false;
function setDrawer(open: boolean): void {
drawerOpen = open;
drawer.classList.toggle('open', open);
overlay.hidden = !open;
burgerBtn.setAttribute('aria-expanded', String(open));
drawer.setAttribute('aria-hidden', String(!open));
document.body.classList.toggle('drawer-open', open);
}
function setActiveButton(page: Page): void {
drawer.querySelectorAll<HTMLButtonElement>('.drawer-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.page === page);
});
}
function renderPage(page: Page): void {
pageTitle.textContent = PAGE_TITLES[page];
setActiveButton(page);
if (page === 'home') {
renderHomePage(pageRoot, { goSync: () => renderPage('sync') });
} else {
renderSyncPage(pageRoot);
}
}
// Главный экран: токен сохранения + кнопки «Скачать» / «Выгрузить».
// HTTP-обвязка появится на следующем шаге.
const syncPanel = new SyncPanel(document);
syncPanel.init();
burgerBtn.addEventListener('click', () => {
setDrawer(!drawerOpen);
});
void initSqlite();
overlay.addEventListener('click', () => {
setDrawer(false);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && drawerOpen) setDrawer(false);
});
drawer.querySelectorAll<HTMLButtonElement>('.drawer-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const page = btn.dataset.page as Page | undefined;
if (page === 'home' || page === 'sync') renderPage(page);
setDrawer(false);
});
});
// Старт: главная страница, меню закрыто
setDrawer(false);
renderPage('home');
console.log('[JapLearn] crossOriginIsolated =', window.crossOriginIsolated);
-34
View File
@@ -1,34 +0,0 @@
// Минимальные типы 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;
}
+83
View File
@@ -0,0 +1,83 @@
// OPFS-хранилище сырой .sqlite.
// Никакого sqlite-wasm здесь пока нет — просто кладём бинарный файл,
// чтобы следующие шаги (открытие БД, выгрузка) работали с одним источником правды.
/** Имя файла БД в OPFS. Совпадает с десктопом: один и тот же бинарный .sqlite. */
export const DB_FILENAME = 'japanese_app.db';
async function opfsRoot(): Promise<FileSystemDirectoryHandle> {
return navigator.storage.getDirectory();
}
/**
* Сохраняет сырые байты .sqlite в OPFS (перезаписывает файл целиком).
* Пишем через SyncAccessHandle, если он доступен, иначе — через WritableStream.
*/
export async function writeDatabaseFile(data: Uint8Array): Promise<void> {
data[18] = 1;
data[19] = 1;
const root = await opfsRoot();
const handle = await root.getFileHandle(DB_FILENAME, { create: true });
// SyncAccessHandle доступен только в воркерах — пробуем, в main-потоке упадёт.
const maybeSync = (handle as unknown as {
createSyncAccessHandle?: () => Promise<FileSystemSyncAccessHandle>;
}).createSyncAccessHandle;
if (typeof maybeSync === 'function') {
try {
const access = await maybeSync.call(handle);
try {
access.truncate(0);
access.write(data, { at: 0 });
access.flush();
} finally {
access.close();
}
return;
} catch {
// Падаем ниже на WritableStream — обычный путь для main-потока
}
}
const writable = await handle.createWritable();
try {
await writable.truncate(0);
// Копия: избегаем проблем с SharedArrayBuffer/neutered буферами
const copy = new Uint8Array(data.length);
copy.set(data);
await writable.write(copy);
} finally {
await writable.close();
}
}
/** Читает сырой .sqlite из OPFS. Бросает Error, если файла нет. */
export async function readDatabaseFile(): Promise<Uint8Array> {
const root = await opfsRoot();
let handle: FileSystemFileHandle;
try {
handle = await root.getFileHandle(DB_FILENAME);
} catch {
throw new Error('Локальная база не найдена — сначала скачай сохранение');
}
const file = await handle.getFile();
if (file.size === 0) throw new Error('Локальная база пуста');
let data = new Uint8Array(await file.arrayBuffer());
return data;
}
/** Проверяет, есть ли непустой файл БД в OPFS. */
export async function hasDatabaseFile(): Promise<boolean> {
try {
const root = await opfsRoot();
console.log("in root dir " + root.entries.length + " files");
const handle = await root.getFileHandle(DB_FILENAME);
const file = await handle.getFile();
console.log(file.size);
console.log(file.name);
console.log(handle);
return file.size > 0;
} catch {
return false;
}
}
-24
View File
@@ -1,24 +0,0 @@
// Небольшие пользовательские предпочтения, переживающие перезагрузку.
// Храним ТОЛЬКО состояние интерфейса (токен для авто-заполнения поля),
// а не данные приложения. Сама база данных живёт в 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);
}
-86
View File
@@ -1,86 +0,0 @@
-- Схема базы данных JapLearn.
-- Источник истины: /dbschema.sql (корень проекта).
-- Здесь та же схема, но идемпотентная (CREATE TABLE IF NOT EXISTS),
-- чтобы выполнять её при инициализации БД без ошибок на уже готовой базе.
-- Все даты и таймстампы — числа. Поля-«даты» (fsrs_opened, fsrs_open_day)
-- хранят число дней от 1970-01-01 (UTC), остальные — таймстампы.
create table if not exists custom_fields
(
id integer
primary key,
name text not null,
identifier text not null
);
create table if not exists decks
(
id INTEGER
primary key autoincrement,
name TEXT not null,
forward TEXT not null,
backward TEXT not null,
filter TEXT not null
);
create table if not exists settings
(
id text
primary key,
value text
);
create table if not exists word_groups
(
id INTEGER
primary key autoincrement,
name TEXT not null
);
create table if not exists words
(
id INTEGER
primary key autoincrement,
key TEXT not null,
value TEXT not null,
tags TEXT not null,
more TEXT,
group_id integer default 1 not null
constraint words_word_group_id_fk
references word_groups
on update cascade on delete cascade
);
create table if not exists history
(
id INTEGER
primary key autoincrement,
deck_id INTEGER not null
references decks
on delete cascade,
word_id INTEGER not null
references words
on delete cascade,
timestamp INTEGER not null,
answer INTEGER not null,
before INTEGER not null,
after INTEGER not null,
fsrs INTEGER not null
);
create table if not exists states
(
id INTEGER
primary key autoincrement,
word_id INTEGER not null
references words
on delete cascade,
deck_id INTEGER
references decks
on delete cascade,
score INTEGER default 1 not null,
last_opened integer not null,
fsrs_opened integer not null,
fsrs_memory integer not null,
fsrs_open_day integer not null
);
-68
View File
@@ -1,68 +0,0 @@
// Хранение бинарного файла базы данных в 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 };
}
+222 -109
View File
@@ -1,27 +1,10 @@
/* JapLearn — мобильный PWA, интервальные повторения (Vanilla + Vite) */
:root {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
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;
}
color: #1e1b2e;
background: #f4f1fa;
}
* {
@@ -30,141 +13,271 @@
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
color: var(--text);
justify-content: center;
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
#app {
width: 100%;
max-width: 480px;
padding: 24px 16px;
padding: 24px 16px 32px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Шапка */
.app-header h1 {
margin: 0;
font-size: 28px;
font-size: 1.75rem;
letter-spacing: 0.02em;
}
.subtitle {
.app-subtitle {
margin: 4px 0 0;
color: var(--muted);
color: #6d6390;
}
/* Карточка сохранения */
.card {
margin-top: 24px;
padding: 20px;
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--surface-shadow);
/* --- Топ-бар + бургер --- */
.top-bar {
display: flex;
align-items: center;
gap: 12px;
}
.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);
.burger-btn {
width: 44px;
height: 44px;
border: 2px solid #d9d2f0;
border-radius: 12px;
background: transparent;
color: var(--text);
background: #fff;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
touch-action: manipulation;
flex: none;
}
#save-token:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
border-color: var(--accent);
.burger-btn span {
display: block;
width: 20px;
height: 2px;
border-radius: 2px;
background: #4c446b;
}
#save-token[aria-invalid='true'] {
border-color: var(--danger);
.burger-btn[aria-expanded='true'] {
border-color: #7c3aed;
}
.hint {
margin: 6px 0 0;
font-size: 13px;
color: var(--muted);
.top-bar-title h1 {
margin: 0;
font-size: 1.4rem;
line-height: 1.2;
}
.error {
margin: 6px 0 0;
font-size: 13px;
color: var(--danger);
.top-bar-title p {
margin: 0;
font-size: 0.9rem;
color: #6d6390;
}
.error[hidden] {
.top-bar-spacer {
flex: 1;
}
.app-content {
display: flex;
flex-direction: column;
gap: 16px;
}
/* --- Выдвижное меню --- */
.drawer-overlay {
position: fixed;
inset: 0;
background: rgb(30 27 46 / 0.45);
z-index: 40;
}
.drawer-overlay[hidden] {
display: none;
}
/* Кнопки */
.actions {
.drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(300px, 85vw);
background: #fff;
z-index: 50;
transform: translateX(-105%);
transition: transform 0.22s ease;
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px 14px;
box-shadow: 8px 0 32px rgb(30 27 46 / 0.18);
}
.drawer.open {
transform: translateX(0);
}
.drawer-header {
padding: 4px 8px 14px;
border-bottom: 1px solid #eee9fb;
margin-bottom: 8px;
}
.drawer-logo {
font-size: 1.3rem;
font-weight: 800;
color: #7c3aed;
}
.drawer-subtitle {
font-size: 0.9rem;
color: #6d6390;
}
.drawer-btn {
border: none;
background: transparent;
text-align: left;
font-size: 1.05rem;
font-weight: 600;
color: #1e1b2e;
padding: 13px 12px;
border-radius: 12px;
cursor: pointer;
touch-action: manipulation;
}
.drawer-btn.active {
background: #f1eafe;
color: #6d28d9;
}
.drawer-btn:active {
background: #e9defc;
}
body.drawer-open {
overflow: hidden;
}
.card {
background: #fff;
border-radius: 16px;
padding: 20px 16px;
box-shadow: 0 8px 24px rgb(124 58 237 / 0.12);
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.card-title {
margin: 0;
font-size: 1.25rem;
}
.card-hint {
margin: 0;
color: #6d6390;
font-size: 0.95rem;
}
.field-label {
font-weight: 600;
font-size: 0.95rem;
}
.token-input {
width: 100%;
padding: 14px 12px;
font-size: 1.05rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.04em;
border: 2px solid #d9d2f0;
border-radius: 12px;
outline: none;
}
.token-input:focus {
border-color: #7c3aed;
}
.token-input.invalid {
border-color: #dc2626;
background: #fef2f2;
}
.token-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
min-height: 1.4em;
}
.token-error {
color: #dc2626;
font-size: 0.9rem;
}
.token-counter {
color: #6d6390;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.btn-row {
display: flex;
gap: 12px;
}
.btn {
min-height: 48px; /* комфортная область касания */
padding: 12px 16px;
font-size: 16px;
flex: 1;
padding: 14px 12px;
font-size: 1rem;
font-weight: 600;
border-radius: 12px;
border: 2px solid #7c3aed;
background: #fff;
color: #7c3aed;
cursor: pointer;
touch-action: manipulation;
}
.btn-primary {
border: none;
background: var(--accent);
color: #ffffff;
}
.btn-secondary {
border: 1.5px solid var(--accent);
background: transparent;
color: var(--accent);
background: #7c3aed;
color: #fff;
}
.btn:disabled {
opacity: 0.45;
opacity: 0.4;
cursor: not-allowed;
}
/* Статусы и подвал */
.status {
margin: 12px 0 0;
min-height: 1.5em;
font-size: 14px;
color: var(--muted);
.btn:not(:disabled):active {
transform: scale(0.98);
}
.footer-status {
margin-top: 24px;
font-size: 13px;
text-align: center;
color: var(--muted);
}
.status-bar {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
background: #f4f1fa;
color: #4c446b;
font-size: 0.95rem;
word-break: break-word;
}
+166
View File
@@ -0,0 +1,166 @@
// Страница «Синхронизация»: токен + Скачать/Выгрузить.
// Скачивание: GET /download/{id} -> zstd -> проверка SQLite -> запись в OPFS.
// Выгрузка: следующий шаг (POST /upload/stream/{id}).
import {
TOKEN_LENGTH,
isValidToken,
loadSavedToken,
normalizeToken,
saveToken,
} from './token.ts';
import { fetchBackupBytes } from './sync.ts';
import { decompressBackup } from './zstd.ts';
import { hasDatabaseFile, writeDatabaseFile } from './opfs.ts';
export function renderSyncPage(root: HTMLElement): void {
root.innerHTML = `
<main class="card" aria-labelledby="sync-title">
<h2 id="sync-title" class="card-title">📡 Синхронизация</h2>
<p class="card-hint">Токен сохранения — 24 символа: латинские буквы и цифры.</p>
<label class="field-label" for="token-input">Токен сохранения</label>
<input
id="token-input"
class="token-input"
type="text"
inputmode="latin"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
maxlength="24"
minlength="24"
placeholder="Например: aB3dEf7hJk9mN2pQr5tUvWx1z"
aria-describedby="token-error token-counter"
/>
<div class="token-meta">
<span id="token-error" class="token-error" role="alert"></span>
<span id="token-counter" class="token-counter" aria-live="polite">0 / 24</span>
</div>
<div class="btn-row">
<button id="btn-download" class="btn" type="button" disabled>📥 Скачать</button>
<button id="btn-upload" class="btn btn-primary" type="button" disabled>📤 Выгрузить</button>
</div>
<p id="status" class="status-bar" role="status">Введите токен из 24 символов</p>
</main>
`;
const tokenInput = root.querySelector<HTMLInputElement>('#token-input')!;
const tokenError = root.querySelector<HTMLElement>('#token-error')!;
const tokenCounter = root.querySelector<HTMLElement>('#token-counter')!;
const btnDownload = root.querySelector<HTMLButtonElement>('#btn-download')!;
const btnUpload = root.querySelector<HTMLButtonElement>('#btn-upload')!;
const statusEl = root.querySelector<HTMLElement>('#status')!;
function setStatus(msg: string): void {
statusEl.textContent = msg;
console.log(`[JapLearn] ${msg}`);
}
function currentToken(): string {
return normalizeToken(tokenInput.value);
}
function setBusy(busy: boolean): void {
btnDownload.disabled = busy || !isValidToken(currentToken());
btnUpload.disabled = true;
tokenInput.disabled = busy;
if (!busy) {
void hasDatabaseFile().then((has) => {
if (!tokenInput.disabled && isValidToken(currentToken())) {
btnUpload.disabled = !has;
}
});
}
}
function refreshUI(): void {
const token = currentToken();
const len = token.length;
tokenCounter.textContent = `${len} / ${TOKEN_LENGTH}`;
const ok = isValidToken(token);
const empty = len === 0;
tokenInput.classList.toggle('invalid', !empty && !ok);
tokenInput.setAttribute('aria-invalid', String(!empty && !ok));
if (empty) {
tokenError.textContent = '';
} else if (len !== TOKEN_LENGTH) {
tokenError.textContent = `Нужно 24 символа, сейчас ${len}`;
} else {
tokenError.textContent = ok ? '' : 'Только латинские буквы и цифры';
}
btnDownload.disabled = !ok;
if (ok) {
void hasDatabaseFile().then((has) => {
if (!tokenInput.disabled) btnUpload.disabled = !has;
});
} else {
btnUpload.disabled = true;
}
}
async function handleDownload(): Promise<void> {
const token = currentToken();
if (!isValidToken(token)) return;
saveToken(token);
setBusy(true);
setStatus('Скачивание сохранения...');
try {
const compressed = await fetchBackupBytes(token);
setStatus(`Скачано ${(compressed.length / 1024).toFixed(1)} КБ, распаковка...`);
const raw = await decompressBackup(compressed);
setStatus(`Распаковано ${(raw.length / 1024).toFixed(1)} КБ, сохранение в OPFS...`);
await writeDatabaseFile(raw);
setStatus(`Готово: база ${(raw.length / 1024).toFixed(1)} КБ сохранена локально (OPFS).`);
} catch (err) {
setStatus(`Ошибка скачивания: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setBusy(false);
refreshUI();
}
}
tokenInput.addEventListener('input', () => {
refreshUI();
});
tokenInput.addEventListener('change', () => {
const token = currentToken();
tokenInput.value = token;
if (isValidToken(token)) {
saveToken(token);
setStatus('Токен сохранён локально. Можно скачивать или выгружать.');
}
refreshUI();
});
btnDownload.addEventListener('click', () => {
void handleDownload();
});
btnUpload.addEventListener('click', () => {
const token = currentToken();
if (!isValidToken(token)) return;
saveToken(token);
setStatus('Выгрузка — следующий шаг (POST /upload/stream/{id}).');
});
const saved = loadSavedToken();
if (saved) {
tokenInput.value = saved;
}
refreshUI();
if (isValidToken(normalizeToken(saved))) {
void hasDatabaseFile().then((has) => {
setStatus(
has
? 'Токен восстановлен, локальная база на месте.'
: 'Токен восстановлен. Нажми «Скачать», чтобы получить базу.',
);
refreshUI();
});
}
}
-136
View File
@@ -1,136 +0,0 @@
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}» — будет реализована на следующем шаге.`;
}
}
+32 -70
View File
@@ -1,80 +1,42 @@
import { Zstd } from '@hpcc-js/wasm-zstd';
import { downloadUrl } from './config';
// Сеть: скачивание сохранения с бэкенда.
// GET https://learning.micialware.ru/download/{id} -> zstd-сжатый .sqlite (бинарь).
// Выгрузка (POST /upload/stream/{id}) — в следующем шаге.
// Магический заголовок файла 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,
]);
export const API_BASE_URL = 'https://learning.micialware.ru';
// Магическое число кадра 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)
);
/** Канонический URL скачивания для токена. id идёт в path, кодируем на всякий случай. */
export function downloadUrl(token: string, baseUrl: string = API_BASE_URL): string {
const base = baseUrl.replace(/\/+$/, '');
return `${base}/download/${encodeURIComponent(token)}`;
}
/**
* Скачивает сохранение по токену и распаковывает zstd-сжатие
* (бинарный файл базы данных SQLite).
* @param token Токен сохранения (24 латинские буквы).
* Скачивает сырые (ещё сжатые zstd) байты сохранения.
* Бросает Error с понятным текстом при HTTP-ошибке или обрыве сети.
*/
export async function downloadSave(token: string): Promise<DownloadedSave> {
const compressed = await downloadRaw(downloadUrl(token));
if (!isZstdFrame(compressed)) {
throw new Error(
'ответ сервера не похож на zstd-сжатый файл — возможно, токен неверный или сохранение не существует',
);
export async function fetchBackupBytes(
token: string,
init?: RequestInit,
baseUrl: string = API_BASE_URL,
): Promise<Uint8Array> {
const url = downloadUrl(token, baseUrl);
let response: Response;
try {
response = await fetch(url, { method: 'GET', ...init });
} catch (err) {
throw new Error(`Нет связи с сервером: ${err instanceof Error ? err.message : String(err)}`);
}
const codec = await getZstd();
const database = codec.decompress(compressed);
if (!isSqliteDatabase(database)) {
throw new Error(
'распакованные данные не похожи на базу данных SQLite (магический заголовок не совпал)',
);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Сохранение не найдено (404). Проверь токен.');
}
throw new Error(`Сервер ответил ${response.status} ${response.statusText}`);
}
return { compressedBytes: compressed.length, database };
}
const buf = await response.arrayBuffer();
if (buf.byteLength === 0) {
throw new Error('Сервер вернул пустой файл');
}
return new Uint8Array(buf);
}
+62
View File
@@ -0,0 +1,62 @@
// Работа с числовыми датами из БД (unix epoch, отсчёт от 1970-01-01).
//
// Единицы (подтверждены владельцем десктопа):
// - timestamp / last_opened: UNIX timestamp в СЕКУНДАХ;
// - fsrs_opened / fsrs_open_day: ДНИ от epoch (целое число суток с 1970-01-01).
const DAY_MS = 24 * 60 * 60 * 1000;
const DAY_SEC = 24 * 60 * 60;
/** Date из UNIX timestamp в секундах (history.timestamp, states.last_opened). */
export function fromDbTime(sec: number): Date {
return new Date(sec * 1000);
}
/** Date из FSRS-дней (states.fsrs_opened, states.fsrs_open_day). */
export function fromDbDays(days: number): Date {
return new Date(days * DAY_MS);
}
/** Текущее unix-время в секундах (для записи last_opened, history.timestamp). */
export function nowSec(): number {
return Math.floor(Date.now() / 1000);
}
/** Текущий день FSRS: целые сутки с 1970-01-01 (для fsrs_opened / fsrs_open_day). */
export function todayDbDays(d: Date = new Date()): number {
return Math.floor(d.getTime() / DAY_MS);
}
/** Сколько целых суток между двумя UNIX timestamp (секунды). */
export function diffDaysSec(aSec: number, bSec: number): number {
return Math.floor((aSec - bSec) / DAY_SEC);
}
/** Сколько дней между двумя FSRS-днями (обычная разность целых). */
export function diffDbDays(aDays: number, bDays: number): number {
return aDays - bDays;
}
/** Короткая подпись даты из секунд: ДД.ММ.ГГГГ. */
export function formatDbDate(sec: number): string {
const d = fromDbTime(sec);
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
return `${dd}.${mm}.${d.getFullYear()}`;
}
/** Короткая подпись FSRS-дня: ДД.ММ.ГГГГ. */
export function formatDbDays(days: number): string {
const d = fromDbDays(days);
const dd = String(d.getUTCDate()).padStart(2, '0');
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
return `${dd}.${mm}.${d.getUTCFullYear()}`;
}
/** Подпись даты+времени из секунд: ДД.ММ.ГГГГ ЧЧ:ММ. */
export function formatDbDateTime(sec: number): string {
const d = fromDbTime(sec);
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${formatDbDate(sec)} ${hh}:${mi}`;
}
+33 -12
View File
@@ -1,20 +1,41 @@
// Токен сохранения. Это НЕ JWT и не что-то подобное: последовательность
// из ровно 24 символов — буквы латинского алфавита (a–z, A–Z) и цифры (0–9),
// по которой бэкенд отдаёт/принимает бинарный файл базы данных.
// Токен сохранения: ровно 24 символа, только латиница и цифры.
// Это НЕ JWT — просто короткий идентификатор для download/upload .sqlite.
export const TOKEN_LENGTH = 24;
const TOKEN_RE = /^[A-Za-z0-9]{24}$/;
/**
* Приводит сырой ввод пользователя к каноническому виду токена:
* обрезает пробелы и приводит к верхнему регистру.
*/
const STORAGE_KEY = 'japlearn_sync_token';
/** Нормализация: trim + убрать внутренние пробелы/переносы (из буфера обмена). */
export function normalizeToken(raw: string): string {
return raw.trim().toUpperCase();
return raw.trim().replace(/\s+/g, '');
}
/** Валиден ли токен: ровно 24 латинские буквы или цифры. */
export function isValidToken(raw: string): boolean {
return TOKEN_RE.test(normalizeToken(raw));
}
export function isValidToken(token: string): boolean {
return TOKEN_RE.test(token);
}
export function loadSavedToken(): string {
try {
return localStorage.getItem(STORAGE_KEY) ?? '';
} catch {
return '';
}
}
export function saveToken(token: string): void {
try {
localStorage.setItem(STORAGE_KEY, token);
} catch {
// private mode и т.п. — молча игнорируем, поле всё равно работает
}
}
export function clearSavedToken(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-11
View File
@@ -1,11 +0,0 @@
// Типы для 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;
}
+70
View File
@@ -0,0 +1,70 @@
// Распаковка zstd через @hpcc-js/wasm-zstd.
// Сервер отдаёт .sqlite, сжатый zstd одним кадром; жмём одной операцией decompress().
import { Zstd } from '@hpcc-js/wasm-zstd';
type ZstdInstance = Awaited<ReturnType<typeof Zstd.load>>;
let zstdPromise: Promise<ZstdInstance> | null = null;
function getZstd(): Promise<ZstdInstance> {
if (!zstdPromise) zstdPromise = Zstd.load();
return zstdPromise;
}
/** Магические байты zstd-кадра: 0x28 0xB5 0x2F 0xFD (little-endian). */
export function looksLikeZstd(data: Uint8Array): boolean {
return (
data.length >= 4 &&
data[0] === 0x28 &&
data[1] === 0xb5 &&
data[2] === 0x2f &&
data[3] === 0xfd
);
}
/** Магия SQLite: файл начинается с "SQLite format 3\0". */
export function looksLikeSQLite(data: Uint8Array): boolean {
const magic = 'SQLite format 3\0';
if (data.length < magic.length) return false;
for (let i = 0; i < magic.length; i++) {
if (data[i] !== magic.charCodeAt(i)) return false;
}
return true;
}
/**
* Распаковывает zstd-байты в сырой .sqlite.
* Если вход уже похож на SQLite (сервер отдал без сжатия) — возвращаем как есть.
*/
export async function decompressBackup(data: Uint8Array): Promise<Uint8Array> {
if (data.length === 0) throw new Error('Пустой файл сохранения');
if (looksLikeSQLite(data)) return data;
if (!looksLikeZstd(data)) {
throw new Error('Файл не похож на zstd-сжатое сохранение (нет magic 28 B5 2F FD)');
}
const zstd = await getZstd();
let out: Uint8Array;
try {
out = zstd.decompress(data);
} catch (err) {
throw new Error(`Не удалось распаковать zstd: ${err instanceof Error ? err.message : String(err)}`);
}
if (!looksLikeSQLite(out)) {
throw new Error('Распакованный файл не похож на SQLite-базу');
}
return out;
}
/**
* Копирует Uint8Array в "чистый" ArrayBuffer ровно нужной длины.
* Нужно перед записью в OPFS: у decompressed.buffer может быть лишний capacity.
*/
export function toExactBuffer(data: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(data.length);
copy.set(data);
return copy.buffer;
}