feat: First iteration, multi themes, I18N
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
// Persistence in browser cookies.
|
||||
//
|
||||
// A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks:
|
||||
// ic_save_<theme>_n -> number of chunks
|
||||
// ic_save_<theme>_0.. -> the chunks
|
||||
// Each theme has its own save slot. Preferences (theme, language) are stored
|
||||
// in a single small cookie.
|
||||
|
||||
import { readCookie, removeCookie, writeCookie } from './cookies.js';
|
||||
|
||||
const CHUNK_SIZE = 3000;
|
||||
const MAX_CHUNKS = 6;
|
||||
const PREFS_KEY = 'ic_prefs';
|
||||
const saveKey = (themeId) => `ic_save_${themeId}`;
|
||||
|
||||
// ------------------------------------------------------------------ encoding
|
||||
|
||||
export function encode(text) {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = '';
|
||||
for (const b of bytes) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function decode(encoded) {
|
||||
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
|
||||
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
|
||||
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- chunked
|
||||
|
||||
function writeChunked(key, text) {
|
||||
const encoded = encode(text);
|
||||
const chunks = [];
|
||||
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) chunks.push(encoded.slice(i, i + CHUNK_SIZE));
|
||||
if (chunks.length > MAX_CHUNKS) throw new Error('save_too_large');
|
||||
|
||||
const previous = Number(readCookie(`${key}_n`)) || 0;
|
||||
chunks.forEach((chunk, i) => writeCookie(`${key}_${i}`, chunk));
|
||||
writeCookie(`${key}_n`, String(chunks.length));
|
||||
for (let i = chunks.length; i < previous; i++) removeCookie(`${key}_${i}`);
|
||||
}
|
||||
|
||||
function readChunked(key) {
|
||||
const count = Number(readCookie(`${key}_n`));
|
||||
if (!Number.isInteger(count) || count < 1 || count > MAX_CHUNKS) return null;
|
||||
let encoded = '';
|
||||
for (let i = 0; i < count; i++) {
|
||||
const chunk = readCookie(`${key}_${i}`);
|
||||
if (chunk == null) return null;
|
||||
encoded += chunk;
|
||||
}
|
||||
try {
|
||||
return decode(encoded);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeChunked(key) {
|
||||
const count = Number(readCookie(`${key}_n`)) || 0;
|
||||
for (let i = 0; i < count; i++) removeCookie(`${key}_${i}`);
|
||||
removeCookie(`${key}_n`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- public
|
||||
|
||||
/** False when the browser refuses cookies (private mode, blocked, ...). */
|
||||
export function cookiesAvailable() {
|
||||
try {
|
||||
writeCookie('ic_probe', '1');
|
||||
const ok = readCookie('ic_probe') === '1';
|
||||
removeCookie('ic_probe');
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Throws if the save does not fit in cookies. */
|
||||
export function saveGameText(themeId, text) {
|
||||
writeChunked(saveKey(themeId), text);
|
||||
}
|
||||
|
||||
export function loadGameText(themeId) {
|
||||
return readChunked(saveKey(themeId));
|
||||
}
|
||||
|
||||
export function deleteGame(themeId) {
|
||||
removeChunked(saveKey(themeId));
|
||||
}
|
||||
|
||||
export function savePrefs(prefs) {
|
||||
writeCookie(PREFS_KEY, encode(JSON.stringify(prefs)));
|
||||
}
|
||||
|
||||
export function loadPrefs() {
|
||||
try {
|
||||
const raw = readCookie(PREFS_KEY);
|
||||
const prefs = raw && JSON.parse(decode(raw));
|
||||
return prefs && typeof prefs === 'object' ? prefs : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user