first commit
This commit is contained in:
commit
4646dad2b8
18 files changed
+1971
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user