UNPKG

@bitblit/asyncglk

Version:
216 lines (212 loc) 8 kB
/* Web storage provider ==================== Copyright (c) 2024 Dannii Willis MIT licenced https://github.com/curiousdannii/asyncglk */ import { decode as base32768_decode, encode as base32768_encode } from 'base32768'; import { NullProvider, show_alert } from './common.js'; //type WebStorageFileMetadata = Pick<FileData, 'atime' | 'mtime'> const METADATA_KEY = 'dialog_metadata'; const STORAGE_VERSION_KEY = 'dialog_storage_version'; var MetadataUpdateOperation; (function (MetadataUpdateOperation) { MetadataUpdateOperation[MetadataUpdateOperation["DELETE"] = 1] = "DELETE"; MetadataUpdateOperation[MetadataUpdateOperation["READ"] = 2] = "READ"; MetadataUpdateOperation[MetadataUpdateOperation["RENAME"] = 3] = "RENAME"; MetadataUpdateOperation[MetadataUpdateOperation["WRITE"] = 4] = "WRITE"; })(MetadataUpdateOperation || (MetadataUpdateOperation = {})); export class WebStorageProvider { browseable; dirs; _metadata = {}; next = new NullProvider(); prefix; store; /** Whether or not we are doing multiple operations in one transaction */ transaction = false; constructor(prefix, store, dirs, browseable) { this.browseable = browseable ?? false; this.dirs = dirs; this.prefix = prefix; this.store = store; if (store === localStorage) { migrate_localStorage(); } } async delete(path) { if (path.startsWith(this.prefix)) { this.store.removeItem(path); await this.update_metadata(path, MetadataUpdateOperation.DELETE); } else { return this.next.delete(path); } } async exists(path) { if (path.startsWith(this.prefix)) { return this.store.getItem(path) !== null; } else { return this.next.exists(path); } } async metadata() { this._metadata = JSON.parse(this.store.getItem(METADATA_KEY) || '{}'); // Add a fake .dir file to the working folder this._metadata[this.dirs.working + '/.dir'] = { atime: 0, mtime: 0 }; return this._metadata; } async read(path) { if (path.startsWith(this.prefix)) { const res = this.store.getItem(path); if (res !== null) { await this.update_metadata(path, MetadataUpdateOperation.READ); return base32768_decode(res); } return null; } else { return this.next.read(path); } } async rename(dir, old_path, new_path) { await this.transaction_start(); if (dir) { for (const path of Object.keys(this._metadata)) { if (path.startsWith(old_path)) { const new_file_path = path.replace(old_path, new_path); await this.rename_file(path, new_file_path); } } } else { await this.rename_file(old_path, new_path); } this.transaction_end(); } async rename_file(old_path, new_path) { const data = await this.read(old_path); await this.write({ new_path: data }); await this.update_metadata(new_path, MetadataUpdateOperation.RENAME, old_path); await this.delete(old_path); } async transaction_start() { const old_transaction = this.transaction; this.transaction = true; if (!old_transaction) { await this.metadata(); } return old_transaction; } transaction_end() { this.transaction = false; // Remove the fake .dir file from the working folder delete this._metadata[this.dirs.working + '/.dir']; this.store.setItem(METADATA_KEY, JSON.stringify(this._metadata)); } async update_metadata(path, op, old_path) { const now = Date.now(); if (!this.transaction) { await this.metadata(); } switch (op) { case MetadataUpdateOperation.DELETE: delete this._metadata[path]; break; case MetadataUpdateOperation.READ: this._metadata[path].atime = now; break; case MetadataUpdateOperation.RENAME: this._metadata[path] = this._metadata[old_path]; delete this._metadata[old_path]; break; case MetadataUpdateOperation.WRITE: if (!this._metadata[path]) { this._metadata[path] = { atime: now, mtime: now, }; } this._metadata[path].mtime = now; } if (!this.transaction) { // Write the metadata via transaction_end this.transaction_end(); } } async write(files) { const was_already_in_transaction = await this.transaction_start(); let wrote_files = false, next_files = false; for (const [path, data] of Object.entries(files)) { if (path.startsWith(this.prefix)) { wrote_files = true; try { this.store.setItem(path, base32768_encode(data)); this.update_metadata(path, MetadataUpdateOperation.WRITE); } catch { // TODO: remove autosaves first show_alert('localStorage full', `The file ${path.replace(/\//g, '/\u200b')} could not be written as your browser's localStorage is full! Please delete some files, and then try again.`); } delete files[path]; } else { next_files = true; } } if (!was_already_in_transaction) { this.transaction = false; if (wrote_files) { this.transaction_end(); } } if (next_files) { this.next.write(files); } } } const DIALOG_V1_TYPES_TO_EXTS = { data: 'glkdata', save: 'glksave', transcript: 'txt', }; export function migrate_localStorage() { const now = Date.now(); const version = parseInt(localStorage.getItem(STORAGE_VERSION_KEY) || '0', 10); if (version < 2) { console.log('Dialog: updating localStorage to version 2'); const metadata = {}; for (let [key, data] of Object.entries(localStorage)) { if (key.startsWith('autosave:')) { // We're not keeping any old autosaves localStorage.removeItem(key); } if (key.startsWith('content:')) { const key_data = /^content:(\w+):(\w*):(.+)$/.exec(key); if (key_data) { const path = `/usr/${key_data[3]}.${DIALOG_V1_TYPES_TO_EXTS[key_data[1]] || key_data[1]}`; if (data !== '' && version < 1) { data = base32768_encode(/\[[,\d]*\]/.test(data) ? JSON.parse(data) : Uint8Array.from(data, ch => ch.charCodeAt(0))); } localStorage.setItem(path, data); const dirent_key = 'dirent' + key.substring(7); const dirent = localStorage.getItem(dirent_key) || ''; const dirent_data = /^created:\d+,modified:(\d+)$/.exec(dirent); metadata[path] = { atime: parseInt(dirent_data?.[1] || '', 10) || now, mtime: parseInt(dirent_data?.[1] || '', 10) || now, }; localStorage.removeItem(dirent_key); } localStorage.removeItem(key); } } localStorage.setItem(METADATA_KEY, JSON.stringify(metadata)); localStorage.setItem(STORAGE_VERSION_KEY, '2'); } if (version > 2) { throw new Error('dialog_storage_version is newer than this library supports'); } }