diff --git a/app/.gitignore b/app/.gitignore
index 9e3b6267..463207c9 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -9,3 +9,4 @@ nohup.out
node_modules
dump
.idea/
+.cache
diff --git a/app/client/views/character/characterSheet.html b/app/client/views/character/characterSheet.html
index 8785892b..efc54123 100644
--- a/app/client/views/character/characterSheet.html
+++ b/app/client/views/character/characterSheet.html
@@ -31,9 +31,17 @@
Settings
-
+
Export to Improved Initiative
+
+
+ Make a copy
+
+
+
+ Download a backup
+
{{else}}
diff --git a/app/client/views/character/characterSheet.js b/app/client/views/character/characterSheet.js
index a2a549f4..0c43d387 100644
--- a/app/client/views/character/characterSheet.js
+++ b/app/client/views/character/characterSheet.js
@@ -234,6 +234,18 @@ Template.characterSheet.events({
element: event.currentTarget.parentElement.parentElement,
});
},
+ "click #characterCopy": function(event, instance){
+ Meteor.call("copyCharacter", this._id, (error, char) => {
+ if (error){
+ console.error(error);
+ } else {
+ Router.go(`/character/${char._id}/${char.urlName || "-"}`);
+ }
+ });
+ },
+ "click #characterDump": function(event, instance){
+ saveCharacterDump(this._id);
+ },
"click #unshareCharacter": function(event, instance){
pushDialogStack({
data: this,
diff --git a/app/client/views/characterList/characterList.html b/app/client/views/characterList/characterList.html
index 94962a5b..c6bc6c97 100644
--- a/app/client/views/characterList/characterList.html
+++ b/app/client/views/characterList/characterList.html
@@ -58,6 +58,13 @@
{{#simpleTooltip class="always"}} New Character {{/simpleTooltip}}
+
+
+
+ {{#simpleTooltip class="always"}} Restore from backup {{/simpleTooltip}}
+
{{/fabMenu}}
diff --git a/app/client/views/characterList/characterList.js b/app/client/views/characterList/characterList.js
index 5bdee54b..f1113243 100644
--- a/app/client/views/characterList/characterList.js
+++ b/app/client/views/characterList/characterList.js
@@ -81,4 +81,13 @@ Template.characterList.events({
returnElement: instance.find(`.party[data-id='${partyId}']`),
});
},
+ "click .restoreCharacter": function(event, instance) {
+ pushDialogStack({
+ template: "characterRestoreDialog",
+ element: event.currentTarget,
+ callback(dump){
+ return;
+ },
+ })
+ },
});
diff --git a/app/client/views/characterList/characterRestoreDialog/characterRestoreDialog.html b/app/client/views/characterList/characterRestoreDialog/characterRestoreDialog.html
new file mode 100644
index 00000000..5ff68c04
--- /dev/null
+++ b/app/client/views/characterList/characterRestoreDialog/characterRestoreDialog.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Restore Character
+
+
+
+
+
+
+
diff --git a/app/client/views/characterList/characterRestoreDialog/characterRestoreDialog.js b/app/client/views/characterList/characterRestoreDialog/characterRestoreDialog.js
new file mode 100644
index 00000000..ebc6a4dd
--- /dev/null
+++ b/app/client/views/characterList/characterRestoreDialog/characterRestoreDialog.js
@@ -0,0 +1,66 @@
+Template.characterRestoreDialog.onCreated(function(){
+ this.dump = {};
+ this.valid = new ReactiveVar(false);
+ this.error = new ReactiveVar(null);
+ this.loading = new ReactiveVar(false);
+});
+
+Template.characterRestoreDialog.helpers({
+ invalid(){
+ return !Template.instance().valid.get();
+ },
+ error(){
+ return Template.instance().error.get();
+ },
+ loading(){
+ return Template.instance().loading.get();
+ },
+});
+
+const fail = function(instance){
+ instance.valid.set(false);
+ instance.error.set("Failed to convert file into a valid character");
+ instance.dump = undefined;
+};
+
+Template.characterRestoreDialog.events({
+ "input .fileInput": function(event, instance){
+ let input = event.currentTarget.$.input;
+ let reader = new FileReader();
+ reader.onload = function(){
+ let dumpString = reader.result;
+ try {
+ let dump = JSON.parse(dumpString);
+ if (dump && dump.character && dump.collections){
+ instance.valid.set(true);
+ instance.error.set(null);
+ instance.dump = dump;
+ } else {
+ fail(instance);
+ }
+ } catch (e) {
+ fail(instance);
+ }
+ };
+ reader.readAsText(input.files[0]);
+ },
+ "click .cancelButton": function(event, instance){
+ popDialogStack();
+ },
+ "click .addButton": function(event, instance){
+ let dump = instance.dump;
+ if (!dump) return;
+ Meteor.call('restoreCharacter', dump, (e, char) => {
+ instance.loading.set(false);
+ if (!char){
+ instance.error.set(e.message)
+ } else {
+ popDialogStack();
+ Router.go("characterSheet", {
+ _id: char._id,
+ urlName: char.urlName || '-',
+ });
+ }
+ });
+ },
+});
diff --git a/app/lib/functions/backupRestoreCharacter.js b/app/lib/functions/backupRestoreCharacter.js
new file mode 100644
index 00000000..31fced03
--- /dev/null
+++ b/app/lib/functions/backupRestoreCharacter.js
@@ -0,0 +1,99 @@
+import { saveAs } from 'file-saver';
+
+let characterCollections = [];
+Meteor.startup(() => {
+ characterCollections = [
+ Actions,
+ Attacks,
+ Buffs,
+ Classes,
+ Conditions,
+ CustomBuffs,
+ Effects,
+ Experiences,
+ Features,
+ Notes,
+ Proficiencies,
+ SpellLists,
+ Spells,
+ TemporaryHitPoints,
+ Items,
+ Containers,
+ ];
+});
+
+dumpCharacter = function(charId){
+ let characterDump = {collections: {}};
+ characterDump.character = Characters.findOne(charId);
+ characterCollections.forEach(c => {
+ characterDump.collections[c._name] = c.find({charId}).fetch();
+ });
+ return characterDump;
+};
+
+saveCharacterDump = function(charId){
+ let dump = dumpCharacter(charId);
+ let textDump = JSON.stringify(dump, null, 2);
+ let charName = dump.character.name;
+ let blob = new Blob([textDump], {type: "application/json;charset=utf-8"});
+ saveAs(blob, `${charName}.JSON`);
+};
+
+giveCharacterDumpNewIds = function(characterDump){
+ // Give the character a new Id
+ const oldCharId = characterDump.character._id;
+ const newCharId = Random.id();
+ characterDump.character._id = newCharId;
+
+ let idMap = {[oldCharId]: newCharId}; // {oldId: newId}
+
+ // Give all documents a new Id, and store the mapping from old to new
+ for (let colName in characterDump.collections){
+ for (let doc of characterDump.collections[colName]){
+ let oldId = doc._id;
+ let newId = Random.id();
+ doc._id = newId;
+ idMap[oldId] = newId;
+ }
+ }
+
+ // Replace all references to old Ids with new ones
+ for (let colName in characterDump.collections){
+ for (let doc of characterDump.collections[colName]){
+ // Replace the character Id with the new one
+ doc.charId = newCharId;
+ // Replace the parent reference id with a new id
+ if (doc.parent && doc.parent.id){
+ let newParentId = idMap[doc.parent.id];
+ if(!newParentId) throw `Can't find the mapping for id ${doc.parent.id}`;
+ doc.parent.id = newParentId;
+ }
+ }
+ }
+}
+
+restoreCharacter = function(characterDump){
+ Characters.direct.insert(characterDump.character);
+ for (collectionName in characterDump.collections){
+ let collection = Meteor.Collection.get(collectionName);
+ for (doc of characterDump.collections[collectionName]){
+ // delete problematic keys that shouldn't ever be available on insert
+ delete doc.restoredAt;
+ delete doc.restoredBy;
+ // Insert the doc with no hooks
+ collection.direct.insert(doc);
+ }
+ }
+};
+
+Meteor.methods({
+ restoreCharacter(characterDump){
+ characterDump.character.name += " - Restored"
+ characterDump.character.owner = Meteor.userId();
+ characterDump.character.readers = [];
+ characterDump.character.writers = [];
+ giveCharacterDumpNewIds(characterDump);
+ restoreCharacter(characterDump);
+ return characterDump.character
+ },
+});
diff --git a/app/lib/functions/permissions.js b/app/lib/functions/permissions.js
index 495d429b..db29df8f 100644
--- a/app/lib/functions/permissions.js
+++ b/app/lib/functions/permissions.js
@@ -12,12 +12,14 @@ canEditCharacter = function(charId, userId){
return (userId === char.owner || _.contains(char.writers, userId));
};
-canViewCharacter = function(charId, userId){
+canViewCharacter = function(char, userId){
userId = userId || Meteor.userId();
- var char = Characters.findOne(
- charId,
- {fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
- );
+ if (typeof char !== 'object'){
+ char = Characters.findOne(
+ char,
+ {fields: {owner: 1, writers: 1, readers: 1, "settings.viewPermission": 1}}
+ );
+ }
if (!char) return true;
return userId === char.owner ||
char.settings.viewPermission === "public" ||
diff --git a/app/lib/methods/characterCopyPaste.js b/app/lib/methods/characterCopyPaste.js
new file mode 100644
index 00000000..76173202
--- /dev/null
+++ b/app/lib/methods/characterCopyPaste.js
@@ -0,0 +1,26 @@
+// Uses '/lib/functions/backupRestoreCharacter.js' to do most the work
+
+Meteor.methods({
+ copyCharacter: function(charId) {
+ const userId = Meteor.userId();
+ let character = Characters.findOne(charId);
+
+ // Need at least view level permission to make a copy for yourself
+ if (!canViewCharacter(character, userId)) return;
+
+ let characterDump = dumpCharacter(charId);
+ giveCharacterDumpNewIds(characterDump);
+
+ // Remove all readers and writers, make this user the new owner
+ characterDump.character.readers = [];
+ characterDump.character.writers = [];
+ characterDump.character.owner = userId;
+
+ // Rename the character so it's obviously a copy
+ characterDump.character.name += " - Copy";
+
+ // Write the character back to the database
+ restoreCharacter(characterDump);
+ return characterDump.character;
+ },
+});
diff --git a/app/package-lock.json b/app/package-lock.json
index 12e5ed0b..3c606c15 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -45,7 +45,7 @@
},
"ansi-regex": {
"version": "2.1.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "resolved": false,
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
},
"aproba": {
@@ -74,6 +74,7 @@
},
"block-stream": {
"version": "0.0.9",
+ "resolved": false,
"integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
"requires": {
"inherits": "~2.0.0"
@@ -131,7 +132,7 @@
},
"code-point-at": {
"version": "1.1.0",
- "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
+ "resolved": false,
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
},
"core-js": {
@@ -139,6 +140,11 @@
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
"integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw=="
},
+ "core-util-is": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+ },
"cross-spawn": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
@@ -209,6 +215,11 @@
"resolved": "https://registry.npmjs.org/fibers/-/fibers-2.0.2.tgz",
"integrity": "sha512-HfVRxhYG7C8Jl9FqtrlElMR2z/8YiLQVDKf67MLY25Ic+ILx3ecmklfT1v3u+7P5/4vEFjuxaAFXhr2/Afwk5g=="
},
+ "file-saver": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.0.tgz",
+ "integrity": "sha512-cYM1ic5DAkg25pHKgi5f10ziAM7RJU37gaH1XQlyNDrtUnzhC/dfoV9zf2OmF0RMKi42jG5B0JWBnPQqyj/G6g=="
+ },
"find-up": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
@@ -259,7 +270,7 @@
},
"graceful-fs": {
"version": "4.1.11",
- "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+ "resolved": false,
"integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
},
"har-schema": {
@@ -361,6 +372,7 @@
},
"inherits": {
"version": "2.0.3",
+ "resolved": false,
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
},
"invert-kv": {
@@ -383,7 +395,7 @@
},
"is-fullwidth-code-point": {
"version": "1.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
+ "resolved": false,
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"requires": {
"number-is-nan": "^1.0.0"
@@ -1064,6 +1076,37 @@
"requires": {
"inherits": "~2.0.1",
"readable-stream": "^2.0.2"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+ "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ }
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
}
},
"stream-http": {
@@ -1076,6 +1119,37 @@
"readable-stream": "^2.3.3",
"to-arraybuffer": "^1.0.0",
"xtend": "^4.0.0"
+ },
+ "dependencies": {
+ "readable-stream": {
+ "version": "2.3.6",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+ "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+ "requires": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ },
+ "dependencies": {
+ "inherits": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+ "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+ }
+ }
+ },
+ "string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "requires": {
+ "safe-buffer": "~5.1.0"
+ }
+ }
}
},
"string_decoder": {
@@ -1434,7 +1508,7 @@
},
"number-is-nan": {
"version": "1.0.1",
- "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
+ "resolved": false,
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
},
"os-homedir": {
@@ -1578,6 +1652,7 @@
},
"minimist": {
"version": "1.2.0",
+ "resolved": false,
"integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
},
"strip-json-comments": {
@@ -1815,7 +1890,7 @@
},
"set-blocking": {
"version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "resolved": false,
"integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
},
"shebang-command": {
@@ -1833,7 +1908,7 @@
},
"signal-exit": {
"version": "3.0.2",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+ "resolved": false,
"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
},
"source-map": {
@@ -1909,7 +1984,7 @@
},
"strip-ansi": {
"version": "3.0.1",
- "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "resolved": false,
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"requires": {
"ansi-regex": "^2.0.0"
@@ -2159,7 +2234,7 @@
},
"wrap-ansi": {
"version": "2.1.0",
- "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz",
"integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=",
"requires": {
"string-width": "^1.0.1",
diff --git a/app/package.json b/app/package.json
index 13ecdf36..fbbadeae 100644
--- a/app/package.json
+++ b/app/package.json
@@ -17,6 +17,7 @@
"bower": "^1.7.9",
"core-js": "^2.5.7",
"fibers": "^2.0.2",
+ "file-saver": "^2.0.0",
"meteor-node-stubs": "^0.3.3",
"qrcode": "^1.3.0",
"source-map-support": "^0.5.9",