Added the ability to import creatures from other instances of DiceCloud

This commit is contained in:
ThaumRystra
2024-09-03 22:54:44 +02:00
parent 2a3357ce5c
commit e11fb50103
11 changed files with 304 additions and 12 deletions

View File

@@ -1,6 +1,7 @@
{
"public": {
"environment": "production",
"disablePatreon": true
"disablePatreon": true,
"disallowCreatureApiImport": false
}
}
}

View File

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

View File

@@ -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';

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 => {

View File

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

View 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';
}
}

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

View File

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

View File

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