diff --git a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js
index 57d0002b6..bce9ecd3c 100644
--- a/app/imports/api/creature/archive/methods/archiveCreatureToFile.js
+++ b/app/imports/api/creature/archive/methods/archiveCreatureToFile.js
@@ -9,6 +9,7 @@ import CreatureProperties from '/imports/api/creature/creatureProperties/Creatur
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 { toSoftArchive } from '/imports/api/creature/archive/methods/softArchiveCreature';
import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles';
import { getFilter } from '/imports/api/parenting/parentingFunctions';
@@ -34,7 +35,7 @@ export function getArchiveObj(creatureId) {
return archiveCreature;
}
-export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creatureId, callback) {
+export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creatureId, autoArchive, callback) {
const archive = getArchiveObj(creatureId);
const buffer = Buffer.from(JSON.stringify(archive, null, 2));
ArchiveCreatureFiles.write(buffer, {
@@ -45,6 +46,7 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat
schemaVersion: SCHEMA_VERSION,
creatureId: archive.creature._id,
creatureName: archive.creature.name,
+ auto: !!autoArchive,
},
}, (error, fileRef) => {
if (error) {
@@ -52,7 +54,11 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat
callback(error);
} else if (!Meteor.settings.useS3) {
// If we aren't using s3, remove the creature and call the callback
- removeCreatureWork(creatureId);
+ if (autoArchive) {
+ toSoftArchive(creatureId, fileRef._id);
+ } else {
+ removeCreatureWork(creatureId);
+ }
callback();
} else {
// Wait for s3Result event that occurs when the s3 attempt to write ends.
@@ -64,7 +70,11 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat
ArchiveCreatureFiles.off('s3Result', resultHandler);
// Remove the creature if there was no error
if (!s3Error) {
- removeCreatureWork(creatureId);
+ if (autoArchive) {
+ toSoftArchive(creatureId, fileRef._id);
+ } else {
+ removeCreatureWork(creatureId);
+ }
}
// Alert the callback that we're done
callback(s3Error);
@@ -74,6 +84,26 @@ export const archiveCreature = Meteor.wrapAsync(function archiveCreatureFn(creat
}, true);
});
+export function softArchiveToArchive(creatureId) {
+ let creature = Creatures.findOne({ _id: creatureId });
+ Creatures.remove({ _id: creatureId });
+ ArchiveCreatureFiles.update(
+ {
+ _id: creature.archiveId,
+ 'meta.creatureId': creature._id,
+ 'meta.auto': true,
+ },
+ {
+ $set: { 'meta.auto': false },
+ }, error => {
+ this.archiveActionLoading = false;
+ if (!error) return;
+ console.error(error);
+ snackbar({text: error.reason});
+ }
+ );
+}
+
const archiveCreatureToFile = new ValidatedMethod({
name: 'Creatures.methods.archiveCreatureToFile',
validate: new SimpleSchema({
@@ -90,7 +120,12 @@ const archiveCreatureToFile = new ValidatedMethod({
async run({ creatureId }) {
assertOwnership(creatureId, this.userId);
if (Meteor.isServer) {
- archiveCreature(creatureId);
+ let creature = Creatures.findOne({ _id: creatureId }, { fields: { archiveId: 1 }});
+ if (creature.archiveId) {
+ softArchiveToArchive(creatureId);
+ } else {
+ archiveCreature(creatureId, false);
+ }
} else {
removeCreatureWork(creatureId);
}
diff --git a/app/imports/api/creature/archive/methods/removeArchiveCreature.js b/app/imports/api/creature/archive/methods/removeArchiveCreature.js
index 7018b0b13..bc57ccea3 100644
--- a/app/imports/api/creature/archive/methods/removeArchiveCreature.js
+++ b/app/imports/api/creature/archive/methods/removeArchiveCreature.js
@@ -2,6 +2,7 @@ import SimpleSchema from 'simpl-schema';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles';
+import Creatures from '/imports/api/creature/creatures/Creatures';
import { incrementFileStorageUsed } from '/imports/api/users/methods/updateFileStorageUsed';
const removeArchiveCreature = new ValidatedMethod({
@@ -30,6 +31,15 @@ const removeArchiveCreature = new ValidatedMethod({
throw new Meteor.Error('Permission denied',
'You can only restore creatures you own');
}
+ if (file.meta.auto) {
+ Creatures.remove(
+ {
+ _id: file.meta.creatureId,
+ owner: this.userId,
+ archiveId: file._id,
+ }
+ );
+ }
//Remove the archive once the restore succeeded
ArchiveCreatureFiles.remove({ _id: fileId });
// Update the user's file storage limits
diff --git a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js
index 4a8da16aa..12c4725ea 100644
--- a/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js
+++ b/app/imports/api/creature/archive/methods/restoreCreatureFromFile.js
@@ -101,4 +101,84 @@ const restoreCreaturefromFile = new ValidatedMethod({
},
});
+export const restoreSoftArchive = Meteor.wrapAsync(async function restoreSoftArchiveFn(creatureId, callback) {
+ if (!Meteor.isServer) return;
+
+ // Verify that the creature and archive match
+ const existingCreature = Creatures.findOne(
+ { _id: creatureId },
+ {
+ fields: {
+ _id: 1,
+ owner: 1,
+ archiveId: 1,
+ },
+ }
+ );
+ if (!existingCreature) throw new Meteor.Error('Doesn\'t exist',
+ 'The creature you are trying to restore doesn\'t exist');
+
+ if (!existingCreature.archiveId) throw new Meteor.Error('No Linked Archive',
+ 'The creature doesn\'t have a linked archive.');
+
+ const archiveFile = ArchiveCreatureFiles.findOne({
+ _id: existingCreature.archiveId,
+ userId: existingCreature.owner,
+ 'meta.auto': true,
+ 'meta.creatureId': creatureId,
+ });
+
+ const archive = await ArchiveCreatureFiles.readJSONFile(archiveFile);
+
+ if (SCHEMA_VERSION < archive.meta.schemaVersion) {
+ throw new Meteor.Error('Incompatible',
+ 'The archive file is from a newer version. Update required to read.')
+ }
+
+ // Migrate and verify the archive meets the current schema
+ migrateArchive(archive);
+
+ // Asset that the archive is safe
+ verifyArchiveSafety(archive);
+
+ try {
+ // Add all the properties
+ if (archive.properties && archive.properties.length) {
+ CreatureProperties.batchInsert(archive.properties);
+ }
+ if (archive.experiences && archive.experiences.length) {
+ Experiences.batchInsert(archive.experiences);
+ }
+ if (archive.logs && archive.logs.length) {
+ CreatureLogs.batchInsert(archive.logs);
+ }
+
+ Creatures.update(
+ { _id: creatureId },
+ { $unset: {
+ archiveId: true,
+ }}
+ );
+ } catch (e) {
+ // If the above fails, delete the inserted creature
+ CreatureVariables.remove({ _creatureId: creatureId });
+ CreatureProperties.remove(getFilter.descendantsOfRoot(creatureId));
+ CreatureLogs.remove({ creatureId });
+ Experiences.remove({ creatureId });
+ Creatures.update(
+ { _id: creatureId },
+ { $set: {
+ archiveId: archiveFile._id,
+ }}
+ );
+ console.log(e);
+ throw e;
+ }
+ //Remove the archive once the restore succeeded
+ ArchiveCreatureFiles.remove({ _id: archiveFile._id });
+ // Update the user's file storage limits
+ incrementFileStorageUsed(archiveFile.userId, -archiveFile.size);
+ callback();
+});
+
export default restoreCreaturefromFile;
diff --git a/app/imports/api/creature/archive/methods/softArchiveCreature.js b/app/imports/api/creature/archive/methods/softArchiveCreature.js
new file mode 100644
index 000000000..e7eb31a9f
--- /dev/null
+++ b/app/imports/api/creature/archive/methods/softArchiveCreature.js
@@ -0,0 +1,27 @@
+import Creatures from '/imports/api/creature/creatures/Creatures';
+import CreatureVariables from '/imports/api/creature/creatures/CreatureVariables';
+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 { getFilter } from '/imports/api/parenting/parentingFunctions';
+
+function removeRelatedDocuments(creatureId) {
+ CreatureVariables.remove({ _creatureId: creatureId });
+ CreatureProperties.remove(getFilter.descendantsOfRoot(creatureId));
+ CreatureLogs.remove({ creatureId });
+ Experiences.remove({ creatureId });
+}
+
+export function toSoftArchive(creatureId, archiveId) {
+ if (!Meteor.isServer) return;
+
+ Creatures.update(
+ { _id: creatureId },
+ {
+ $set: {
+ archiveId: archiveId,
+ },
+ }
+ );
+ removeRelatedDocuments(creatureId);
+}
diff --git a/app/imports/api/creature/creatures/Creatures.ts b/app/imports/api/creature/creatures/Creatures.ts
index e6b170e61..1d1e0bf24 100644
--- a/app/imports/api/creature/creatures/Creatures.ts
+++ b/app/imports/api/creature/creatures/Creatures.ts
@@ -180,6 +180,12 @@ const CreatureSchema = TypedSimpleSchema.from({
type: CreatureSettingsSchema,
defaultValue: {},
},
+
+ // Was auto archived
+ 'archiveId': {
+ type: String,
+ optional: true,
+ },
})
.extend(ColorSchema)
.extend(SharingSchema);
diff --git a/app/imports/api/users/methods/updateFileStorageUsed.js b/app/imports/api/users/methods/updateFileStorageUsed.js
index 656915b22..d055727c1 100644
--- a/app/imports/api/users/methods/updateFileStorageUsed.js
+++ b/app/imports/api/users/methods/updateFileStorageUsed.js
@@ -2,7 +2,6 @@ import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import ArchiveCreatureFiles from '/imports/api/creature/archive/ArchiveCreatureFiles';
import UserImages from '/imports/api/files/userImages/UserImages';
-const fileCollections = [ArchiveCreatureFiles, UserImages];
const updateFileStorageUsed = new ValidatedMethod({
name: 'users.recalculateFileStorageUsed',
@@ -33,10 +32,11 @@ export function updateFileStorageUsedWork(userId) {
}
let sum = 0;
- fileCollections.forEach(collection => {
- collection.find({ userId }, { fields: { size: 1 } }).forEach(file => {
- sum += file.size;
- });
+ ArchiveCreatureFiles.find({ userId }, { fields: { size: 1 } }).forEach(file => {
+ sum += file.size;
+ });
+ UserImages.find({ userId }, { fields: { size: 1 } }).forEach(file => {
+ sum += file.size;
});
Meteor.users.update(userId, {
diff --git a/app/imports/client/ui/creature/archive/ArchiveDialog.vue b/app/imports/client/ui/creature/archive/ArchiveDialog.vue
index 8113ac427..67610d963 100644
--- a/app/imports/client/ui/creature/archive/ArchiveDialog.vue
+++ b/app/imports/client/ui/creature/archive/ArchiveDialog.vue
@@ -27,8 +27,8 @@
selection
:creatures="mode === 'archive' ? CreaturesWithNoParty : archiveCreaturesWithNoParty"
:folders="mode === 'archive' ? folders : archivefolders"
- :selected-creature="selectedCreature"
- @creature-selected="id => selectedCreature = id"
+ :selected-creature-id="selectedCreatureId"
+ @creature-selected="id => selectedCreatureId = id"
/>
{
this.archiveActionLoading = false;
if (!error) return;
console.error(error);
snackbar({text: error.reason});
});
- } else if (this.mode === 'restore'){
+ } else if (this.mode === 'restore') {
restoreCreatureFromFile.call({
- fileId: this.selectedCreature,
+ fileId: this.selectedCreatureId,
}, error => {
this.archiveActionLoading = false;
if (!error) return;
@@ -139,7 +140,7 @@ export default {
snackbar({text: error.reason});
});
}
- this.selectedCreature = null;
+ this.selectedCreatureId = null;
}
},
meteor: {
@@ -194,6 +195,10 @@ export default {
folder.creatures = ArchiveCreatureFiles.find(
{
'meta.creatureId': {$in: folder.creatures || []},
+ $or: [
+ { 'meta.auto': false },
+ { 'meta.auto': { $exists: false } },
+ ],
userId,
}, {
sort: {'meta.creatureName': 1},
@@ -211,6 +216,10 @@ export default {
return ArchiveCreatureFiles.find(
{
'meta.creatureId': {$nin: folderChars},
+ $or: [
+ { 'meta.auto': false },
+ { 'meta.auto': { $exists: false } },
+ ],
userId,
}, {
sort: {'meta.creatureName': 1},
diff --git a/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue b/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue
index 5c4806d59..ecf5be234 100644
--- a/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue
+++ b/app/imports/client/ui/creature/creatureList/CreatureFolderList.vue
@@ -8,7 +8,7 @@
$emit('creature-selected', id)"
/>
@@ -34,7 +34,7 @@
:creatures="folder.creatures"
:folder-id="folder._id"
:selection="selection"
- :selected-creature="selectedCreature"
+ :selected-creature-id="selectedCreatureId"
:dense="dense"
@creature-selected="id => $emit('creature-selected', id)"
/>
@@ -62,7 +62,7 @@ export default {
default: () => [],
},
selection: Boolean,
- selectedCreature: {
+ selectedCreatureId: {
type: String,
default: undefined,
},
diff --git a/app/imports/client/ui/creature/creatureList/CreatureList.vue b/app/imports/client/ui/creature/creatureList/CreatureList.vue
index f8869b013..d300c74fc 100644
--- a/app/imports/client/ui/creature/creatureList/CreatureList.vue
+++ b/app/imports/client/ui/creature/creatureList/CreatureList.vue
@@ -15,7 +15,7 @@
class="creature"
:model="creature"
:selection="selection"
- :is-selected="selectedCreature === creature._id || selectedCreatures.has(creature._id)"
+ :is-selected="selectedCreatureId === creature._id || selectedCreatureIds.has(creature._id)"
v-bind="selection ? {} : {to: creature.url}"
:dense="dense"
:data-id="dense ? undefined : creature._id"
@@ -45,11 +45,11 @@
default: null,
},
selection: Boolean,
- selectedCreature: {
+ selectedCreatureId: {
type: String,
default: undefined,
},
- selectedCreatures: {
+ selectedCreatureIds: {
type: Set,
default: () => new Set(),
},
diff --git a/app/imports/client/ui/files/ArchiveFileCard.vue b/app/imports/client/ui/files/ArchiveFileCard.vue
index 9eb926ac8..95dff9b19 100644
--- a/app/imports/client/ui/files/ArchiveFileCard.vue
+++ b/app/imports/client/ui/files/ArchiveFileCard.vue
@@ -4,17 +4,24 @@
{{ model.meta.creatureName }}
- {{ model.size }}
+ {{ model.size }} {{ model.meta.auto ? "(Automatically Archived)" : ""}}
Restore
+
+ Open
+
{
f.size = prettyBytes(f.size);
f.link = ArchiveCreatureFiles.link(f);
+ f.url = `/character/${f.meta.creatureId}/${getCreatureUrlName({ name: f.meta.creatureName })}`
return f;
});
},
diff --git a/app/imports/server/cron/archiveOldCreatures.js b/app/imports/server/cron/archiveOldCreatures.js
new file mode 100644
index 000000000..2755064a8
--- /dev/null
+++ b/app/imports/server/cron/archiveOldCreatures.js
@@ -0,0 +1,40 @@
+import Creatures from '/imports/api/creature/creatures/Creatures';
+import { archiveCreature } from '/imports/api/creature/archive/methods/archiveCreatureToFile';
+import { assertAdmin } from '/imports/api/sharing/sharingPermissions';
+import { SyncedCron } from 'meteor/littledata:synced-cron';
+const archiveAfterDays = Meteor.settings?.archiveAfterDays;
+
+Meteor.startup(() => {
+ /**
+ * Archive all creatures older than the configured amount of days.
+ */
+ const archiveOldCreatures = function () {
+ if (typeof archiveAfterDays != 'number') {
+ return;
+ }
+ const now = new Date();
+ const expire = new Date(now.getTime() - (archiveAfterDays * 24 * 60 * 60 * 1000));
+ Creatures.find({ lastComputedAt: { $lt: expire }, archiveId: { $exists: false } }, { _id: 1 }).forEach( creature => {
+ archiveCreature(creature._id, true);
+ });
+ }
+
+ SyncedCron.add({
+ name: 'archiveOldCreatures',
+ schedule: function (parser) {
+ return parser.text('every 10 minutes');
+ },
+ job: archiveOldCreatures,
+ });
+
+ SyncedCron.start();
+
+ // Add a method to manually trigger removal
+ Meteor.methods({
+ archiveOldCreatures() {
+ assertAdmin(this.userId);
+ this.unblock();
+ archiveOldCreatures();
+ },
+ });
+});
diff --git a/app/imports/server/publications/characterList.js b/app/imports/server/publications/characterList.js
index 6f2f1be92..50dedbc77 100644
--- a/app/imports/server/publications/characterList.js
+++ b/app/imports/server/publications/characterList.js
@@ -34,6 +34,11 @@ Meteor.publish('characterList', function () {
avatarPicture: 1,
public: 1,
type: 1,
+ archiveId: { $cond: {
+ if: "$archiveId",
+ then: true,
+ else: null,
+ }}
}
}
),
diff --git a/app/imports/server/publications/singleCharacter.js b/app/imports/server/publications/singleCharacter.js
index c6c0581be..91d56dacf 100644
--- a/app/imports/server/publications/singleCharacter.js
+++ b/app/imports/server/publications/singleCharacter.js
@@ -9,6 +9,7 @@ import VERSION from '/imports/constants/VERSION';
import { loadCreature } from '/imports/api/engine/loadCreatures';
import { rebuildCreatureNestedSets } from '/imports/api/parenting/parentingFunctions';
import EngineActions from '/imports/api/engine/action/EngineActions';
+import { restoreSoftArchive } from '/imports/api/creature/archive/methods/restoreCreatureFromFile';
let schema = new SimpleSchema({
creatureId: {
@@ -36,10 +37,14 @@ Meteor.publish('singleCharacter', function (creatureId) {
public: 1,
computeVersion: 1,
tabletopId: 1,
+ archiveId: 1,
}
});
try { assertViewPermission(permissionCreature, userId) }
catch (e) { return [] }
+ if (permissionCreature && permissionCreature.archiveId) {
+ restoreSoftArchive(permissionCreature._id);
+ }
loadCreature(creatureId, self);
if (permissionCreature?.computeVersion !== VERSION && computation.firstRun) {
try {
diff --git a/app/packages/redis-oplog b/app/packages/redis-oplog
new file mode 160000
index 000000000..83e302c15
--- /dev/null
+++ b/app/packages/redis-oplog
@@ -0,0 +1 @@
+Subproject commit 83e302c15456d6744047c50fc8d1add9e739b001
diff --git a/app/server/main.js b/app/server/main.js
index eeab11f8c..e606e48a9 100644
--- a/app/server/main.js
+++ b/app/server/main.js
@@ -8,6 +8,7 @@ import '/imports/server/config/SyncedCronConfig';
import '/imports/server/config/redisCaching';
import '/imports/server/publications/index';
import '/imports/server/cron/deleteSoftRemovedDocuments';
+import '/imports/server/cron/archiveOldCreatures';
import '/imports/api/parenting/organizeMethods';
import '/imports/api/users/patreon/updatePatreonOnLogin';
import '/imports/migrations/server/index';
diff --git a/docker-compose.yml b/docker-compose.yml
index b6f40f3ed..17909a547 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,7 +20,8 @@ services:
- MONGO_URL=mongodb://meteor:meteor@dicecloud-db:27017
- PORT=3000
- NODE_ENV=production
- - METEOR_SETTINGS={"public":{"environment":"production","disablePatreon":true}}
+ - METEOR_SETTINGS={"public":{"environment":"production","disablePatreon":true},"archiveAfterDays":100}
- MAIL_URL=smtp://EMAIL:PASSWORD@SERVER:PORT
ports:
- - '3000:3000' #The internal port should match the port set in the environmental variables
\ No newline at end of file
+ - '3000:3000' #The internal port should match the port set in the environmental variables
+