Merge pull request #25 from bwindels/bwindels/export

Add import/export functionality
This commit is contained in:
Bruno Windels 2019-12-14 17:31:51 +00:00 committed by GitHub
commit 6ac76f554b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 140 additions and 36 deletions

View File

@ -5,6 +5,10 @@ import LoginViewModel from "./LoginViewModel.js";
import SessionPickerViewModel from "./SessionPickerViewModel.js"; import SessionPickerViewModel from "./SessionPickerViewModel.js";
import EventEmitter from "../EventEmitter.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 { export default class BrawlViewModel extends EventEmitter {
constructor({storageFactory, sessionStore, createHsApi, clock}) { constructor({storageFactory, sessionStore, createHsApi, clock}) {
super(); super();
@ -93,7 +97,7 @@ export default class BrawlViewModel extends EventEmitter {
async _onLoginFinished(loginData) { async _onLoginFinished(loginData) {
if (loginData) { if (loginData) {
// TODO: extract random() as it is a source of non-determinism // 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 = { const sessionInfo = {
id: sessionId, id: sessionId,
deviceId: loginData.device_id, deviceId: loginData.device_id,

View File

@ -1,5 +1,6 @@
import {SortedArray} from "../observable/index.js"; import {SortedArray} from "../observable/index.js";
import EventEmitter from "../EventEmitter.js"; import EventEmitter from "../EventEmitter.js";
import {createNewSessionId} from "./BrawlViewModel.js"
class SessionItemViewModel extends EventEmitter { class SessionItemViewModel extends EventEmitter {
constructor(sessionInfo, pickerVM) { constructor(sessionInfo, pickerVM) {
@ -9,7 +10,7 @@ class SessionItemViewModel extends EventEmitter {
this._isDeleting = false; this._isDeleting = false;
this._isClearing = false; this._isClearing = false;
this._error = null; this._error = null;
this._showJSON = false; this._exportDataUrl = null;
} }
get error() { get error() {
@ -33,7 +34,6 @@ class SessionItemViewModel extends EventEmitter {
async clear() { async clear() {
this._isClearing = true; this._isClearing = true;
this._showJSON = true;
this.emit("change"); this.emit("change");
try { try {
await this._pickerVM.clear(this.id); await this._pickerVM.clear(this.id);
@ -59,19 +59,42 @@ class SessionItemViewModel extends EventEmitter {
return this._sessionInfo.id; return this._sessionInfo.id;
} }
get userId() { get label() {
return this._sessionInfo.userId; const {userId, comment} = this._sessionInfo;
if (comment) {
return `${userId} (${comment})`;
} else {
return userId;
}
} }
get sessionInfo() { get sessionInfo() {
return this._sessionInfo; return this._sessionInfo;
} }
get json() { get exportDataUrl() {
if (this._showJSON) { return this._exportDataUrl;
return JSON.stringify(this._sessionInfo); }
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) { async import(json) {
const sessionInfo = JSON.parse(json); const data = JSON.parse(json);
const sessionId = (Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)).toString(); const {sessionInfo} = data;
sessionInfo.id = sessionId; sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
sessionInfo.lastUsed = sessionId; sessionInfo.id = createNewSessionId();
await this._storageFactory.import(sessionInfo.id, data.stores);
await this._sessionStore.add(sessionInfo); await this._sessionStore.add(sessionInfo);
this._sessions.set(new SessionItemViewModel(sessionInfo, this)); this._sessions.set(new SessionItemViewModel(sessionInfo, this));
} }

View File

@ -1,18 +1,31 @@
import Storage from "./storage.js"; import Storage from "./storage.js";
import { openDatabase, reqAsPromise } from "./utils.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 { export default class StorageFactory {
async create(sessionId) { async create(sessionId) {
const databaseName = `brawl_session_${sessionId}`; const db = await openDatabaseWithSessionId(sessionId);
const db = await openDatabase(databaseName, createStores, 1);
return new Storage(db); return new Storage(db);
} }
delete(sessionId) { delete(sessionId) {
const databaseName = `brawl_session_${sessionId}`; const databaseName = sessionName(sessionId);
const req = window.indexedDB.deleteDatabase(databaseName); const req = window.indexedDB.deleteDatabase(databaseName);
return reqAsPromise(req); 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) { function createStores(db) {

View File

@ -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);
}

View File

@ -1,44 +1,72 @@
import ListView from "../general/ListView.js"; import ListView from "../general/ListView.js";
import TemplateView from "../general/TemplateView.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 { class SessionPickerItemView extends TemplateView {
constructor(vm) { constructor(vm) {
super(vm, true); super(vm, true);
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onClearClick = this._onClearClick.bind(this);
} }
_onDeleteClick(event) { _onDeleteClick() {
event.stopPropagation();
event.preventDefault();
if (confirm("Are you sure?")) { if (confirm("Are you sure?")) {
this.viewModel.delete(); this.viewModel.delete();
} }
} }
_onClearClick(event) {
event.stopPropagation();
event.preventDefault();
this.viewModel.clear();
}
render(t) { render(t) {
const deleteButton = t.button({ const deleteButton = t.button({
disabled: vm => vm.isDeleting, disabled: vm => vm.isDeleting,
onClick: this._onDeleteClick, onClick: this._onDeleteClick.bind(this),
}, "Delete"); }, "Delete");
const clearButton = t.button({ const clearButton = t.button({
disabled: vm => vm.isClearing, disabled: vm => vm.isClearing,
onClick: this._onClearClick, onClick: () => this.viewModel.clear(),
}, "Clear"); }, "Clear");
const exportButton = t.button({
const json = t.if(vm => vm.json, t => { disabled: vm => vm.isClearing,
return t.div(t.pre(vm => vm.json)); 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)); 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({ this._sessionList = new ListView({
list: this.viewModel.sessions, list: this.viewModel.sessions,
onItemClick: (item, event) => { onItemClick: (item, event) => {
if (event.target.closest(".sessionInfo")) { if (event.target.closest(".userId")) {
this.viewModel.pick(item.viewModel.id); this.viewModel.pick(item.viewModel.id);
} }
}, },
@ -62,7 +90,7 @@ export default class SessionPickerView extends TemplateView {
t.h1(["Pick a session"]), t.h1(["Pick a session"]),
this._sessionList.mount(), 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.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"])) t.p(t.a({href: "https://github.com/bwindels/brawl-chat"}, ["Brawl on Github"]))
]); ]);
} }