Added the ability to import creatures from other instances of DiceCloud
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"public": {
|
||||
"environment": "production",
|
||||
"disablePatreon": true
|
||||
"disablePatreon": true,
|
||||
"disallowCreatureApiImport": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION';
|
||||
import SimpleSchema from 'simpl-schema';
|
||||
import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
||||
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
||||
import Creatures from '/imports/api/creature/creatures/Creatures';
|
||||
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
|
||||
import CreatureLogs from '/imports/api/creature/log/CreatureLogs';
|
||||
import Experiences from '/imports/api/creature/experience/Experiences';
|
||||
import { removeCreatureWork } from '/imports/api/creature/creatures/methods/removeCreature';
|
||||
import assertHasCharactersSlots from '/imports/api/creature/creatures/methods/assertHasCharacterSlots';
|
||||
import verifyArchiveSafety from '/imports/api/creature/archive/methods/verifyArchiveSafety';
|
||||
|
||||
let migrateApiCharacter;
|
||||
if (Meteor.isServer) {
|
||||
migrateApiCharacter = require('/imports/migrations/apiCreature/migrateApiCreature2To3.js').default;
|
||||
}
|
||||
|
||||
function importApiCreature(apiCreature, userId) {
|
||||
const apiVersion = apiCreature.meta?.schemaVersion ?? 2;
|
||||
const creatureId = apiCreature.creatures[0]._id;
|
||||
if (SCHEMA_VERSION < apiVersion) {
|
||||
throw new Meteor.Error('Incompatible',
|
||||
'The creature on the remote server is from a newer version of DiceCloud')
|
||||
}
|
||||
|
||||
// Migrate and verify the archive meets the current schema
|
||||
migrateApiCharacter(apiCreature);
|
||||
|
||||
// Asset that the api creature is (mildly) safe
|
||||
verifyArchiveSafety({
|
||||
creature: apiCreature.creatures[0],
|
||||
properties: apiCreature.creatureProperties ?? [],
|
||||
experiences: apiCreature.experiences ?? [],
|
||||
logs: apiCreature.logs ?? [],
|
||||
});
|
||||
|
||||
// Don't upload creatures twice
|
||||
const existingCreature = Creatures.findOne(apiCreature.creatures[0]._id, {
|
||||
fields: { _id: 1 }
|
||||
});
|
||||
|
||||
if (existingCreature) throw new Meteor.Error('Already exists',
|
||||
'The creature you are trying to import already exists in this database.')
|
||||
|
||||
// Ensure the user owns the restored creature
|
||||
apiCreature.creatures[0].owner = userId;
|
||||
|
||||
// Ensure there is only 1 creature being imported
|
||||
if (apiCreature.creatures.length !== 1) {
|
||||
throw new Meteor.Error('invalid-import',
|
||||
'One and only one creature must be imported at a time'
|
||||
)
|
||||
}
|
||||
|
||||
// Insert the creature sub documents
|
||||
// They still have their original _id's
|
||||
Creatures.insert(apiCreature.creatures[0]);
|
||||
try {
|
||||
// Add all the properties
|
||||
if (apiCreature.creatureProperties && apiCreature.creatureProperties.length) {
|
||||
CreatureProperties.batchInsert(apiCreature.creatureProperties);
|
||||
}
|
||||
if (apiCreature.experiences && apiCreature.experiences.length) {
|
||||
Experiences.batchInsert(apiCreature.experiences);
|
||||
}
|
||||
if (apiCreature.logs && apiCreature.logs.length) {
|
||||
CreatureLogs.batchInsert(apiCreature.logs);
|
||||
}
|
||||
} catch (e) {
|
||||
// If the above fails, delete the inserted creature
|
||||
removeCreatureWork(creatureId);
|
||||
throw e;
|
||||
}
|
||||
return creatureId;
|
||||
}
|
||||
|
||||
const importCharacterFromDiceCloudInstance = new ValidatedMethod({
|
||||
name: 'Creatures.methods.importFromInstance',
|
||||
validate: null,
|
||||
mixins: [RateLimiterMixin],
|
||||
rateLimit: {
|
||||
numRequests: 10,
|
||||
timeInterval: 5000,
|
||||
},
|
||||
async run({ characterData }) {
|
||||
if (Meteor.settings.public.disallowCreatureApiImport) throw new Meteor.Error('not-allowed',
|
||||
'This instance of DiceCloud has disallowed creature imports')
|
||||
// fetch the file
|
||||
if (!characterData) {
|
||||
throw new Meteor.Error('no-input',
|
||||
'No character data was provided');
|
||||
}
|
||||
assertHasCharactersSlots(this.userId);
|
||||
if (Meteor.isServer) {
|
||||
return importApiCreature(characterData, this.userId)
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default importCharacterFromDiceCloudInstance;
|
||||
@@ -1,4 +1,5 @@
|
||||
import '/imports/api/creature/creatures/methods/changeAllowedLibraries';
|
||||
import '/imports/api/creature/creatures/methods/importCharacterFromDiceCloudInstance.js';
|
||||
import '/imports/api/creature/creatures/methods/insertCreature';
|
||||
import '/imports/api/creature/creatures/methods/removeCreature';
|
||||
import '/imports/api/creature/creatures/methods/updateCreature';
|
||||
import '/imports/api/creature/creatures/methods/changeAllowedLibraries';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<dialog-base>
|
||||
<v-toolbar-title slot="toolbar">
|
||||
Import character
|
||||
</v-toolbar-title>
|
||||
<div>
|
||||
<h2 class="mb-4">
|
||||
Import a character from another instance of DiceCloud
|
||||
</h2>
|
||||
<p>
|
||||
The character needs to have their sharing permission set to "anyone can view"
|
||||
</p>
|
||||
<text-field
|
||||
:value="currentUrl"
|
||||
:error-messages="importError"
|
||||
@change="setUrl"
|
||||
/>
|
||||
<div class="d-flex justify-center">
|
||||
<v-slide-x-transition>
|
||||
<v-btn
|
||||
v-show="characterData"
|
||||
:loading="loadingImportCharacter"
|
||||
color="primary"
|
||||
@click="importCharacterData"
|
||||
>
|
||||
Import
|
||||
</v-btn>
|
||||
</v-slide-x-transition>
|
||||
</div>
|
||||
</div>
|
||||
<template slot="actions">
|
||||
<v-btn
|
||||
text
|
||||
@click="$emit('pop')"
|
||||
>
|
||||
Cancel
|
||||
</v-btn>
|
||||
</template>
|
||||
</dialog-base>
|
||||
</template>
|
||||
|
||||
<script lang="js">
|
||||
import DialogBase from '/imports/client/ui/dialogStack/DialogBase.vue';
|
||||
import importCharacterFromDiceCloudInstance from '/imports/api/creature/creatures/methods/importCharacterFromDiceCloudInstance'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DialogBase,
|
||||
},
|
||||
data(){return {
|
||||
loadingImportCharacter: false,
|
||||
importError: undefined,
|
||||
currentUrl: '',
|
||||
characterData: undefined,
|
||||
}},
|
||||
computed: {
|
||||
biographyAlert() {
|
||||
if (!this.name) return 'Name required';
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
meteor: {
|
||||
$subscribe: {
|
||||
'libraries': [],
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async setUrl(val, ack) {
|
||||
const regex = /(https?:\/\/)([\w|.]+)\/character\/([^/]+)\/(.+)/;
|
||||
if (!regex.test(val)) {
|
||||
ack('Not a valid character URL');
|
||||
return;
|
||||
}
|
||||
const newUrl = val.replace(regex, '$1$2/api/creature/$3');
|
||||
let characterData = undefined;
|
||||
this.importError = undefined;
|
||||
try {
|
||||
const res = await fetch(newUrl);
|
||||
characterData = await res.json();
|
||||
} catch (e) {
|
||||
ack(e);
|
||||
return;
|
||||
}
|
||||
if (characterData.error) {
|
||||
if (characterData.reason === 'No user ID. Are you logged in?') {
|
||||
ack('This character\'s sharing settings are not set to allow anyone to view')
|
||||
} else {
|
||||
ack(characterData.reason ?? characterData.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.characterData = characterData
|
||||
this.currentUrl = val;
|
||||
ack();
|
||||
},
|
||||
async importCharacterData() {
|
||||
this.loadingImportCharacter = true;
|
||||
importCharacterFromDiceCloudInstance.call({
|
||||
characterData: this.characterData
|
||||
}, (error, characterId) => {
|
||||
this.loadingImportCharacter = false;
|
||||
if (error) {
|
||||
this.importError = error.reason || error.message || error.toString();
|
||||
return;
|
||||
}
|
||||
this.$emit('pop', characterId);
|
||||
});
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import TransferOwnershipDialog from '/imports/client/ui/sharing/TransferOwnershi
|
||||
|
||||
// Lazily load less common dialogs
|
||||
const ArchiveDialog = () => import('/imports/client/ui/creature/archive/ArchiveDialog.vue');
|
||||
const CharacterImportDialog = () => import('/imports/client/ui/creature/character/CharacterImportDialog.vue');
|
||||
const CreatureFromLibraryDialog = () => import('/imports/client/ui/tabletop/CreatureFromLibraryDialog.vue');
|
||||
const DeleteUserAccountDialog = () => import('/imports/client/ui/user/DeleteUserAccountDialog.vue');
|
||||
const DependencyGraphDialog = () => import('/imports/client/ui/creature/dependencyGraph/DependencyGraphDialog.vue');
|
||||
@@ -43,6 +44,7 @@ export default {
|
||||
ArchiveDialog,
|
||||
CastSpellWithSlotDialog,
|
||||
CharacterCreationDialog,
|
||||
CharacterImportDialog,
|
||||
CharacterSheetDialog,
|
||||
CreatureFormDialog,
|
||||
CreatureFromLibraryDialog,
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
/>
|
||||
</v-card>
|
||||
<div class="layout justify-end mt-2">
|
||||
<v-btn
|
||||
v-if="showImportButton"
|
||||
text
|
||||
data-id="import-character-button"
|
||||
@click="importCharacter"
|
||||
>
|
||||
import character
|
||||
</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
:loading="loadingInsertFolder"
|
||||
@@ -161,6 +169,9 @@ export default {
|
||||
let currentCharacterCount = this.creatureCount;
|
||||
return tier.characterSlots !== -1 && currentCharacterCount > tier.characterSlots
|
||||
},
|
||||
showImportButton() {
|
||||
return !Meteor.settings.public?.disallowCreatureApiImport;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
insertCharacter() {
|
||||
@@ -171,6 +182,14 @@ export default {
|
||||
callback: creatureId => creatureId,
|
||||
});
|
||||
},
|
||||
importCharacter() {
|
||||
const self = this;
|
||||
self.$store.commit('pushDialogStack', {
|
||||
component: 'character-import-dialog',
|
||||
elementId: 'import-character-button',
|
||||
callback: creatureId => creatureId,
|
||||
});
|
||||
},
|
||||
insertFolder() {
|
||||
this.loadingInsertFolder = true;
|
||||
insertCreatureFolder.call(error => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties';
|
||||
|
||||
export default function cleanApiCreatureAtCurrent(apiCreature) {
|
||||
apiCreature.creatureProperties = apiCreature.creatureProperties.map(prop => {
|
||||
let cleanProp = prop;
|
||||
try {
|
||||
const schema = CreatureProperties.simpleSchema(prop);
|
||||
// Clean according to schema
|
||||
cleanProp = schema.clean(prop);
|
||||
schema.validate(cleanProp);
|
||||
} catch (e) {
|
||||
console.warn('Failed to clean archive prop', { propId: prop._id, error: e.message || e.reason || e.toString() });
|
||||
}
|
||||
return cleanProp;
|
||||
});
|
||||
}
|
||||
20
app/imports/migrations/apiCreature/migrateApiCreature.js
Normal file
20
app/imports/migrations/apiCreature/migrateApiCreature.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import migrate2To3 from './migrateApiCreature2To3';
|
||||
import cleanAtCurrent from './cleanApiCreatureAtCurrent';
|
||||
|
||||
/* eslint no-fallthrough: "off" -- Using switch fallthrough to run all
|
||||
migration steps after the current version of the file. */
|
||||
export default function migrateApiCreature(apiCreature) {
|
||||
const apiVersion = apiCreature.meta?.schemaVersion ?? 2;
|
||||
switch (apiVersion) {
|
||||
case 'version1':
|
||||
case 1:
|
||||
throw new Meteor.Error('not-supported', 'Importing characters is not supported for the version of the linked instance of DiceCloud')
|
||||
case 2:
|
||||
migrate2To3(apiCreature);
|
||||
case 3:
|
||||
cleanAtCurrent(apiCreature);
|
||||
break;
|
||||
default:
|
||||
throw 'Archive version not supported';
|
||||
}
|
||||
}
|
||||
12
app/imports/migrations/apiCreature/migrateApiCreature2To3.js
Normal file
12
app/imports/migrations/apiCreature/migrateApiCreature2To3.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import migrateProperty2To3 from '/imports/migrations/archive/properties/migrateProperty2To3';
|
||||
|
||||
export default function migrateApiCreature2To3(creature) {
|
||||
creature.creatureProperties = creature.creatureProperties.map(prop => {
|
||||
try {
|
||||
migrateProperty2To3(prop);
|
||||
} catch (e) {
|
||||
console.warn('Property migration 2 -> 3 failed: ', { propId: prop._id, error: e.message || e.reason || e.toString() });
|
||||
}
|
||||
return prop;
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import migrateProperty2To3 from '/imports/migrations/archive/properties/migrateProperty2To3';
|
||||
|
||||
export default function migrate2To3(archive) {
|
||||
archive.properties = archive.properties.map(prop => {
|
||||
try {
|
||||
prop.root = prop.ancestors[0];
|
||||
if (!prop.root) {
|
||||
throw 'Property has no root ancestor, will become orphaned'
|
||||
}
|
||||
if (prop.parent?.collection === 'creatureProperties') {
|
||||
prop.parentId = prop.parent.id;
|
||||
}
|
||||
prop.left = prop.order;
|
||||
prop.right = prop.order;
|
||||
migrateProperty2To3(prop);
|
||||
} catch (e) {
|
||||
console.warn('Property migration 2 -> 3 failed: ', { propId: prop._id, error: e.message || e.reason || e.toString() });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export default function migrateProperty2To3(prop) {
|
||||
prop.root = prop.ancestors[0];
|
||||
if (!prop.root) {
|
||||
throw 'Property has no root ancestor, will become orphaned'
|
||||
}
|
||||
if (prop.parent?.collection === 'creatureProperties') {
|
||||
prop.parentId = prop.parent.id;
|
||||
}
|
||||
prop.left = prop.order;
|
||||
prop.right = prop.order;
|
||||
}
|
||||
Reference in New Issue
Block a user