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 @@
+
+
+
+ Import character
+
+
+
+ Import a character from another instance of DiceCloud
+
+
+ The character needs to have their sharing permission set to "anyone can view"
+
+
+
+
+
+ Import
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
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