diff --git a/src/domain/BrawlViewModel.js b/src/domain/BrawlViewModel.js index d24c4b3d..c9ebfa91 100644 --- a/src/domain/BrawlViewModel.js +++ b/src/domain/BrawlViewModel.js @@ -5,6 +5,10 @@ import LoginViewModel from "./LoginViewModel.js"; import SessionPickerViewModel from "./SessionPickerViewModel.js"; import EventEmitter from "../EventEmitter.js"; +export function createNewSessionId() { + return (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); +} + export default class BrawlViewModel extends EventEmitter { constructor({storageFactory, sessionStore, createHsApi, clock}) { super(); @@ -93,7 +97,7 @@ export default class BrawlViewModel extends EventEmitter { async _onLoginFinished(loginData) { if (loginData) { // TODO: extract random() as it is a source of non-determinism - const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); + const sessionId = createNewSessionId(); const sessionInfo = { id: sessionId, deviceId: loginData.device_id, diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js index 2347dfe4..97efdcf3 100644 --- a/src/domain/SessionPickerViewModel.js +++ b/src/domain/SessionPickerViewModel.js @@ -1,5 +1,6 @@ import {SortedArray} from "../observable/index.js"; import EventEmitter from "../EventEmitter.js"; +import {createNewSessionId} from "./BrawlViewModel.js" class SessionItemViewModel extends EventEmitter { constructor(sessionInfo, pickerVM) { @@ -9,7 +10,7 @@ class SessionItemViewModel extends EventEmitter { this._isDeleting = false; this._isClearing = false; this._error = null; - this._showJSON = false; + this._exportDataUrl = null; } get error() { @@ -33,7 +34,6 @@ class SessionItemViewModel extends EventEmitter { async clear() { this._isClearing = true; - this._showJSON = true; this.emit("change"); try { await this._pickerVM.clear(this.id); @@ -59,19 +59,42 @@ class SessionItemViewModel extends EventEmitter { return this._sessionInfo.id; } - get userId() { - return this._sessionInfo.userId; + get label() { + const {userId, comment} = this._sessionInfo; + if (comment) { + return `${userId} (${comment})`; + } else { + return userId; + } } get sessionInfo() { return this._sessionInfo; } - get json() { - if (this._showJSON) { - return JSON.stringify(this._sessionInfo); + get exportDataUrl() { + return this._exportDataUrl; + } + + async export() { + try { + const data = await this._pickerVM._exportData(this._sessionInfo.id); + const json = JSON.stringify(data, undefined, 2); + const blob = new Blob([json], {type: "application/json"}); + this._exportDataUrl = URL.createObjectURL(blob); + this.emit("change", "exportDataUrl"); + } catch (err) { + alert(err.message); + console.error(err); + } + } + + clearExport() { + if (this._exportDataUrl) { + URL.revokeObjectURL(this._exportDataUrl); + this._exportDataUrl = null; + this.emit("change", "exportDataUrl"); } - return null; } } @@ -95,11 +118,19 @@ export default class SessionPickerViewModel { } } + async _exportData(id) { + const sessionInfo = await this._sessionStore.get(id); + const stores = await this._storageFactory.export(id); + const data = {sessionInfo, stores}; + return data; + } + async import(json) { - const sessionInfo = JSON.parse(json); - const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); - sessionInfo.id = sessionId; - sessionInfo.lastUsed = sessionId; + const data = JSON.parse(json); + const {sessionInfo} = data; + sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`; + sessionInfo.id = createNewSessionId(); + await this._storageFactory.import(sessionInfo.id, data.stores); await this._sessionStore.add(sessionInfo); this._sessions.set(new SessionItemViewModel(sessionInfo, this)); } diff --git a/src/matrix/storage/idb/create.js b/src/matrix/storage/idb/create.js index 633b24f7..6056409f 100644 --- a/src/matrix/storage/idb/create.js +++ b/src/matrix/storage/idb/create.js @@ -1,18 +1,31 @@ import Storage from "./storage.js"; import { openDatabase, reqAsPromise } from "./utils.js"; +import { exportSession, importSession } from "./export.js"; + +const sessionName = sessionId => `brawl_session_${sessionId}`; +const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, 1); export default class StorageFactory { async create(sessionId) { - const databaseName = `brawl_session_${sessionId}`; - const db = await openDatabase(databaseName, createStores, 1); + const db = await openDatabaseWithSessionId(sessionId); return new Storage(db); } delete(sessionId) { - const databaseName = `brawl_session_${sessionId}`; + const databaseName = sessionName(sessionId); const req = window.indexedDB.deleteDatabase(databaseName); return reqAsPromise(req); } + + async export(sessionId) { + const db = await openDatabaseWithSessionId(sessionId); + return await exportSession(db); + } + + async import(sessionId, data) { + const db = await openDatabaseWithSessionId(sessionId); + return await importSession(db, data); + } } function createStores(db) { diff --git a/src/matrix/storage/idb/export.js b/src/matrix/storage/idb/export.js new file mode 100644 index 00000000..14cabc48 --- /dev/null +++ b/src/matrix/storage/idb/export.js @@ -0,0 +1,28 @@ +import { iterateCursor, txnAsPromise } from "./utils.js"; +import { STORE_NAMES } from "../common.js"; + +export async function exportSession(db) { + const NOT_DONE = {done: false}; + const txn = db.transaction(STORE_NAMES, "readonly"); + const data = {}; + await Promise.all(STORE_NAMES.map(async name => { + const results = data[name] = []; // initialize in deterministic order + const store = txn.objectStore(name); + await iterateCursor(store.openCursor(), (value) => { + results.push(value); + return NOT_DONE; + }); + })); + return data; +} + +export async function importSession(db, data) { + const txn = db.transaction(STORE_NAMES, "readwrite"); + for (const name of STORE_NAMES) { + const store = txn.objectStore(name); + for (const value of data[name]) { + store.add(value); + } + } + await txnAsPromise(txn); +} diff --git a/src/ui/web/login/SessionPickerView.js b/src/ui/web/login/SessionPickerView.js index 36c72598..d49a2b4b 100644 --- a/src/ui/web/login/SessionPickerView.js +++ b/src/ui/web/login/SessionPickerView.js @@ -1,44 +1,72 @@ import ListView from "../general/ListView.js"; import TemplateView from "../general/TemplateView.js"; +function selectFileAsText(mimeType) { + const input = document.createElement("input"); + input.setAttribute("type", "file"); + if (mimeType) { + input.setAttribute("accept", mimeType); + } + const promise = new Promise((resolve, reject) => { + const checkFile = () => { + input.removeEventListener("change", checkFile, true); + const file = input.files[0]; + if (file) { + resolve(file.text()); + } else { + reject(new Error("No file selected")); + } + } + input.addEventListener("change", checkFile, true); + }); + input.click(); + return promise; +} + + + class SessionPickerItemView extends TemplateView { constructor(vm) { super(vm, true); - this._onDeleteClick = this._onDeleteClick.bind(this); - this._onClearClick = this._onClearClick.bind(this); } - _onDeleteClick(event) { - event.stopPropagation(); - event.preventDefault(); + _onDeleteClick() { if (confirm("Are you sure?")) { this.viewModel.delete(); } } - _onClearClick(event) { - event.stopPropagation(); - event.preventDefault(); - this.viewModel.clear(); - } - render(t) { const deleteButton = t.button({ disabled: vm => vm.isDeleting, - onClick: this._onDeleteClick, + onClick: this._onDeleteClick.bind(this), }, "Delete"); const clearButton = t.button({ disabled: vm => vm.isClearing, - onClick: this._onClearClick, + onClick: () => this.viewModel.clear(), }, "Clear"); - - const json = t.if(vm => vm.json, t => { - return t.div(t.pre(vm => vm.json)); + const exportButton = t.button({ + disabled: vm => vm.isClearing, + onClick: () => this.viewModel.export(), + }, "Export"); + const downloadExport = t.if(vm => vm.exportDataUrl, (t, vm) => { + return t.a({ + href: vm.exportDataUrl, + download: `brawl-session-${this.viewModel.id}.json`, + onClick: () => setTimeout(() => this.viewModel.clearExport(), 100), + }, "Download"); }); - const userName = t.span({className: "userId"}, vm => vm.userId); + const userName = t.span({className: "userId"}, vm => vm.label); const errorMessage = t.if(vm => vm.error, t => t.span({className: "error"}, vm => vm.error)); - return t.li([t.div({className: "sessionInfo"}, [userName, errorMessage, clearButton, deleteButton]), json]); + return t.li([t.div({className: "sessionInfo"}, [ + userName, + errorMessage, + downloadExport, + exportButton, + clearButton, + deleteButton, + ])]); } } @@ -47,7 +75,7 @@ export default class SessionPickerView extends TemplateView { this._sessionList = new ListView({ list: this.viewModel.sessions, onItemClick: (item, event) => { - if (event.target.closest(".sessionInfo")) { + if (event.target.closest(".userId")) { this.viewModel.pick(item.viewModel.id); } }, @@ -62,7 +90,7 @@ export default class SessionPickerView extends TemplateView { t.h1(["Pick a session"]), this._sessionList.mount(), t.p(t.button({onClick: () => this.viewModel.cancel()}, ["Log in to a new session instead"])), - t.p(t.button({onClick: () => this.viewModel.import(prompt("JSON"))}, ["Import Session JSON"])), + t.p(t.button({onClick: async () => this.viewModel.import(await selectFileAsText("application/json"))}, "Import")), t.p(t.a({href: "https://github.com/bwindels/brawl-chat"}, ["Brawl on Github"])) ]); }