Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fbb006783 | ||
|
|
2253672f43 | ||
|
|
ed6d557f8a | ||
|
|
4d642b56bb | ||
|
|
436c5bb785 | ||
|
|
8489ef5ec0 | ||
|
|
c9710bdb09 | ||
|
|
26784f11b6 | ||
|
|
23d43f7d43 | ||
|
|
1ebb0d2527 | ||
|
|
9d86cb8bee | ||
|
|
3343f8a813 | ||
|
|
0260824c2f | ||
|
|
66ee3ff808 | ||
|
|
cb71f6d380 | ||
|
|
2f04d9ec1c | ||
|
|
40c54524a7 | ||
|
|
b890a3b11e | ||
|
|
c9242a95f3 | ||
|
|
fedda62c7c | ||
|
|
612575d0e6 | ||
|
|
d1d22c0d89 | ||
|
|
b94f5ebb4b | ||
|
|
3f32535666 | ||
|
|
4ea02c4fbb | ||
|
|
b052e8dd19 | ||
|
|
e2822b9f22 | ||
|
|
c46b836985 | ||
|
|
65d1bac0dc |
62
README.md
62
README.md
@@ -1,13 +1,71 @@
|
||||
RPG Docs
|
||||
DiceCloud
|
||||
========
|
||||
|
||||
This is the repo for [DiceCloud](dicecloud.com).
|
||||
|
||||
DiceCloud is a free, auditable, real-time character sheet for D&D 5e.
|
||||
|
||||
Philosophy
|
||||
----------
|
||||
|
||||
Setting up your character on DiceCloud takes a little longer than
|
||||
just filling it in on a paper character sheet would. The goal of using an
|
||||
online sheet is to make actually playing the game more streamlined, and
|
||||
ultimately more fun. So putting a little extra effort into setting up a
|
||||
character now pays off over and over again once you're playing.
|
||||
|
||||
The idea is to track where each number comes from, and allow you to easily make
|
||||
changes on the fly. Let's look at a hypothetical example.
|
||||
|
||||
> You need to swim through a sunken section of dungeon to fetch the quest's Thing.
|
||||
> You'll need to take off your magical Plate Armor of +1 Constitution to swim
|
||||
> without sinking, of course.
|
||||
>
|
||||
> Taking it off will take away that disadvantage on
|
||||
> stealth checks, change your armor class, your speed and your constitution, and
|
||||
> which in turn changes your hit points and your constitution saving throw.
|
||||
> Working out all those changes in the middle of a game will drag the game to a
|
||||
> halt.
|
||||
>
|
||||
> Fortunately you have DiceCloud, so it's a matter of dragging
|
||||
> your Plate Armor +1 Con from your "equipment" box to your "backpack" box and
|
||||
> you're done. Your hitpoints change correctly, your saving throws are up to date,
|
||||
> your armor class goes back to reflecting the fact that you have natural armor
|
||||
> from being a dragonborn. Your character sheet keeps up and you
|
||||
> ultimately get more time to play the game. Huzzah!
|
||||
|
||||
Getting started
|
||||
---------------
|
||||
|
||||
`git clone https://github.com/ThaumRystra/DiceCloud1 dicecloud`
|
||||
Running DiceCloud locally, either to host it yourself away from an internet
|
||||
connection, or to contribute to developing it further, is fairly
|
||||
straightforward and it should work on Linux, Windows, and Mac.
|
||||
|
||||
You'll need to have installed:
|
||||
|
||||
- [git](https://www.atlassian.com/git/tutorials/install-git)
|
||||
- [Meteor](https://www.meteor.com/install)
|
||||
- [Bower](https://bower.io/)
|
||||
|
||||
Then, it's just a matter of cloning this repository into a folder, installing the bower dependencies and running
|
||||
`meteor` in the app directory.
|
||||
|
||||
`git clone https://github.com/ThaumRystra/DiceCloud dicecloud`
|
||||
`cd dicecloud`
|
||||
`cd app`
|
||||
`bower install`
|
||||
`meteor`
|
||||
|
||||
You should see this:
|
||||
|
||||
```
|
||||
=> Started proxy.
|
||||
=> Started MongoDB.
|
||||
=> Started your app.
|
||||
|
||||
=> App running at: http://localhost:3000/
|
||||
```
|
||||
|
||||
Now, visiting http://localhost:3000/ should show you an empty instance of
|
||||
DiceCloud running.
|
||||
|
||||
|
||||
2
app/.gitignore
vendored
2
app/.gitignore
vendored
@@ -8,3 +8,5 @@ private/oldClient
|
||||
nohup.out
|
||||
node_modules
|
||||
dump
|
||||
.idea/
|
||||
.cache
|
||||
|
||||
26
app/Model/Meta/PatreonPosts.js
Normal file
26
app/Model/Meta/PatreonPosts.js
Normal file
@@ -0,0 +1,26 @@
|
||||
PatreonPosts = new Mongo.Collection("patreonPosts");
|
||||
|
||||
Schemas.PatreonPosts = new SimpleSchema({
|
||||
link: {
|
||||
type: String,
|
||||
},
|
||||
dateAdded: {
|
||||
type: Date,
|
||||
autoValue(){
|
||||
return new Date();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
PatreonPosts.attachSchema(Schemas.PatreonPosts);
|
||||
|
||||
PatreonPosts.allow({
|
||||
insert: function(userId, doc) {
|
||||
var user = Meteor.users.findOne(userId);
|
||||
if (user) return _.contains(user.roles, "admin");
|
||||
},
|
||||
remove: function(userId, doc) {
|
||||
var user = Meteor.users.findOne(userId);
|
||||
if (user) return _.contains(user.roles, "admin");
|
||||
},
|
||||
});
|
||||
@@ -70,6 +70,10 @@ Schemas.User = new SimpleSchema({
|
||||
index: 1,
|
||||
optional: true,
|
||||
},
|
||||
lastPatreonPostClicked: {
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.users.attachSchema(Schemas.User);
|
||||
@@ -107,3 +111,11 @@ if (Meteor.isServer) Meteor.methods({
|
||||
Meteor.users.update(this.userId, {$set: {apiKey}});
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.methods({
|
||||
clickPatreonPost(link) {
|
||||
Meteor.users.update(this.userId, {$set: {
|
||||
lastPatreonPostClicked: link
|
||||
}});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,91 +1,268 @@
|
||||
Router.map(function() {
|
||||
this.route("vmixCharacter", {
|
||||
path: "/vmix-character/:_id/",
|
||||
where: "server",
|
||||
action: function() {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixCharacter", () =>
|
||||
this.response.end(vMixCharacter(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
this.route("vmixParty", {
|
||||
path: "/vmix-party/:_id/",
|
||||
where: "server",
|
||||
action: function() {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixParty", () =>
|
||||
this.response.end(vMixParty(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
Router.map(function () {
|
||||
this.route("vmixCharacter", {
|
||||
path: "/vmix-character/:_id/",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixCharacter", () =>
|
||||
this.response.end(vMixCharacter(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
this.route("vmixParty", {
|
||||
path: "/vmix-party/:_id/",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "vmixParty", () =>
|
||||
this.response.end(vMixParty(this.params._id))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.route("jsonCharacterSheet", {
|
||||
path: "/character/:_id/json",
|
||||
where: "server",
|
||||
action: function() {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
|
||||
if (canViewCharacter(this.params._id, userIdFromKey(key))){
|
||||
this.response.end(JSONExport(this.params._id))
|
||||
} else {
|
||||
this.response.writeHead(403, "You do not have permission to view this character");
|
||||
this.response.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
this.route("jsonCharacterSheet", { // GET /character/:_id/json?key=:key
|
||||
path: "/character/:_id/json",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
ifKeyValid(key, this.response, "jsonCharacterSheet", () => {
|
||||
if (canViewCharacter(this.params._id, userIdFromKey(key))) {
|
||||
this.response.end(JSONExport(this.params._id))
|
||||
} else {
|
||||
this.response.writeHead(403, "You do not have permission to view this character");
|
||||
this.response.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
this.route("getUserId", { // GET /api/user?username=:un&key=:key
|
||||
path: "/api/user",
|
||||
where: "server",
|
||||
action: function () {
|
||||
this.response.setHeader("Content-Type", "application/json");
|
||||
var query = this.params.query;
|
||||
var key = query && query.key;
|
||||
var username = query && query.username;
|
||||
ifKeyValid(key, this.response, "getUserId", () => {
|
||||
Meteor.call("getUserId", username, (err, result) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
this.response.writeHead(404, "User not found");
|
||||
this.response.end();
|
||||
} else {
|
||||
console.log(result);
|
||||
this.response.end(JSON.stringify({id: result}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.route("addSpellsToCharacter", { // POST /api/character/:_id/spellList/:listId
|
||||
path: "/api/character/:_id/spellList/:listId",
|
||||
where: "server"
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const spells = this.request.body;
|
||||
const charId = this.params._id;
|
||||
const listId = this.params.listId;
|
||||
Meteor.call("insertSpells", key, charId, listId, spells, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("createCharacter", { // POST /api/character
|
||||
path: "/api/character",
|
||||
where: "server"
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const character = this.request.body;
|
||||
Meteor.call("insertCharacter", key, character, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("deleteCharacter", { // DELETE /api/character/:_id
|
||||
path: "/api/character/:_id",
|
||||
where: "server"
|
||||
}).delete(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
Meteor.call("deleteCharacter", key, charId, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("transferCharacterOwnership", { // PUT /api/character/:_id/owner
|
||||
path: "/api/character/:_id/owner",
|
||||
where: "server"
|
||||
}).put(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const ownerId = this.request.body['id'];
|
||||
Meteor.call("transferCharacterOwnership", key, charId, ownerId, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertFeatures", { // POST /api/character/:_id/feature
|
||||
path: "/api/character/:_id/feature",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const features = this.request.body;
|
||||
Meteor.call("insertFeatures", key, charId, features, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertProfs", { // POST /api/character/:_id/prof
|
||||
path: "/api/character/:_id/prof",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const profs = this.request.body;
|
||||
Meteor.call("insertProfs", key, charId, profs, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertEffects", { // POST /api/character/:_id/effect
|
||||
path: "/api/character/:_id/effect",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const effects = this.request.body;
|
||||
Meteor.call("insertEffects", key, charId, effects, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.route("insertClasses", { // POST /api/character/:_id/class
|
||||
path: "/api/character/:_id/class",
|
||||
where: "server",
|
||||
}).post(function () {
|
||||
const key = startPOSTResponse(this);
|
||||
const charId = this.params._id;
|
||||
const classes = this.request.body;
|
||||
Meteor.call("insertClasses", key, charId, classes, (err, res) => {
|
||||
if (err) {
|
||||
this.response.writeHead(err.error, err.reason);
|
||||
this.response.end(err.details);
|
||||
} else {
|
||||
this.response.end(JSON.stringify(res));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var ifKeyValid = function(apiKey, response, method, callback){
|
||||
if (!apiKey){
|
||||
response.writeHead(403, "You must use an api key to access this api");
|
||||
response.end();
|
||||
} else if (!isKeyValid(apiKey)){
|
||||
response.writeHead(403, "API key is invalid");
|
||||
response.end();
|
||||
} else if (isRateLimited(apiKey, method)){
|
||||
response.writeHead(429, "Too many requests");
|
||||
response.end(JSON.stringify({
|
||||
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
|
||||
}));
|
||||
} else {
|
||||
rateLimiter.increment({apiKey: apiKey, method: method})
|
||||
callback();
|
||||
}
|
||||
const startPOSTResponse = function (request) {
|
||||
request.response.setHeader("Content-Type", "application/json");
|
||||
const header = request.request.headers;
|
||||
return header && header['authorization'];
|
||||
};
|
||||
|
||||
var isKeyValid = function(apiKey){
|
||||
var user = Meteor.users.findOne({apiKey});
|
||||
if (!user) return false;
|
||||
var blackListed = Blacklist.findOne({userId: user._id});
|
||||
return !blackListed;
|
||||
var ifKeyValid = function (apiKey, response, method, callback) {
|
||||
if (!apiKey) {
|
||||
response.writeHead(403, "You must use an api key to access this api");
|
||||
response.end();
|
||||
} else if (!isKeyValid(apiKey)) {
|
||||
response.writeHead(403, "API key is invalid");
|
||||
response.end();
|
||||
} else if (isRateLimited(apiKey, method)) {
|
||||
response.writeHead(429, "Too many requests");
|
||||
response.end(JSON.stringify({
|
||||
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
|
||||
}));
|
||||
} else {
|
||||
rateLimiter.increment({apiKey: apiKey, method: method});
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
var userIdFromKey = function(apiKey){
|
||||
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
|
||||
return user._id;
|
||||
}
|
||||
isKeyValid = function (apiKey) {
|
||||
var user = Meteor.users.findOne({apiKey});
|
||||
if (!user) return false;
|
||||
var blackListed = Blacklist.findOne({userId: user._id});
|
||||
return !blackListed;
|
||||
};
|
||||
|
||||
var rateLimiter = new RateLimiter();
|
||||
rateLimiter.addRule({apiKey: String}, 5, 5000);
|
||||
userIdFromKey = function (apiKey) {
|
||||
var user = Meteor.users.findOne({apiKey}); // we know user exists from isKeyValid
|
||||
return user._id;
|
||||
};
|
||||
|
||||
rateLimiter = new RateLimiter();
|
||||
// global limit
|
||||
rateLimiter.addRule({apiKey: String}, 10, 1000);
|
||||
|
||||
// vmix stuff
|
||||
rateLimiter.addRule({apiKey: String, method: "vmixCharacter"}, 2, 10000);
|
||||
rateLimiter.addRule({apiKey: String, method: "vmixParty"}, 2, 10000);
|
||||
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
|
||||
|
||||
var isRateLimited = function(apiKey, method){
|
||||
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed
|
||||
if (limited) {
|
||||
console.log(`Rate limit hit by API key ${apiKey}`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
// bot API endpoints
|
||||
rateLimiter.addRule({apiKey: String, method: "jsonCharacterSheet"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "getUserId"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "addSpellsToCharacter"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "createCharacter"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "transferCharacterOwnership"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertFeatures"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertProfs"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertEffects"}, 5, 5000);
|
||||
rateLimiter.addRule({apiKey: String, method: "insertClasses"}, 5, 5000);
|
||||
|
||||
isRateLimited = function (apiKey, method) {
|
||||
const limited = !rateLimiter.check({apiKey: apiKey, method: method}).allowed;
|
||||
if (limited) {
|
||||
console.log(`Rate limit hit by API key ${apiKey}`);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,9 +31,17 @@
|
||||
Settings
|
||||
</paper-icon-item>
|
||||
<paper-icon-item id="characterExport">
|
||||
<iron-icon icon="content-copy" item-icon></iron-icon>
|
||||
<iron-icon icon="exit-to-app" item-icon></iron-icon>
|
||||
Export to Improved Initiative
|
||||
</paper-icon-item>
|
||||
<paper-icon-item id="characterCopy">
|
||||
<iron-icon icon="content-copy" item-icon></iron-icon>
|
||||
Make a copy
|
||||
</paper-icon-item>
|
||||
<paper-icon-item id="characterDump">
|
||||
<iron-icon icon="file-download" item-icon></iron-icon>
|
||||
Download a backup
|
||||
</paper-icon-item>
|
||||
</paper-menu>
|
||||
</paper-menu-button>
|
||||
{{else}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
</paper-fab>
|
||||
{{#simpleTooltip class="always"}} New Character {{/simpleTooltip}}
|
||||
</div>
|
||||
<div>
|
||||
<paper-fab icon="file-upload"
|
||||
class="restoreCharacter"
|
||||
mini>
|
||||
</paper-fab>
|
||||
{{#simpleTooltip class="always"}} Restore from backup {{/simpleTooltip}}
|
||||
</div>
|
||||
{{/fabMenu}}
|
||||
</div>
|
||||
</app-header-layout>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template name="characterRestoreDialog">
|
||||
<div class="fit layout vertical">
|
||||
<app-header-layout has-scrolling-region class="new-character-dialog flex">
|
||||
<app-header fixed effects="waterfall">
|
||||
<app-toolbar>
|
||||
<div main-title>Restore Character</div>
|
||||
</app-toolbar>
|
||||
</app-header>
|
||||
<div class="form">
|
||||
<p>
|
||||
Restore a character from a backup file, this will create a new copy of
|
||||
the restored character
|
||||
</p>
|
||||
<paper-input class="fileInput" type="file" label="File"></paper-input><br>
|
||||
{{#if error}}
|
||||
<p style="color: red;">
|
||||
{{error}}
|
||||
</p>
|
||||
{{/if}}
|
||||
{{#if loading}}
|
||||
<paper-spinner active></paper-spinner>
|
||||
{{/if}}
|
||||
</div>
|
||||
</app-header-layout>
|
||||
<div class="buttons layout horizontal end-justified">
|
||||
<paper-button class="cancelButton">
|
||||
Cancel
|
||||
</paper-button>
|
||||
<paper-button class="addButton" disabled={{invalid}}>
|
||||
Restore
|
||||
</paper-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 || '-',
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -51,9 +51,16 @@
|
||||
<iron-icon icon="bug-report" item-icon></iron-icon>
|
||||
Send Feedback
|
||||
</paper-icon-item>
|
||||
<a class="patreon" href="https://www.patreon.com/dicecloud" target="_blank" tabindex="-1">
|
||||
<a class="patreon" href="{{patreonLink}}" target="_blank" tabindex="-1">
|
||||
<paper-icon-item>
|
||||
<iron-icon icon="dicecloud:patreon" item-icon></iron-icon>
|
||||
<iron-icon id="patreon-link-icon" icon="dicecloud:patreon" item-icon></iron-icon>
|
||||
{{#if showPatreonBadge}}
|
||||
<paper-badge
|
||||
icon="av:new-releases"
|
||||
for="patreon-link-icon"
|
||||
label="New post">
|
||||
</paper-badge>
|
||||
{{/if}}
|
||||
Patreon
|
||||
</paper-icon-item>
|
||||
</a>
|
||||
|
||||
@@ -7,6 +7,16 @@ Template.appDrawer.helpers({
|
||||
var user = Meteor.user();
|
||||
return user.profile && user.profile.username || user.username || "My Account";
|
||||
},
|
||||
showPatreonBadge: function(){
|
||||
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||
let user = Meteor.user();
|
||||
if (!post || !user) return false;
|
||||
return post.link !== user.lastPatreonPostClicked;
|
||||
},
|
||||
patreonLink: function(){
|
||||
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||
return (post && post.link) || 'https://www.patreon.com/dicecloud';
|
||||
},
|
||||
});
|
||||
|
||||
let drawerLayout;
|
||||
@@ -37,6 +47,9 @@ Template.appDrawer.events({
|
||||
closeDrawer(instance);
|
||||
},
|
||||
"click .patreon": function(event, instance){
|
||||
let post = PatreonPosts.findOne({}, {sort: {date: -1}});
|
||||
let link = (post && post.link) || 'https://www.patreon.com/dicecloud';
|
||||
Meteor.call('clickPatreonPost', link);
|
||||
ga("send", "event", "externalLink", "patreon");
|
||||
},
|
||||
"click .github": function(event, instance){
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"polyfill": "/components/webcomponentsjs/webcomponents.min.js",
|
||||
"useShadowDom": true,
|
||||
"imports": [
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-layout.html",
|
||||
"/components/app-layout/app-scroll-effects/effects/waterfall.html",
|
||||
"/components/app-layout/app-scroll-effects/effects/parallax-background.html",
|
||||
"/components/app-layout/app-scroll-effects/effects/resize-title.html",
|
||||
|
||||
"/components/iron-collapse/iron-collapse.html",
|
||||
"/components/iron-collapse/iron-collapse.html",
|
||||
"/components/iron-icon/iron-icon.html",
|
||||
"/components/iron-icons/av-icons.html",
|
||||
"/components/iron-icons/editor-icons.html",
|
||||
@@ -21,7 +21,8 @@
|
||||
|
||||
"/components/neon-animation/neon-animation.html",
|
||||
|
||||
"/components/paper-button/paper-button.html",
|
||||
"/components/paper-button/paper-button.html",
|
||||
"/components/paper-badge/paper-badge.html",
|
||||
"/components/paper-swatch-picker/paper-swatch-picker.html",
|
||||
"/components/paper-dialog/paper-dialog.html",
|
||||
"/components/paper-dropdown-menu/paper-dropdown-menu.html",
|
||||
|
||||
471
app/lib/functions/api.js
Normal file
471
app/lib/functions/api.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
JSONExport = function (charId) {
|
||||
const character = {
|
||||
"attacks": Attacks.find({charId: charId}).fetch(),
|
||||
"characters": Characters.find({_id: charId}).fetch(),
|
||||
"classes": Classes.find({charId: charId}).fetch(),
|
||||
"containers": Containers.find({charId: charId}).fetch(),
|
||||
"effects": Effects.find({charId: charId}).fetch(),
|
||||
"experience": Experiences.find({charId: charId}).fetch(),
|
||||
"features": Features.find({charId: charId}).fetch(),
|
||||
"items": Items.find({charId: charId}).fetch(),
|
||||
"notes": Notes.find({charId: charId}).fetch(),
|
||||
"proficiencies": Proficiencies.find({charId: charId}).fetch(),
|
||||
"spellLists": SpellLists.find({charId: charId}).fetch(),
|
||||
"spells": Spells.find({charId: charId}).fetch()
|
||||
};
|
||||
return JSON.stringify(character);
|
||||
};
|
||||
|
||||
Meteor.methods({
|
||||
"insertSpells": function (key, charId, listId, spells) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "addSpellsToCharacter");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let spell of spells) {
|
||||
spell.charId = charId;
|
||||
try {
|
||||
Schemas.Spell.clean(spell);
|
||||
} catch (e) {
|
||||
// console.log(e);
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
spell.parent = {id: listId, collection: "SpellLists"};
|
||||
let id = Spells.direct.insert(spell, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
// console.log(id);
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more spells", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
},
|
||||
|
||||
"insertCharacter": function (key, character) {
|
||||
if (Meteor.isClient) return;
|
||||
assertAuthorized(key, "createCharacter");
|
||||
let error, id;
|
||||
|
||||
character.owner = userIdFromKey(key);
|
||||
try {
|
||||
Schemas.Character.clean(character);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
id = Characters.direct.insert(character, (err) => {
|
||||
if (err)
|
||||
error = err.message;
|
||||
});
|
||||
afterCharacterInsert(id);
|
||||
return {id: id};
|
||||
} else {
|
||||
throw new Meteor.Error(400, "Failed to insert character", JSON.stringify({err: error}));
|
||||
}
|
||||
},
|
||||
|
||||
"deleteCharacter": function (key, charId) {
|
||||
if (Meteor.isClient) return;
|
||||
assertAuthorized(key, "deleteCharacter");
|
||||
if (isOwner(charId, userIdFromKey(key))) {
|
||||
let error;
|
||||
|
||||
Characters.direct.remove({_id: charId}, (err) => {
|
||||
if (err)
|
||||
error = err.message;
|
||||
});
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to delete character", JSON.stringify({err: error}));
|
||||
} else {
|
||||
return {success: true};
|
||||
}
|
||||
} else {
|
||||
throw new Meteor.Error(403, "You do not have permission to delete the requested character");
|
||||
}
|
||||
},
|
||||
|
||||
"transferCharacterOwnership": function (key, charId, newOwner) {
|
||||
if (Meteor.isClient) return;
|
||||
assertAuthorized(key, "transferCharacterOwnership");
|
||||
if (isOwner(charId, userIdFromKey(key))) {
|
||||
let error;
|
||||
Characters.direct.update({_id: charId}, {"$set": {owner: newOwner}}, null,
|
||||
(err) => {
|
||||
if (err)
|
||||
error = err.message;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to update character", JSON.stringify({err: error}));
|
||||
} else {
|
||||
return {success: true};
|
||||
}
|
||||
} else {
|
||||
throw new Meteor.Error(403, "You do not have permission to transfer the requested character");
|
||||
}
|
||||
},
|
||||
|
||||
"insertFeatures": function (key, charId, features) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertFeatures");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let feature of features) {
|
||||
feature.charId = charId;
|
||||
try {
|
||||
Schemas.Feature.clean(feature);
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Features.direct.insert(feature, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more features", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
"insertProfs": function (key, charId, profs) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertProfs");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let prof of profs) {
|
||||
prof.charId = charId;
|
||||
try {
|
||||
Schemas.Proficiency.clean(prof, {filter: false});
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Proficiencies.direct.insert(prof, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more profs", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
},
|
||||
|
||||
"insertEffects": function (key, charId, effects) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertEffects");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let effect of effects) {
|
||||
effect.charId = charId;
|
||||
try {
|
||||
Schemas.Effect.clean(effect, {filter: false});
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Effects.direct.insert(effect, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more effects", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
},
|
||||
|
||||
"insertClasses": function (key, charId, klasses) {
|
||||
if (Meteor.isClient) return;
|
||||
assertCanEdit(key, charId, "insertClasses");
|
||||
let ids = [];
|
||||
let error;
|
||||
for (let klass of klasses) {
|
||||
klass.charId = charId;
|
||||
try {
|
||||
Schemas.Class.clean(klass);
|
||||
} catch (e) {
|
||||
error = e.error;
|
||||
}
|
||||
if (!error) {
|
||||
let id = Classes.direct.insert(klass, (err) => {
|
||||
if (err) {
|
||||
error = err.message;
|
||||
}
|
||||
});
|
||||
ids.push(id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
throw new Meteor.Error(400, "Failed to insert one or more classes", JSON.stringify({
|
||||
err: error,
|
||||
inserted: ids
|
||||
}));
|
||||
} else {
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var assertCanEdit = function (key, charId, method) {
|
||||
if (canEditCharacter(charId, userIdFromKey(key))) {
|
||||
assertAuthorized(key, method);
|
||||
} else {
|
||||
throw new Meteor.Error(403, "You do not have permission to edit the requested character");
|
||||
}
|
||||
};
|
||||
|
||||
var assertAuthorized = function (apiKey, method) {
|
||||
if (!apiKey) {
|
||||
throw new Meteor.Error(403, "You must use an api key to access this api");
|
||||
} else if (!isKeyValid(apiKey)) {
|
||||
throw new Meteor.Error(403, "API key is invalid");
|
||||
} else if (isRateLimited(apiKey, method)) {
|
||||
throw new Meteor.Error(429, "Too many requests", JSON.stringify({
|
||||
"timeToReset": rateLimiter.check({apiKey: apiKey, method: method}).timeToReset
|
||||
}));
|
||||
} else {
|
||||
rateLimiter.increment({apiKey: apiKey, method: method});
|
||||
}
|
||||
};
|
||||
|
||||
var afterCharacterInsert = function (charId) {
|
||||
// Effects
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Constitution modifier for each level",
|
||||
stat: "hitPoints",
|
||||
operation: "add",
|
||||
calculation: "level * constitutionMod",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Proficiency bonus by level",
|
||||
stat: "proficiencyBonus",
|
||||
operation: "add",
|
||||
calculation: "floor(level / 4 + 1.75)",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Dexterity Armor Bonus",
|
||||
stat: "armor",
|
||||
operation: "add",
|
||||
calculation: "dexterityArmor",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Natural Armor",
|
||||
stat: "armor",
|
||||
operation: "base",
|
||||
value: 10,
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
Effects.direct.insert({
|
||||
charId: charId,
|
||||
name: "Natural Carrying Capacity",
|
||||
stat: "carryMultiplier",
|
||||
operation: "base",
|
||||
value: "1",
|
||||
parent: {
|
||||
id: charId,
|
||||
collection: "Characters",
|
||||
group: "Inate",
|
||||
},
|
||||
});
|
||||
// Features
|
||||
let featureId = Features.direct.insert({
|
||||
name: "Base Ability Scores",
|
||||
charId: charId,
|
||||
enabled: true,
|
||||
alwaysEnabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "strength",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "dexterity",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "constitution",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "intelligence",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "wisdom",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
Effects.direct.insert({
|
||||
stat: "charisma",
|
||||
charId: charId,
|
||||
parent: {
|
||||
id: featureId,
|
||||
collection: "Features",
|
||||
},
|
||||
operation: "base",
|
||||
value: 10,
|
||||
enabled: true,
|
||||
});
|
||||
// Items
|
||||
let containerId = Containers.direct.insert({
|
||||
name: "Coin Pouch",
|
||||
charId: charId,
|
||||
isCarried: true,
|
||||
description: "A sturdy pouch for coins",
|
||||
color: "d",
|
||||
});
|
||||
Items.direct.insert({
|
||||
name: "Gold piece",
|
||||
plural: "Gold pieces",
|
||||
charId: charId,
|
||||
quantity: 0,
|
||||
weight: 0.02,
|
||||
value: 1,
|
||||
color: "n",
|
||||
parent: {
|
||||
id: containerId,
|
||||
collection: "Containers",
|
||||
},
|
||||
settings: {
|
||||
showIncrement: true,
|
||||
},
|
||||
});
|
||||
Items.direct.insert({
|
||||
name: "Silver piece",
|
||||
plural: "Silver pieces",
|
||||
charId: charId,
|
||||
quantity: 0,
|
||||
weight: 0.02,
|
||||
value: 0.1,
|
||||
color: "q",
|
||||
parent: {
|
||||
id: containerId,
|
||||
collection: "Containers",
|
||||
},
|
||||
settings: {
|
||||
showIncrement: true,
|
||||
},
|
||||
});
|
||||
Items.direct.insert({
|
||||
name: "Copper piece",
|
||||
plural: "Copper pieces",
|
||||
charId: charId,
|
||||
quantity: 0,
|
||||
weight: 0.02,
|
||||
value: 0.01,
|
||||
color: "s",
|
||||
parent: {
|
||||
id: containerId,
|
||||
collection: "Containers",
|
||||
},
|
||||
settings: {
|
||||
showIncrement: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
99
app/lib/functions/backupRestoreCharacter.js
Normal file
99
app/lib/functions/backupRestoreCharacter.js
Normal file
@@ -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
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
JSONExport = function(charId) {
|
||||
var character = {
|
||||
"attacks": Attacks.find({charId: charId}).fetch(),
|
||||
"characters": Characters.find({_id: charId}).fetch(),
|
||||
"classes": Classes.find({charId: charId}).fetch(),
|
||||
"containers": Containers.find({charId: charId}).fetch(),
|
||||
"effects": Effects.find({charId: charId}).fetch(),
|
||||
"experience": Experiences.find({charId: charId}).fetch(),
|
||||
"features": Features.find({charId: charId}).fetch(),
|
||||
"items": Items.find({charId: charId}).fetch(),
|
||||
"notes": Notes.find({charId: charId}).fetch(),
|
||||
"proficiencies": Proficiencies.find({charId: charId}).fetch(),
|
||||
"spellLists": SpellLists.find({charId: charId}).fetch(),
|
||||
"spells": Spells.find({charId: charId}).fetch()
|
||||
};
|
||||
return JSON.stringify(character);
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
isOwner = function(charId, userId) {
|
||||
userId = userId || Meteor.userId();
|
||||
var char = Characters.findOne(charId, {fields: {owner: 1}});
|
||||
if (!char) return true;
|
||||
return (userId === char.owner);
|
||||
};
|
||||
|
||||
canEditCharacter = function(charId, userId){
|
||||
userId = userId || Meteor.userId();
|
||||
var char = Characters.findOne(charId, {fields: {owner: 1, writers: 1}});
|
||||
@@ -5,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" ||
|
||||
|
||||
26
app/lib/methods/characterCopyPaste.js
Normal file
26
app/lib/methods/characterCopyPaste.js
Normal file
@@ -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;
|
||||
},
|
||||
});
|
||||
93
app/package-lock.json
generated
93
app/package-lock.json
generated
@@ -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.1",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.1.tgz",
|
||||
"integrity": "sha512-dCB3K7/BvAcUmtmh1DzFdv0eXSVJ9IAFt1mw3XZfAexodNRoE29l3xB2EX4wH2q8m/UTzwzEPq/ArYk98kUkBQ=="
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"bower": "^1.7.9",
|
||||
"core-js": "^2.5.7",
|
||||
"fibers": "^2.0.2",
|
||||
"file-saver": "^2.0.1",
|
||||
"meteor-node-stubs": "^0.3.3",
|
||||
"qrcode": "^1.3.0",
|
||||
"source-map-support": "^0.5.9",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
Meteor.publish("user", function(){
|
||||
return Meteor.users.find(this.userId, {fields: {
|
||||
roles: 1,
|
||||
username: 1,
|
||||
profile: 1,
|
||||
apiKey: 1,
|
||||
librarySubscriptions: 1,
|
||||
}});
|
||||
return [
|
||||
Meteor.users.find(this.userId, {fields: {
|
||||
roles: 1,
|
||||
username: 1,
|
||||
profile: 1,
|
||||
apiKey: 1,
|
||||
librarySubscriptions: 1,
|
||||
lastPatreonPostClicked: 1,
|
||||
}}),
|
||||
PatreonPosts.find({},{sort: {dateAdded: -1}, limit: 1})
|
||||
];
|
||||
});
|
||||
|
||||
@@ -2194,9 +2194,9 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"castingTime": "bonus action",
|
||||
"castingTime": "action",
|
||||
"description": "Choose a manufactured metal object, such as a metal weapon or a suit of heavy or medium metal armor, that you can see within range. You cause the object to glow red-hot. Any creature in physical contact with the object takes 2d8 fire damage when you cast the spell. Until the spell ends, you can use a bonus action on each of your subsequent turns to cause this damage again.\n\nIf a creature is holding or wearing the object and takes the damage from it, the creature must succeed on a DC {DC} Constitution saving throw or drop the object if it can. If it doesn’t drop the object, it has disadvantage on attack rolls and ability checks until the start of your next turn.\n\n***At Higher Levels.*** When you cast this spell using a spell slot of 3rd level or higher, the damage increases by 1d8 for each slot level above 2nd.",
|
||||
"duration": "Instantaneous",
|
||||
"duration": "Concentration, up to 1 minute",
|
||||
"level": 2,
|
||||
"range": "60 feet",
|
||||
"school": "Transmutation",
|
||||
@@ -2204,8 +2204,9 @@
|
||||
"name": "Heat Metal",
|
||||
"components": {
|
||||
"verbal": true,
|
||||
"somatic": false,
|
||||
"concentration": false
|
||||
"somatic": true,
|
||||
"concentration": true,
|
||||
"material": "a piece of iron and a flame"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user