database reading
This commit is contained in:
1 parent
68c24dcec5
commit
fc8455e353
418 files changed
+674410
-1561
No files matched your search
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user