Files
DiceCloud/app/imports/api/library/LibraryNodes.js
2023-12-31 18:06:31 +02:00

320 lines
8.6 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { ValidatedMethod } from 'meteor/mdg:validated-method';
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
import SimpleSchema from 'simpl-schema';
import ColorSchema from '/imports/api/properties/subSchemas/ColorSchema';
import ChildSchema, { RefSchema } from '/imports/api/parenting/ChildSchema';
import propertySchemasIndex from '/imports/api/properties/propertySchemasIndex';
import Libraries from '/imports/api/library/Libraries';
import { assertEditPermission } from '/imports/api/sharing/sharingPermissions';
import { softRemove } from '/imports/api/parenting/softRemove';
import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema';
import { storedIconsSchema } from '/imports/api/icons/Icons';
import '/imports/api/library/methods/index';
import { updateReferenceNodeWork } from '/imports/api/library/methods/updateReferenceNode';
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS';
import { restore } from '/imports/api/parenting/softRemove';
import { fetchDocByRef, getAncestry } from '/imports/api/parenting/parentingFunctions';
import { rebuildNestedSets } from '/imports/api/parenting/parentingFunctions';
let LibraryNodes = new Mongo.Collection('libraryNodes');
let LibraryNodeSchema = new SimpleSchema({
_id: {
type: String,
regEx: SimpleSchema.RegEx.Id,
},
type: {
type: String,
allowedValues: Object.keys(propertySchemasIndex),
},
tags: {
type: Array,
defaultValue: [],
maxCount: STORAGE_LIMITS.tagCount,
},
'tags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
icon: {
type: storedIconsSchema,
optional: true,
max: STORAGE_LIMITS.icon,
},
// Library-specific properties, these can be stripped from the resulting
// creature properties
// Will this property show up in the slot-fill dialog
fillSlots: {
type: Boolean,
optional: true,
index: 1,
},
// Will this property show up in the insert-from-library dialog
searchable: {
type: Boolean,
optional: true,
index: 1,
},
libraryTags: {
type: Array,
optional: true,
maxCount: STORAGE_LIMITS.tagCount,
},
'libraryTags.$': {
type: String,
max: STORAGE_LIMITS.tagLength,
},
// Overrides the type when searching for properties
slotFillerType: {
type: String,
optional: true,
max: STORAGE_LIMITS.variableName,
},
// Image to display when filling the slot
slotFillImage: {
type: String,
optional: true,
max: STORAGE_LIMITS.url,
},
// Fill more than one quantity in a slot, like feats and ability score
// improvements, filtered out of UI if there isn't space in quantityExpected
slotQuantityFilled: {
type: SimpleSchema.Integer,
optional: true, // Undefined implies 1
},
// Filters out of UI if condition isn't met, but isn't otherwise enforced
slotFillerCondition: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
// Text to display if slot filler condition fails
slotFillerConditionNote: {
type: String,
optional: true,
max: STORAGE_LIMITS.calculation,
},
});
// Set up server side search index
if (Meteor.isServer) {
LibraryNodes._ensureIndex({
'name': 'text',
'tags': 'text',
});
}
for (let key in propertySchemasIndex) {
let schema = new SimpleSchema({});
schema.extend(LibraryNodeSchema);
schema.extend(ColorSchema);
schema.extend(propertySchemasIndex[key]);
schema.extend(ChildSchema);
schema.extend(SoftRemovableSchema);
// Use the any schema as a default schema for the collection
if (key === 'any') {
// @ts-expect-error don't have types for .attachSchema
LibraryNodes.attachSchema(schema);
}
// TODO make this an else branch and remove all {selector: {type: any}} options
// @ts-expect-error don't have types for .attachSchema
LibraryNodes.attachSchema(schema, {
selector: { type: key }
});
}
function getLibrary(node) {
if (!node) throw new Meteor.Error('No node provided');
let library = Libraries.findOne(node.root.id);
if (!library) throw new Meteor.Error('Library does not exist');
return library;
}
function assertNodeEditPermission(node, userId) {
let lib = getLibrary(node);
return assertEditPermission(lib, userId);
}
const insertNode = new ValidatedMethod({
name: 'libraryNodes.insert',
validate: new SimpleSchema({
libraryNode: {
type: Object,
blackbox: true,
},
parentRef: RefSchema,
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ libraryNode, parentRef }) {
// get the new ancestry
const parentDoc = fetchDocByRef(parentRef);
// Check permission to edit
let root;
if (parentRef.collection === 'libraries') {
root = parentDoc;
} else if (parentRef.collection === 'libraryNodes') {
root = Libraries.findOne(parentDoc.root.id);
libraryNode.parentId = parentRef.id;
} else {
throw `${parentRef.collection} is not a valid parent collection`
}
assertEditPermission(root, this.userId);
// Remove its ID if it came with one to force a random one to be generated
// server-side
delete libraryNode._id;
// Insert the node
const nodeId = LibraryNodes.insert(libraryNode);
// Update the node if it was a reference node
if (libraryNode.type == 'reference') {
libraryNode._id = nodeId;
updateReferenceNodeWork(libraryNode, this.userId);
}
// Tree structure changed by insert, reorder the tree
rebuildNestedSets(LibraryNodes, root._id);
// Return the id of the inserted node
return nodeId;
},
});
const updateLibraryNode = new ValidatedMethod({
name: 'libraryNodes.update',
validate({ _id, path }) {
if (!_id) return false;
// We cannot change these fields with a simple update
switch (path[0]) {
case 'type':
case 'order':
case 'parent':
case 'ancestors':
case 'parentId':
case 'root':
return false;
}
},
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 15,
timeInterval: 5000,
},
run({ _id, path, value }) {
let node = LibraryNodes.findOne(_id);
assertNodeEditPermission(node, this.userId);
let pathString = path.join('.');
let modifier;
// unset empty values
if (value === null || value === undefined) {
modifier = { $unset: { [pathString]: 1 } };
} else {
modifier = { $set: { [pathString]: value } };
}
let numUpdated = LibraryNodes.update(_id, modifier, {
selector: { type: node.type },
});
if (node.type == 'reference') {
node = LibraryNodes.findOne(_id);
updateReferenceNodeWork(node, this.userId);
}
return numUpdated;
},
});
const pushToLibraryNode = new ValidatedMethod({
name: 'libraryNodes.push',
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, path, value }) {
let node = LibraryNodes.findOne(_id);
assertNodeEditPermission(node, this.userId);
return LibraryNodes.update(_id, {
$push: { [path.join('.')]: value },
}, {
selector: { type: node.type },
});
}
});
const pullFromLibraryNode = new ValidatedMethod({
name: 'libraryNodes.pull',
validate: null,
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id, path, itemId }) {
let node = LibraryNodes.findOne(_id);
assertNodeEditPermission(node, this.userId);
return LibraryNodes.update(_id, {
$pull: { [path.join('.')]: { _id: itemId } },
}, {
selector: { type: node.type },
getAutoValues: false,
});
}
});
const softRemoveLibraryNode = new ValidatedMethod({
name: 'libraryNodes.softRemove',
validate: new SimpleSchema({
_id: SimpleSchema.RegEx.Id
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id }) {
let node = LibraryNodes.findOne(_id);
assertNodeEditPermission(node, this.userId);
softRemove({ _id, collection: LibraryNodes });
}
});
const restoreLibraryNode = new ValidatedMethod({
name: 'libraryNodes.restore',
validate: new SimpleSchema({
_id: SimpleSchema.RegEx.Id
}).validator(),
mixins: [RateLimiterMixin],
rateLimit: {
numRequests: 5,
timeInterval: 5000,
},
run({ _id }) {
// Permissions
let node = LibraryNodes.findOne(_id);
assertNodeEditPermission(node, this.userId);
// Do work
restore(LibraryNodes, _id);
}
});
export default LibraryNodes;
export {
LibraryNodeSchema,
insertNode,
updateLibraryNode,
pullFromLibraryNode,
pushToLibraryNode,
softRemoveLibraryNode,
restoreLibraryNode,
};