Merge pull request #25 from bwindels/bwindels/export
Add import/export functionality
This commit is contained in:
commit
6ac76f554b
5 changed files with 140 additions and 36 deletions
|
@ -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,
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
28
src/matrix/storage/idb/export.js
Normal file
28
src/matrix/storage/idb/export.js
Normal 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);
|
||||||
|
}
|
|
@ -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"]))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue