From e11fb5010341a4474a0ed892a114855dc932b28a Mon Sep 17 00:00:00 2001 From: ThaumRystra Date: Tue, 3 Sep 2024 22:54:44 +0200 Subject: [PATCH] Added the ability to import creatures from other instances of DiceCloud --- app/exampleMeteorSettings.json | 5 +- .../importCharacterFromDiceCloudInstance.js | 100 +++++++++++++++ .../api/creature/creatures/methods/index.js | 3 +- .../character/CharacterImportDialog.vue | 116 ++++++++++++++++++ .../ui/dialogStack/DialogComponentIndex.js | 2 + app/imports/client/ui/pages/CharacterList.vue | 19 +++ .../apiCreature/cleanApiCreatureAtCurrent.js | 16 +++ .../apiCreature/migrateApiCreature.js | 20 +++ .../apiCreature/migrateApiCreature2To3.js | 12 ++ .../migrations/archive/migrateArchive2To3.js | 12 +- .../archive/properties/migrateProperty2To3.js | 11 ++ 11 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 app/imports/api/creature/creatures/methods/importCharacterFromDiceCloudInstance.js create mode 100644 app/imports/client/ui/creature/character/CharacterImportDialog.vue create mode 100644 app/imports/migrations/apiCreature/cleanApiCreatureAtCurrent.js create mode 100644 app/imports/migrations/apiCreature/migrateApiCreature.js create mode 100644 app/imports/migrations/apiCreature/migrateApiCreature2To3.js create mode 100644 app/imports/migrations/archive/properties/migrateProperty2To3.js diff --git a/app/exampleMeteorSettings.json b/app/exampleMeteorSettings.json index 76d4108c..56083303 100644 --- a/app/exampleMeteorSettings.json +++ b/app/exampleMeteorSettings.json @@ -1,6 +1,7 @@ { "public": { "environment": "production", - "disablePatreon": true + "disablePatreon": true, + "disallowCreatureApiImport": false } -} +} \ No newline at end of file diff --git a/app/imports/api/creature/creatures/methods/importCharacterFromDiceCloudInstance.js b/app/imports/api/creature/creatures/methods/importCharacterFromDiceCloudInstance.js new file mode 100644 index 00000000..ed4e24af --- /dev/null +++ b/app/imports/api/creature/creatures/methods/importCharacterFromDiceCloudInstance.js @@ -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; diff --git a/app/imports/api/creature/creatures/methods/index.js b/app/imports/api/creature/creatures/methods/index.js index 25715c2c..9d0cf4b2 100644 --- a/app/imports/api/creature/creatures/methods/index.js +++ b/app/imports/api/creature/creatures/methods/index.js @@ -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'; diff --git a/app/imports/client/ui/creature/character/CharacterImportDialog.vue b/app/imports/client/ui/creature/character/CharacterImportDialog.vue new file mode 100644 index 00000000..7da64420 --- /dev/null +++ b/app/imports/client/ui/creature/character/CharacterImportDialog.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/app/imports/client/ui/dialogStack/DialogComponentIndex.js b/app/imports/client/ui/dialogStack/DialogComponentIndex.js index 96c3a1f4..4f35aa83 100644 --- a/app/imports/client/ui/dialogStack/DialogComponentIndex.js +++ b/app/imports/client/ui/dialogStack/DialogComponentIndex.js @@ -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, diff --git a/app/imports/client/ui/pages/CharacterList.vue b/app/imports/client/ui/pages/CharacterList.vue index cd446b80..ac3663a8 100644 --- a/app/imports/client/ui/pages/CharacterList.vue +++ b/app/imports/client/ui/pages/CharacterList.vue @@ -50,6 +50,14 @@ />
+ + import character + 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 => { diff --git a/app/imports/migrations/apiCreature/cleanApiCreatureAtCurrent.js b/app/imports/migrations/apiCreature/cleanApiCreatureAtCurrent.js new file mode 100644 index 00000000..25c2f6f7 --- /dev/null +++ b/app/imports/migrations/apiCreature/cleanApiCreatureAtCurrent.js @@ -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; + }); +} diff --git a/app/imports/migrations/apiCreature/migrateApiCreature.js b/app/imports/migrations/apiCreature/migrateApiCreature.js new file mode 100644 index 00000000..4adb643a --- /dev/null +++ b/app/imports/migrations/apiCreature/migrateApiCreature.js @@ -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'; + } +} diff --git a/app/imports/migrations/apiCreature/migrateApiCreature2To3.js b/app/imports/migrations/apiCreature/migrateApiCreature2To3.js new file mode 100644 index 00000000..a6d90b97 --- /dev/null +++ b/app/imports/migrations/apiCreature/migrateApiCreature2To3.js @@ -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; + }); +} diff --git a/app/imports/migrations/archive/migrateArchive2To3.js b/app/imports/migrations/archive/migrateArchive2To3.js index e46e82e9..67b9021c 100644 --- a/app/imports/migrations/archive/migrateArchive2To3.js +++ b/app/imports/migrations/archive/migrateArchive2To3.js @@ -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() }); } diff --git a/app/imports/migrations/archive/properties/migrateProperty2To3.js b/app/imports/migrations/archive/properties/migrateProperty2To3.js new file mode 100644 index 00000000..6df18540 --- /dev/null +++ b/app/imports/migrations/archive/properties/migrateProperty2To3.js @@ -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; +} \ No newline at end of file