Began rewrite of all parenting functions to nested sets
What have I gotten myself into :(
This commit is contained in:
@@ -50,3 +50,4 @@ littledata:synced-cron
|
|||||||
typescript@4.9.4
|
typescript@4.9.4
|
||||||
seba:minifiers-autoprefixer
|
seba:minifiers-autoprefixer
|
||||||
mixmax:smart-disconnect
|
mixmax:smart-disconnect
|
||||||
|
zodern:types
|
||||||
|
|||||||
@@ -125,3 +125,4 @@ url@1.3.2
|
|||||||
webapp@1.13.5
|
webapp@1.13.5
|
||||||
webapp-hashing@1.1.1
|
webapp-hashing@1.1.1
|
||||||
zer0th:meteor-vuetify-loader@0.1.41
|
zer0th:meteor-vuetify-loader@0.1.41
|
||||||
|
zodern:types@1.0.9
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import SimpleSchema from 'simpl-schema';
|
|
||||||
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
|
|
||||||
|
|
||||||
const RefSchema = new SimpleSchema({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
regEx: SimpleSchema.RegEx.Id,
|
|
||||||
// TODO: Rather than indexing this field, index `ancestors.0.id` to only
|
|
||||||
// index the root of the ancestor heirarchy to significantly reduce
|
|
||||||
// index size and improve performance
|
|
||||||
// All queries on an ancestor document need to target `ancestors.0.id` first
|
|
||||||
// before targeting a younger ancestor
|
|
||||||
index: 1
|
|
||||||
},
|
|
||||||
collection: {
|
|
||||||
type: String,
|
|
||||||
max: STORAGE_LIMITS.collectionName,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let ChildSchema = new SimpleSchema({
|
|
||||||
order: {
|
|
||||||
type: Number,
|
|
||||||
},
|
|
||||||
parent: {
|
|
||||||
type: RefSchema,
|
|
||||||
optional: true,
|
|
||||||
},
|
|
||||||
// Change this from ancestor list to left and right indices
|
|
||||||
ancestors: {
|
|
||||||
type: Array,
|
|
||||||
defaultValue: [],
|
|
||||||
maxCount: STORAGE_LIMITS.ancestorCount,
|
|
||||||
},
|
|
||||||
'ancestors.$': {
|
|
||||||
type: RefSchema,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ChildSchema;
|
|
||||||
export { RefSchema };
|
|
||||||
78
app/imports/api/parenting/ChildSchema.ts
Normal file
78
app/imports/api/parenting/ChildSchema.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import SimpleSchema from 'simpl-schema';
|
||||||
|
import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js';
|
||||||
|
|
||||||
|
const RefSchema = new SimpleSchema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
regEx: SimpleSchema.RegEx.Id,
|
||||||
|
},
|
||||||
|
collection: {
|
||||||
|
type: String,
|
||||||
|
max: STORAGE_LIMITS.collectionName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChildSchema = new SimpleSchema({
|
||||||
|
root: {
|
||||||
|
type: RefSchema,
|
||||||
|
},
|
||||||
|
'root.id': {
|
||||||
|
type: String,
|
||||||
|
regEx: SimpleSchema.RegEx.Id,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
// Parent id of a document in the same collection
|
||||||
|
// Undefined parent id implies the root is the parent
|
||||||
|
parentId: {
|
||||||
|
type: String,
|
||||||
|
regEx: SimpleSchema.RegEx.Id,
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The tree structure goes as follows where the numbering follows a counterclockwise depth first
|
||||||
|
* path around the tree. The canonical structure comes from the root and parentId references,
|
||||||
|
* while the left and right numbering is used to optimize ancestor queries.
|
||||||
|
*
|
||||||
|
* Left can be used as the canonical ordering of properties in an expanded tree folder view.
|
||||||
|
*
|
||||||
|
* 1 Books 12
|
||||||
|
* ┃
|
||||||
|
* 2 Programming 11
|
||||||
|
* ┏━━━━━━━━┻━━━━━━━━━┓
|
||||||
|
* 3 Languages 4 5 Databases 10
|
||||||
|
* ┏━━━━━━━┻━━━━━━━┓
|
||||||
|
* 6 MongoDB 7 8 dbm 9
|
||||||
|
*/
|
||||||
|
left: {
|
||||||
|
type: Number,
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
type: Number,
|
||||||
|
index: 1,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Reference {
|
||||||
|
collection: string,
|
||||||
|
id: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeDoc {
|
||||||
|
_id: string,
|
||||||
|
root: Reference,
|
||||||
|
parentId?: string,
|
||||||
|
left: number,
|
||||||
|
right: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const treeDocFields = {
|
||||||
|
_id: 1,
|
||||||
|
root: 1,
|
||||||
|
parentId: 1,
|
||||||
|
left: 1,
|
||||||
|
right: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChildSchema;
|
||||||
|
export { RefSchema };
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
|
||||||
|
|
||||||
const docNotFoundError = function({id, collection}){
|
|
||||||
throw new Meteor.Error('document-not-found',
|
|
||||||
`No document could be found with id: ${id} in ${collection}`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function fetchDocByRef({id, collection}, options){
|
|
||||||
return getCollectionByName(collection).findOne(id, options) ||
|
|
||||||
docNotFoundError({id, collection});
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
const collectionDoesntExistError = function(collectionName){
|
|
||||||
throw new Meteor.Error('bad-collection-reference',
|
|
||||||
`Parent references collection ${collectionName}, which does not exist`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCollectionByName = function(name){
|
|
||||||
return Mongo.Collection.get(name) || collectionDoesntExistError(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default getCollectionByName;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import nodesToTree from '/imports/api/parenting/nodesToTree.js';
|
|
||||||
|
|
||||||
export default function getDescendantsInDepthFirstOrder({
|
|
||||||
collection,
|
|
||||||
ancestorId,
|
|
||||||
filter,
|
|
||||||
options = {fields: {order: 1, ancestors: 1}},
|
|
||||||
}){
|
|
||||||
let forest = nodesToTree({collection, ancestorId, filter, options});
|
|
||||||
let orderMemo = getDocsInDepthFirstOrder(forest);
|
|
||||||
return orderMemo;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDocsInDepthFirstOrder(forest){
|
|
||||||
let docs = [];
|
|
||||||
forest.forEach(node => {
|
|
||||||
addNodeAndTraverse(node, docs)
|
|
||||||
});
|
|
||||||
return docs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addNodeAndTraverse(node, docs){
|
|
||||||
docs.push(node.node);
|
|
||||||
node.children.forEach(child => {
|
|
||||||
addNodeAndTraverse(child, docs)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { union, difference, sortBy, findLast, intersection } from 'lodash';
|
|
||||||
|
|
||||||
export function nodeArrayToTree(nodes) {
|
|
||||||
// Store a dict and list of all the nodes
|
|
||||||
let nodeIndex = {};
|
|
||||||
let nodeList = [];
|
|
||||||
nodes.forEach(node => {
|
|
||||||
let treeNode = {
|
|
||||||
node: node,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
nodeIndex[node._id] = treeNode;
|
|
||||||
nodeList.push(treeNode);
|
|
||||||
});
|
|
||||||
// Create a forest of trees
|
|
||||||
let forest = [];
|
|
||||||
// Either the node is a child of its nearest found ancestor, or in the forest as a root
|
|
||||||
nodeList.forEach(treeNode => {
|
|
||||||
let ancestorInForest = findLast(
|
|
||||||
treeNode.node.ancestors,
|
|
||||||
ancestor => !!nodeIndex[ancestor.id]
|
|
||||||
);
|
|
||||||
if (ancestorInForest) {
|
|
||||||
nodeIndex[ancestorInForest.id].children.push(treeNode);
|
|
||||||
} else {
|
|
||||||
forest.push(treeNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
forest.nodeIndex = nodeIndex;
|
|
||||||
return forest;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the documents from a collection, and return the tree of those documents
|
|
||||||
export default function nodesToTree({
|
|
||||||
collection, ancestorId, filter, options = {},
|
|
||||||
includeFilteredDocAncestors = false, includeFilteredDocDescendants = false
|
|
||||||
}) {
|
|
||||||
// Setup the filter
|
|
||||||
let collectionFilter = {
|
|
||||||
'ancestors.id': ancestorId,
|
|
||||||
'removed': { $ne: true },
|
|
||||||
};
|
|
||||||
if (filter) {
|
|
||||||
collectionFilter = {
|
|
||||||
...collectionFilter,
|
|
||||||
...filter,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Set up the options
|
|
||||||
let collectionSort = {
|
|
||||||
order: 1
|
|
||||||
};
|
|
||||||
if (options && options.sort) {
|
|
||||||
collectionSort = {
|
|
||||||
...collectionSort,
|
|
||||||
...options.sort,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let collectionOptions = {
|
|
||||||
sort: collectionSort,
|
|
||||||
}
|
|
||||||
if (options) {
|
|
||||||
collectionOptions = {
|
|
||||||
...collectionOptions,
|
|
||||||
...options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Find all the nodes that match the filter
|
|
||||||
let docs = collection.find(collectionFilter, collectionOptions).map(doc => {
|
|
||||||
if (!filter) return doc;
|
|
||||||
// Mark the nodes that were found by the custom filter
|
|
||||||
doc._matchedDocumentFilter = true;
|
|
||||||
return doc;
|
|
||||||
});
|
|
||||||
let ancestors = [];
|
|
||||||
let ancestorIds = [];
|
|
||||||
let docIds = [];
|
|
||||||
if (filter && (includeFilteredDocAncestors || includeFilteredDocDescendants)) {
|
|
||||||
docIds = docs.map(doc => doc._id)
|
|
||||||
}
|
|
||||||
if (filter && includeFilteredDocAncestors) {
|
|
||||||
// Add all ancestor ids to an array
|
|
||||||
docs.forEach(doc => {
|
|
||||||
ancestorIds = union(ancestorIds, doc.ancestors.map(ref => ref.id));
|
|
||||||
});
|
|
||||||
// Get all the docs that are also ancestors and mark them
|
|
||||||
docs.forEach(doc => {
|
|
||||||
if (ancestorIds.includes(doc._id)) {
|
|
||||||
doc._ancestorOfMatchedDocument = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Remove the ancestor IDs of docs we have already found
|
|
||||||
ancestorIds = difference(ancestorIds, docIds);
|
|
||||||
// Get the ancestor docs from the collection, don't worry about `removed` docs,
|
|
||||||
// if their descendant was not removed, neither are they
|
|
||||||
ancestors = collection.find({ _id: { $in: ancestorIds } }).map(doc => {
|
|
||||||
// Mark that the nodes are ancestors of the found nodes
|
|
||||||
doc._ancestorOfMatchedDocument = true;
|
|
||||||
return doc;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let descendants = [];
|
|
||||||
if (filter && includeFilteredDocDescendants) {
|
|
||||||
let exludeIds = union(ancestorIds, docIds);
|
|
||||||
descendants = collection.find({
|
|
||||||
'_id': { $nin: exludeIds },
|
|
||||||
'ancestors.id': { $in: docIds },
|
|
||||||
'removed': { $ne: true },
|
|
||||||
}).map(doc => {
|
|
||||||
// Mark that the nodes are descendants of the found nodes
|
|
||||||
doc._descendantOfMatchedDocument = true;
|
|
||||||
return doc;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let nodes = sortBy([
|
|
||||||
...ancestors,
|
|
||||||
...docs,
|
|
||||||
...descendants
|
|
||||||
], 'order');
|
|
||||||
// Find all the nodes
|
|
||||||
return nodeArrayToTree(nodes);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
|
|
||||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
|
||||||
import getDescendantsInDepthFirstOrder from '/imports/api/parenting/getDescendantsInDepthFirstOrder.js'
|
|
||||||
|
|
||||||
// Docs keep track of their depth-first order amongst their entire ancestor tree
|
|
||||||
export function compareOrder(docA, docB){
|
|
||||||
// < 0 if A comes before B
|
|
||||||
// = 0 if A and B are the same order
|
|
||||||
// > 0 if B comes before A
|
|
||||||
|
|
||||||
// They must share a root ancestor to be meaningfully sorted
|
|
||||||
if (docA.ancestors[0].id !== docB.ancestors[0].id){
|
|
||||||
return 0;
|
|
||||||
} else {
|
|
||||||
return docA.order - docB.order;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getHighestOrder({collection, ancestorId}){
|
|
||||||
const highestOrderedDoc = collection.findOne({
|
|
||||||
'ancestors.id': ancestorId,
|
|
||||||
}, {
|
|
||||||
fields: {order: 1},
|
|
||||||
sort: {order: -1},
|
|
||||||
});
|
|
||||||
return highestOrderedDoc ? highestOrderedDoc.order : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setDocToLastOrder({collection, doc}){
|
|
||||||
doc.order = getHighestOrder({
|
|
||||||
collection,
|
|
||||||
ancestorId: doc.ancestors[0].id,
|
|
||||||
}) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the order of a doc, and shift the related docs around to suit the new
|
|
||||||
// order
|
|
||||||
function cheapUpdateDocOrder({docRef, order}){
|
|
||||||
let doc = fetchDocByRef(docRef, {fields: {
|
|
||||||
order: 1,
|
|
||||||
parent: 1,
|
|
||||||
}});
|
|
||||||
let collection = getCollectionByName(docRef.collection);
|
|
||||||
const currentOrder = doc.order;
|
|
||||||
if (currentOrder === order){
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
// First move the documents that are in the way
|
|
||||||
let inBetweenSelector, increment;
|
|
||||||
if (order > currentOrder){
|
|
||||||
// Move in-between docs backward
|
|
||||||
inBetweenSelector = {
|
|
||||||
$gt: currentOrder,
|
|
||||||
$lte: order
|
|
||||||
};
|
|
||||||
increment = -1;
|
|
||||||
} else if (order < currentOrder){
|
|
||||||
// Move in-between docs forward
|
|
||||||
inBetweenSelector = {
|
|
||||||
$lt: currentOrder,
|
|
||||||
$gte: order
|
|
||||||
};
|
|
||||||
increment = 1;
|
|
||||||
}
|
|
||||||
collection.update({
|
|
||||||
'ancestors.id': doc.ancestors[0].id,
|
|
||||||
order: inBetweenSelector,
|
|
||||||
}, {
|
|
||||||
$inc: {order: increment},
|
|
||||||
}, {
|
|
||||||
multi: true,
|
|
||||||
selector: {type: 'any'},
|
|
||||||
});
|
|
||||||
// Then move the document itself
|
|
||||||
collection.update(doc._id, {$set: {order}}, {selector: {type: 'any'}});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cheapRemovedDocAtOrder({collection, doc}){
|
|
||||||
// Decrement the order of all docs after the removed doc
|
|
||||||
collection.update({
|
|
||||||
'ancestors.id': doc.ancestors[0].id,
|
|
||||||
order: {$gt: doc.order},
|
|
||||||
}, {
|
|
||||||
$inc: {order: -1},
|
|
||||||
}, {
|
|
||||||
multi: true,
|
|
||||||
selector: {type: 'any'},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cheapInsertedDocAtOrder({collection, ancestorId, order}){
|
|
||||||
// Increment the order of all docs after the inserted doc
|
|
||||||
collection.update({
|
|
||||||
'ancestors.id': ancestorId,
|
|
||||||
order: {$gte: order},
|
|
||||||
}, {
|
|
||||||
$inc: {order: 1},
|
|
||||||
}, {
|
|
||||||
multi: true,
|
|
||||||
selector: {type: 'any'},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the order a single doc and re-order the entire related doc list
|
|
||||||
// with the change
|
|
||||||
export function safeUpdateDocOrder({docRef, order}){
|
|
||||||
let collection = getCollectionByName(docRef.collection);
|
|
||||||
// Put the new doc half a step in front of its new order
|
|
||||||
// to ensure it's in front of whichever doc was there before
|
|
||||||
collection.update(docRef.id, {
|
|
||||||
$set: {order}
|
|
||||||
}, {
|
|
||||||
selector: {type: 'any'}
|
|
||||||
});
|
|
||||||
// reorder all related docs so that order is back to being a continous
|
|
||||||
// set of whole numbers
|
|
||||||
let movedDoc = fetchDocByRef(docRef, {fields: {ancestors: 1}});
|
|
||||||
let ancestorId = movedDoc.ancestors[0].id;
|
|
||||||
reorderDocs({collection, ancestorId});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function reorderDocs({collection, ancestorId}){
|
|
||||||
let orderedDocs = getDescendantsInDepthFirstOrder({collection, ancestorId});
|
|
||||||
let bulkWrite = [];
|
|
||||||
orderedDocs.forEach((doc, index) => {
|
|
||||||
if (doc.order !== index){
|
|
||||||
bulkWrite.push({
|
|
||||||
updateOne : {
|
|
||||||
filter: {_id: doc._id},
|
|
||||||
update: {$set: {order: index}},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (Meteor.isServer && bulkWrite.length){
|
|
||||||
collection.rawCollection().bulkWrite(
|
|
||||||
bulkWrite,
|
|
||||||
{ordered : false},
|
|
||||||
function(e){
|
|
||||||
if (e) {
|
|
||||||
console.error('Bulk write failed: ');
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
bulkWrite.forEach(op => {
|
|
||||||
collection.update(
|
|
||||||
op.updateOne.filter,
|
|
||||||
op.updateOne.update,
|
|
||||||
{selector: {type: 'any'}}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,9 @@ import SimpleSchema from 'simpl-schema';
|
|||||||
import { union } from 'lodash';
|
import { union } from 'lodash';
|
||||||
import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
import { ValidatedMethod } from 'meteor/mdg:validated-method';
|
||||||
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
import { RateLimiterMixin } from 'ddp-rate-limiter-mixin';
|
||||||
import { updateParent } from '/imports/api/parenting/parenting.js';
|
import { changeParent, fetchDocByRef, getCollectionByName } from '/imports/api/parenting/parentingFunctions';
|
||||||
import { reorderDocs, safeUpdateDocOrder } from '/imports/api/parenting/order.js';
|
import { RefSchema } from '/imports/api/parenting/ChildSchema';
|
||||||
import { RefSchema } from '/imports/api/parenting/ChildSchema.js';
|
|
||||||
import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js';
|
import { assertDocEditPermission } from '/imports/api/sharing/sharingPermissions.js';
|
||||||
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
|
|
||||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
|
||||||
import Creatures from '/imports/api/creature/creatures/Creatures.js';
|
import Creatures from '/imports/api/creature/creatures/Creatures.js';
|
||||||
|
|
||||||
const organizeDoc = new ValidatedMethod({
|
const organizeDoc = new ValidatedMethod({
|
||||||
@@ -33,47 +30,45 @@ const organizeDoc = new ValidatedMethod({
|
|||||||
numRequests: 5,
|
numRequests: 5,
|
||||||
timeInterval: 5000,
|
timeInterval: 5000,
|
||||||
},
|
},
|
||||||
run({ docRef, parentRef, order, skipRecompute, skipClient }) {
|
async run({ docRef, parentId, order, skipRecompute, skipClient }) {
|
||||||
if (skipClient && this.isSimulation) {
|
if (skipClient && this.isSimulation) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let doc = fetchDocByRef(docRef);
|
const collection = getCollectionByName(docRef.collection);
|
||||||
let collection = getCollectionByName(docRef.collection);
|
const [doc, parent] = await Promise.all([
|
||||||
|
collection.findOneAsync(docRef.id),
|
||||||
|
collection.findOneAsync(parentId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!doc) throw new Meteor.Error('Document not found', 'The property to move could not be found');
|
||||||
|
if (!parent) throw new Meteor.Error('Document not found', 'The new parent could not be found');
|
||||||
// The user must be able to edit both the doc and its parent to move it
|
// The user must be able to edit both the doc and its parent to move it
|
||||||
// successfully
|
// successfully
|
||||||
assertDocEditPermission(doc, this.userId);
|
await Promise.all([
|
||||||
let parent = fetchDocByRef(parentRef);
|
assertDocEditPermission(doc, this.userId),
|
||||||
assertDocEditPermission(parent, this.userId);
|
// Only check parent if it has a different root
|
||||||
|
doc.root.id !== parent.root.id && assertDocEditPermission(parent, this.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
// Change the doc's parent
|
// Change the doc's parent
|
||||||
updateParent({ docRef, parentRef });
|
await changeParent(doc, parent, collection, order);
|
||||||
// Change the doc's order to be a half step ahead of its target location
|
|
||||||
collection.update(doc._id, { $set: { order } }, { selector: { type: 'any' } });
|
|
||||||
|
|
||||||
// Reorder both ancestors' documents
|
|
||||||
let oldAncestorId = doc.ancestors[0].id;
|
|
||||||
reorderDocs({ collection, ancestorId: oldAncestorId });
|
|
||||||
|
|
||||||
let newAncestorId = getRootId(parent);
|
|
||||||
if (newAncestorId !== oldAncestorId) {
|
|
||||||
reorderDocs({ collection, ancestorId: newAncestorId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Figure out which creatures need to be recalculated after this move
|
// Figure out which creatures need to be recalculated after this move
|
||||||
let docCreatures = getCreatureAncestors(doc);
|
if (!skipRecompute && docRef.collection === 'creatures') {
|
||||||
let parentCreatures = getCreatureAncestors(parent);
|
const creaturesToRecompute = union[doc.root.id, parent.root.id];
|
||||||
if (!skipRecompute) {
|
|
||||||
let creaturesToRecompute = union(docCreatures, parentCreatures);
|
|
||||||
// Mark the creatures for recompute
|
// Mark the creatures for recompute
|
||||||
Creatures.update({
|
await Creatures.updateAsync({
|
||||||
_id: { $in: creaturesToRecompute }
|
_id: { $in: creaturesToRecompute }
|
||||||
}, {
|
}, {
|
||||||
$set: { dirty: true },
|
$set: { dirty: true },
|
||||||
|
}, {
|
||||||
|
multi: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO, rewrite
|
||||||
const reorderDoc = new ValidatedMethod({
|
const reorderDoc = new ValidatedMethod({
|
||||||
name: 'organize.reorderDoc',
|
name: 'organize.reorderDoc',
|
||||||
validate: new SimpleSchema({
|
validate: new SimpleSchema({
|
||||||
@@ -104,27 +99,4 @@ const reorderDoc = new ValidatedMethod({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function getRootId(doc) {
|
|
||||||
if (doc.ancestors && doc.ancestors.length && doc.ancestors[0]) {
|
|
||||||
return doc.ancestors[0].id;
|
|
||||||
} else {
|
|
||||||
return doc._id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCreatureAncestors(doc) {
|
|
||||||
let ids = [];
|
|
||||||
if (doc.type === 'pc' || doc.type === 'npc' || doc.type === 'monster') {
|
|
||||||
ids.push(doc._id);
|
|
||||||
}
|
|
||||||
if (doc.ancestors) {
|
|
||||||
doc.ancestors.forEach(ancestorRef => {
|
|
||||||
if (ancestorRef.collection === 'creatures') {
|
|
||||||
ids.push(ancestorRef.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { organizeDoc, reorderDoc };
|
export { organizeDoc, reorderDoc };
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import fetchDocByRef from '/imports/api/parenting/fetchDocByRef.js';
|
|
||||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
|
||||||
import { flatten } from 'lodash';
|
|
||||||
|
|
||||||
const generalParents = [
|
|
||||||
'attribute',
|
|
||||||
'buff',
|
|
||||||
'classLevel',
|
|
||||||
'feature',
|
|
||||||
'folder',
|
|
||||||
'root',
|
|
||||||
'item',
|
|
||||||
'spell',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Which types are allowed as parents for other types
|
|
||||||
const allowedParenting = {
|
|
||||||
folder: [...generalParents, 'container'],
|
|
||||||
rollResult: ['roll', 'rollResult'],
|
|
||||||
container: ['root', 'folder'],
|
|
||||||
item: ['root', 'container', 'folder'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const allParentTypes = new Set(flatten(Object.values(allowedParenting)));
|
|
||||||
|
|
||||||
export function canBeParent(type){
|
|
||||||
return true;
|
|
||||||
//TODO until there is a good reason to disallow certain parenting options,
|
|
||||||
// this should just let the user do whatever
|
|
||||||
return type && allParentTypes.has(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAllowedParents({childType}){
|
|
||||||
return allowedParenting[childType] || generalParents;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isParentAllowed({parentType = 'root', childType}){
|
|
||||||
return true;
|
|
||||||
//TODO until there is a good reason to disallow certain parenting options,
|
|
||||||
// this should just let the user do whatever
|
|
||||||
if (!childType) throw 'childType is required';
|
|
||||||
let allowedParents = getAllowedParents({childType});
|
|
||||||
return allowedParents.includes(parentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchParent({id, collection}){
|
|
||||||
return fetchDocByRef({id, collection});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchChildren({ collection, parentId, filter = {}, options = {sort: {order: 1}} }){
|
|
||||||
filter['parent.id'] = parentId;
|
|
||||||
let children = [];
|
|
||||||
children.push(
|
|
||||||
...collection.find({
|
|
||||||
'parent.id': parentId
|
|
||||||
}, options).fetch()
|
|
||||||
);
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateChildren({collection, parentId, filter = {}, modifier, options={}}){
|
|
||||||
filter['parent.id'] = parentId;
|
|
||||||
options.multi = true;
|
|
||||||
collection.update(filter, modifier, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchDescendants({ collection, ancestorId, filter = {}, options}){
|
|
||||||
filter['ancestors.id'] = ancestorId;
|
|
||||||
let descendants = [];
|
|
||||||
descendants.push(...collection.find(filter, options).fetch());
|
|
||||||
return descendants;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateDescendants({collection, ancestorId, filter = {}, modifier, options={}}){
|
|
||||||
filter['ancestors.id'] = ancestorId;
|
|
||||||
options.multi = true;
|
|
||||||
options.selector = {type: 'any'};
|
|
||||||
collection.update(filter, modifier, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function forEachDescendant({collection, ancestorId, filter = {}, options}, callback){
|
|
||||||
filter['ancestors.id'] = ancestorId;
|
|
||||||
collection.find(filter, options).forEach(callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1 database read
|
|
||||||
export function getAncestry({parentRef, inheritedFields = {}}){
|
|
||||||
let parentDoc = fetchDocByRef(parentRef, {fields: inheritedFields});
|
|
||||||
let parent = { ...parentRef};
|
|
||||||
for (let field in inheritedFields){
|
|
||||||
if (inheritedFields[field]){
|
|
||||||
parent[field] = parentDoc[field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ancestors is [...parent's ancestors, parent ref]
|
|
||||||
let ancestors = parentDoc.ancestors || [];
|
|
||||||
ancestors.push(parent);
|
|
||||||
|
|
||||||
return {parentDoc, parent, ancestors};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLineageOfDocs({docArray, oldParent, newAncestry}){
|
|
||||||
const newParent = newAncestry[newAncestry.length - 1];
|
|
||||||
docArray.forEach(doc => {
|
|
||||||
if(doc.parent.id === oldParent.id){
|
|
||||||
doc.parent = newParent;
|
|
||||||
}
|
|
||||||
let oldAncestors = doc.ancestors;
|
|
||||||
let oldParentIndex = oldAncestors.findIndex(a => a.id === oldParent.id);
|
|
||||||
if (oldParentIndex === -1) return;
|
|
||||||
doc.ancestors = [...newAncestry, ...oldAncestors.slice(oldParentIndex + 1)];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Give documents new random ids and transform their references.
|
|
||||||
* Transform collections of re-IDed docs according to the collection map
|
|
||||||
*/
|
|
||||||
export function renewDocIds({docArray, collectionMap, idMap = {}}){
|
|
||||||
// idMap is a map of {oldId: newId}
|
|
||||||
// Get a random generator that's consistent on client and server
|
|
||||||
let randomSrc = DDP.randomStream('renewDocIds');
|
|
||||||
|
|
||||||
// Give new ids and map the changes as {oldId: newId}
|
|
||||||
docArray.forEach(doc => {
|
|
||||||
let oldId = doc._id;
|
|
||||||
let newId = idMap[oldId] || randomSrc.id();
|
|
||||||
doc._id = newId;
|
|
||||||
idMap[oldId] = newId;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remap all references using the new IDs
|
|
||||||
const remapReference = ref => {
|
|
||||||
if (idMap[ref.id]){
|
|
||||||
ref.id = idMap[ref.id];
|
|
||||||
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
docArray.forEach(doc => {
|
|
||||||
remapReference(doc.parent);
|
|
||||||
doc.ancestors.forEach(remapReference);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateParent({docRef, parentRef}){
|
|
||||||
let collection = getCollectionByName(docRef.collection);
|
|
||||||
let oldDoc = fetchDocByRef(docRef, {fields: {
|
|
||||||
parent: 1,
|
|
||||||
ancestors: 1,
|
|
||||||
type: 1,
|
|
||||||
}});
|
|
||||||
let updateOptions = { selector: {type: 'any'} };
|
|
||||||
|
|
||||||
// Skip if we aren't changing the parent id
|
|
||||||
if (oldDoc.parent.id === parentRef.id) return;
|
|
||||||
|
|
||||||
// Get the parent and its ancestry
|
|
||||||
let {parentDoc, parent, ancestors} = getAncestry({parentRef});
|
|
||||||
|
|
||||||
// Check that the doc isn't its own ancestor
|
|
||||||
ancestors.forEach(ancestor => {
|
|
||||||
if (docRef.id === ancestor.id){
|
|
||||||
throw new Meteor.Error('invalid parenting',
|
|
||||||
'A doc can\'t be its own ancestor')
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If the doc and its parent are in the same collection, apply the allowed
|
|
||||||
// parent rules based on type
|
|
||||||
if (docRef.collection === parentRef.collection){
|
|
||||||
let parentAllowed = isParentAllowed({
|
|
||||||
parentType: parentDoc.type,
|
|
||||||
childType: oldDoc.type
|
|
||||||
});
|
|
||||||
if (!parentAllowed){
|
|
||||||
throw new Meteor.Error('invalid parenting',
|
|
||||||
`Can't make ${oldDoc.type} a child of ${parentDoc.type}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the document's parenting
|
|
||||||
collection.update(docRef.id, {
|
|
||||||
$set: {parent, ancestors}
|
|
||||||
}, updateOptions);
|
|
||||||
|
|
||||||
// Remove the old ancestors from the descendants
|
|
||||||
updateDescendants({
|
|
||||||
collection,
|
|
||||||
ancestorId: docRef.id,
|
|
||||||
modifier: {$pullAll: {
|
|
||||||
ancestors: oldDoc.ancestors,
|
|
||||||
}},
|
|
||||||
options: updateOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add the new ancestors to the descendants
|
|
||||||
updateDescendants({
|
|
||||||
collection,
|
|
||||||
ancestorId: docRef.id,
|
|
||||||
modifier: {$push: {
|
|
||||||
ancestors: {
|
|
||||||
$each: ancestors,
|
|
||||||
$position: 0,
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
options: updateOptions,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getName(doc){
|
|
||||||
if (doc.name) return name;
|
|
||||||
var i = doc.ancestors.length;
|
|
||||||
while(i--) {
|
|
||||||
if (doc.ancestors[i].name) return doc.ancestors[i].name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
77
app/imports/api/parenting/parentingFunctions.test.ts
Normal file
77
app/imports/api/parenting/parentingFunctions.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { docsToForest, calculateNestedSetOperations } from '/imports/api/parenting/parentingFunctions'
|
||||||
|
import { TreeDoc } from '/imports/api/parenting/ChildSchema';
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
function doc(_id, left, right, parentId?): TreeDoc {
|
||||||
|
return { _id, root: { id: 'root', collection: 'col' }, left, right, parentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function op(_id, left, right) {
|
||||||
|
return {
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id },
|
||||||
|
update: { $set: { left, right } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Parenting with nested sets', function () {
|
||||||
|
/**
|
||||||
|
* Test the following structure
|
||||||
|
*
|
||||||
|
* 1 Books 12
|
||||||
|
* ┃
|
||||||
|
* 2 Programming 11
|
||||||
|
* ┏━━━━━━━━┻━━━━━━━━━┓
|
||||||
|
* 3 Languages 4 5 Databases 10
|
||||||
|
* ┏━━━━━━━┻━━━━━━━┓
|
||||||
|
* 6 MongoDB 7 8 dbm 9
|
||||||
|
*/
|
||||||
|
it('Takes a set of documents and builds the forest', function () {
|
||||||
|
const docArray: Array<TreeDoc> = [
|
||||||
|
doc('Books', 1, 12),
|
||||||
|
doc('Programming', 2, 11),
|
||||||
|
doc('Languages', 3, 4),
|
||||||
|
doc('Databases', 5, 10),
|
||||||
|
doc('MongoDB', 6, 7),
|
||||||
|
doc('dbm', 8, 9),
|
||||||
|
];
|
||||||
|
const forest = docsToForest(docArray);
|
||||||
|
assert.deepEqual(forest, [
|
||||||
|
{
|
||||||
|
doc: doc('Books', 1, 12), children: [
|
||||||
|
{
|
||||||
|
doc: doc('Programming', 2, 11), children: [
|
||||||
|
{ doc: doc('Languages', 3, 4), children: [] },
|
||||||
|
{
|
||||||
|
doc: doc('Databases', 5, 10), children: [
|
||||||
|
{ doc: doc('MongoDB', 6, 7), children: [] },
|
||||||
|
{ doc: doc('dbm', 8, 9), children: [] },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('Can recalculate left and right for docs with set parents', function () {
|
||||||
|
const docArray = [
|
||||||
|
doc('Books', 71, 33, undefined),
|
||||||
|
doc('Programming', 72, 33, 'Books'),
|
||||||
|
doc('Languages', 73, 33, 'Programming'),
|
||||||
|
doc('Databases', 74, 33, 'Programming'),
|
||||||
|
doc('MongoDB', 75, 33, 'Databases'),
|
||||||
|
doc('dbm', 76, 33, 'Databases'),
|
||||||
|
];
|
||||||
|
const ops = calculateNestedSetOperations(docArray);
|
||||||
|
assert.deepEqual(ops, [
|
||||||
|
op('Books', 1, 12),
|
||||||
|
op('Programming', 2, 11),
|
||||||
|
op('Languages', 3, 4),
|
||||||
|
op('Databases', 5, 10),
|
||||||
|
op('MongoDB', 6, 7),
|
||||||
|
op('dbm', 8, 9),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
494
app/imports/api/parenting/parentingFunctions.ts
Normal file
494
app/imports/api/parenting/parentingFunctions.ts
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import { chain, reverse } from 'lodash';
|
||||||
|
import { TreeDoc, treeDocFields, Reference } from '/imports/api/parenting/ChildSchema';
|
||||||
|
|
||||||
|
export function getCollectionByName(name: string): Mongo.Collection<TreeDoc> {
|
||||||
|
const collection = Mongo.Collection.get(name)
|
||||||
|
if (!collection) {
|
||||||
|
throw new Meteor.Error('bad-collection-reference',
|
||||||
|
`Parent references collection ${name}, which does not exist`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDocByRef(ref: Reference, options?: Mongo.Options<object>) {
|
||||||
|
const doc = await getCollectionByName(ref.collection).findOneAsync(ref.id, options);
|
||||||
|
if (!doc) {
|
||||||
|
throw new Meteor.Error('document-not-found',
|
||||||
|
`No document could be found with id: ${ref.id} in ${ref.collection}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNode<T> {
|
||||||
|
doc: T,
|
||||||
|
children: TreeNode<T>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param nodes An array of documents that share a common root. Must already be sorted by `.left` in ascending order
|
||||||
|
* @returns An array of tree nodes that each contain a document and its children. Children are
|
||||||
|
* assigned based on the nearest ancestor included in the input, which may or may not be their
|
||||||
|
* actual direct parents
|
||||||
|
*/
|
||||||
|
export function docsToForest(docs: Array<TreeDoc>): TreeNode<TreeDoc>[] {
|
||||||
|
if (!docs.length) return [];
|
||||||
|
const forest: TreeNode<TreeDoc>[] = [];
|
||||||
|
const ancestorStack: TreeNode<TreeDoc>[] = [];
|
||||||
|
let currentAncestor: TreeNode<TreeDoc> | undefined;
|
||||||
|
docs.forEach(doc => {
|
||||||
|
const node: TreeNode<TreeDoc> = {
|
||||||
|
doc,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
// Remove ancestors from the stack until we find one that contains the current document
|
||||||
|
// Ancestor contains document if ancestor.left < doc.left and ancestor.right > doc.right
|
||||||
|
// ancestor.left < doc.left is ensured already, because we sorted by doc.left
|
||||||
|
while (currentAncestor && currentAncestor.doc.right < doc.left) {
|
||||||
|
currentAncestor = ancestorStack.pop();
|
||||||
|
}
|
||||||
|
// Add this child to its place in the forest, either as a child of the ancestor or as the root
|
||||||
|
// of a new tree
|
||||||
|
if (currentAncestor) {
|
||||||
|
currentAncestor.children.push(node);
|
||||||
|
} else {
|
||||||
|
forest.push(node);
|
||||||
|
}
|
||||||
|
// Move the last ancestor onto the stack and make this node the new one
|
||||||
|
if (currentAncestor) ancestorStack.push(currentAncestor);
|
||||||
|
currentAncestor = node;
|
||||||
|
});
|
||||||
|
return forest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the documents from a collection, and return the tree of those documents, potentially
|
||||||
|
* including their ancestors or descendants as required
|
||||||
|
* @param param options
|
||||||
|
* @returns An array of tree nodes that each contain a document and its children. Children are
|
||||||
|
* assigned based on the nearest ancestor included in the input, which may or may not be their
|
||||||
|
* actual direct parents
|
||||||
|
*/
|
||||||
|
type FilteredDoc = {
|
||||||
|
_descendantOfMatchedDocument?: boolean,
|
||||||
|
_matchedDocumentFilter?: boolean,
|
||||||
|
_ancestorOfMatchedDocument?: boolean,
|
||||||
|
} & TreeDoc;
|
||||||
|
|
||||||
|
export default async function filterToForest(
|
||||||
|
collection: Mongo.Collection<TreeDoc>,
|
||||||
|
rootId: string,
|
||||||
|
filter: Mongo.Query<TreeDoc>,
|
||||||
|
options: Mongo.Options<object> = {},
|
||||||
|
includeFilteredDocAncestors = false,
|
||||||
|
includeFilteredDocDescendants = false
|
||||||
|
): Promise<TreeNode<FilteredDoc>[]> {
|
||||||
|
// Setup the filter
|
||||||
|
let collectionFilter = {
|
||||||
|
'root.id': rootId,
|
||||||
|
'removed': { $ne: true },
|
||||||
|
};
|
||||||
|
if (filter) {
|
||||||
|
collectionFilter = {
|
||||||
|
...collectionFilter,
|
||||||
|
...filter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Set up the options
|
||||||
|
let collectionSort = {
|
||||||
|
left: 1
|
||||||
|
};
|
||||||
|
if (options && options.sort) {
|
||||||
|
collectionSort = {
|
||||||
|
...collectionSort,
|
||||||
|
...options.sort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let collectionOptions: Mongo.Options<object> = {
|
||||||
|
sort: collectionSort,
|
||||||
|
}
|
||||||
|
if (options) {
|
||||||
|
collectionOptions = {
|
||||||
|
...collectionOptions,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Find all the docs that match the filter
|
||||||
|
const docs: TreeDoc[] = await collection.find(collectionFilter, collectionOptions)
|
||||||
|
.mapAsync(doc => {
|
||||||
|
if (!filter) return doc;
|
||||||
|
// Mark the docs that were found by the custom filter
|
||||||
|
doc._matchedDocumentFilter = true;
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the doc ancestors
|
||||||
|
let ancestors: object[] = [];
|
||||||
|
if (filter && includeFilteredDocAncestors) {
|
||||||
|
ancestors = await collection.find(getFilter.ancestorsOfAll(docs), collectionOptions).mapAsync(doc => {
|
||||||
|
// Mark that the nodes are ancestors of the found nodes
|
||||||
|
doc._ancestorOfMatchedDocument = true;
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the doc descendants
|
||||||
|
let descendants: FilteredDoc[] = [];
|
||||||
|
if (filter && includeFilteredDocDescendants) {
|
||||||
|
descendants = await collection.find({
|
||||||
|
'removed': { $ne: true },
|
||||||
|
...getFilter.descendantsOfAll(docs),
|
||||||
|
}).mapAsync((doc: FilteredDoc) => {
|
||||||
|
// Mark that the nodes are descendants of the found nodes
|
||||||
|
doc._descendantOfMatchedDocument = true;
|
||||||
|
return doc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const nodes = chain([
|
||||||
|
ancestors,
|
||||||
|
docs,
|
||||||
|
descendants
|
||||||
|
]).uniqBy('_id')
|
||||||
|
.sortBy('left')
|
||||||
|
.value();
|
||||||
|
// Find all the nodes
|
||||||
|
return docsToForest(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ForestAndOrphans = { forest: TreeNode<TreeDoc>[], orphanIds: string[] }
|
||||||
|
/**
|
||||||
|
* Takes a complete set of documents and builds a forest using just their `.parentIds`
|
||||||
|
* Uses `.left` for sibling order within a parent only.
|
||||||
|
* Orphans whose direct parents can't be found are collected separately
|
||||||
|
* @param docs An array of all document that share a common root already sorted by `.left` in
|
||||||
|
* ascending order
|
||||||
|
* @returns forest: An array of tree nodes that each contain a document and its children.
|
||||||
|
* orphans: an array of the same, but their parents weren't in the input array
|
||||||
|
*/
|
||||||
|
export function docsToForestByParentId(docs: TreeDoc[]): ForestAndOrphans {
|
||||||
|
// Collect all the docs in a dict by id
|
||||||
|
const nodesById = <{ [_id: string]: TreeNode<TreeDoc> }>{};
|
||||||
|
docs.forEach(doc => {
|
||||||
|
nodesById[doc._id] = { doc, children: [] };
|
||||||
|
});
|
||||||
|
// Assign the docs to their parent or the forest or orphanage
|
||||||
|
const forest: TreeNode<TreeDoc>[] = [];
|
||||||
|
const orphanIds: string[] = [];
|
||||||
|
docs.forEach(doc => {
|
||||||
|
const node = nodesById[doc._id];
|
||||||
|
if (!doc.parentId) {
|
||||||
|
// Root is parent
|
||||||
|
forest.push(node);
|
||||||
|
} else if (nodesById[doc.parentId]) {
|
||||||
|
// Parent is found
|
||||||
|
nodesById[doc.parentId].children.push(node);
|
||||||
|
} else {
|
||||||
|
// Parent is missing, unset it, and store orphan
|
||||||
|
node.doc.parentId = undefined;
|
||||||
|
orphanIds.push(node.doc._id);
|
||||||
|
forest.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { forest, orphanIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFilter = {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param doc A document or array of documents that share a root
|
||||||
|
* @returns A query filter that finds all the ancestors of the doc(s)
|
||||||
|
*/
|
||||||
|
ancestors(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||||
|
return {
|
||||||
|
'root.id': doc.root.id,
|
||||||
|
left: { $lt: doc.left },
|
||||||
|
right: { $gt: doc.right },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
ancestorsOfAll(docs: Array<TreeDoc>): Mongo.Query<TreeDoc> {
|
||||||
|
// The ancestors of no documents is a query that returns nothing
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return { _id: '' };
|
||||||
|
}
|
||||||
|
// Fallback to the simpler filter for a single document
|
||||||
|
if (docs.length === 1) {
|
||||||
|
return getFilter.ancestors(docs[0]);
|
||||||
|
}
|
||||||
|
// Build a filter that selects all ancestors
|
||||||
|
const filter = {
|
||||||
|
'root.id': docs[0].root.id,
|
||||||
|
$or: <object[]>[],
|
||||||
|
};
|
||||||
|
docs.forEach(doc => {
|
||||||
|
filter.$or.push({
|
||||||
|
left: { $lt: doc.left },
|
||||||
|
right: { $gt: doc.right },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return filter;
|
||||||
|
},
|
||||||
|
descendants(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||||
|
return {
|
||||||
|
'root.id': doc.root.id,
|
||||||
|
left: { $gt: doc.left },
|
||||||
|
right: { $lt: doc.right },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
descendantsOfAll(docs: Array<TreeDoc>): Mongo.Query<TreeDoc> {
|
||||||
|
// The descendants of no documents is a query that returns nothing
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return { _id: '' };
|
||||||
|
}
|
||||||
|
// Fallback to the simpler filter for a single document
|
||||||
|
if (docs.length === 1) {
|
||||||
|
return getFilter.descendants(docs[0]);
|
||||||
|
}
|
||||||
|
// Build a filter that selects all descendants
|
||||||
|
const filter = {
|
||||||
|
'root.id': docs[0].root.id,
|
||||||
|
$or: <object[]>[],
|
||||||
|
};
|
||||||
|
docs.forEach(doc => {
|
||||||
|
filter.$or.push({
|
||||||
|
left: { $gt: doc.left },
|
||||||
|
right: { $lt: doc.right },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return filter;
|
||||||
|
},
|
||||||
|
children(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||||
|
return {
|
||||||
|
'root.id': doc.root.id,
|
||||||
|
parentId: doc._id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parent(doc: TreeDoc): Mongo.Query<TreeDoc> {
|
||||||
|
return {
|
||||||
|
_id: doc.parentId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchParent({ id, collection }) {
|
||||||
|
return await fetchDocByRef({ id, collection });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give documents new random ids and transform their references.
|
||||||
|
* Transform collections of re-IDed docs according to the collection map
|
||||||
|
*/
|
||||||
|
export function renewDocIds({ docArray, collectionMap, idMap = {} }) {
|
||||||
|
// idMap is a map of {oldId: newId}
|
||||||
|
// Get a random generator that's consistent on client and server
|
||||||
|
const randomSrc = DDP.randomStream('renewDocIds');
|
||||||
|
|
||||||
|
// Give new ids and map the changes as {oldId: newId}
|
||||||
|
docArray.forEach(doc => {
|
||||||
|
const oldId = doc._id;
|
||||||
|
const newId = idMap[oldId] || randomSrc.id();
|
||||||
|
doc._id = newId;
|
||||||
|
idMap[oldId] = newId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remap all references using the new IDs
|
||||||
|
const remapReference = ref => {
|
||||||
|
if (idMap[ref.id]) {
|
||||||
|
ref.id = idMap[ref.id];
|
||||||
|
ref.collection = collectionMap && collectionMap[ref.collection] || ref.collection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docArray.forEach(doc => {
|
||||||
|
remapReference(doc.parent);
|
||||||
|
remapReference(doc.root);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the doc to be a child of the parent, and then rebuilds the nested sets of the roots
|
||||||
|
* of both doc and parent
|
||||||
|
* @param doc The doc to move
|
||||||
|
* @param parent The new parent of the doc
|
||||||
|
* @param collection
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function changeParent(doc: TreeDoc, parent: TreeDoc, collection: Mongo.Collection<TreeDoc>, order?: number) {
|
||||||
|
// Skip if we aren't changing the parent id
|
||||||
|
if (doc.parentId === parent._id) return;
|
||||||
|
|
||||||
|
// Store the original roots
|
||||||
|
const rootChange = doc.root.id !== parent.root.id;
|
||||||
|
|
||||||
|
// Check that the doc isn't becoming its own ancestor
|
||||||
|
if (parent.left > doc.left && parent.right < doc.right) {
|
||||||
|
throw new Meteor.Error('invalid parenting', 'A doc can\'t be its own ancestor');
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the document's parenting and root if necessary
|
||||||
|
const update: Mongo.Modifier<TreeDoc> = {
|
||||||
|
$set: { parentId: parent._id }
|
||||||
|
};
|
||||||
|
if (rootChange && update.$set) {
|
||||||
|
update.$set.root = parent.root;
|
||||||
|
}
|
||||||
|
if (order && update.$set) {
|
||||||
|
update.$set.left = order;
|
||||||
|
}
|
||||||
|
await collection.updateAsync(doc._id, update);
|
||||||
|
|
||||||
|
// Rebuild the nested sets of everything on the root document(s)
|
||||||
|
rebuildNestedSets(collection, doc.root.id);
|
||||||
|
if (rootChange) {
|
||||||
|
rebuildNestedSets(collection, parent.root.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareOrder(docA, docB) {
|
||||||
|
// < 0 if A comes before B
|
||||||
|
// = 0 if A and B are the same order
|
||||||
|
// > 0 if B comes before A
|
||||||
|
|
||||||
|
// They must share a root ancestor to be meaningfully sorted
|
||||||
|
if (docA.root.id !== docB.root.id) {
|
||||||
|
return 0;
|
||||||
|
} else {
|
||||||
|
return docA.left - docB.left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Just set left to Number.MAX_SAFE_INTEGER instead
|
||||||
|
*/
|
||||||
|
export function setDocToLastOrder(collection: Mongo.Collection<TreeDoc>, doc: TreeDoc) {
|
||||||
|
doc.left = Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rebuildNestedSets(collection: Mongo.Collection<TreeDoc>, rootId: string) {
|
||||||
|
const docs = collection.find({
|
||||||
|
'root.id': rootId,
|
||||||
|
removed: { $ne: true }
|
||||||
|
}, {
|
||||||
|
fields: {
|
||||||
|
root: 1,
|
||||||
|
parentId: 1,
|
||||||
|
left: 1,
|
||||||
|
right: 1,
|
||||||
|
},
|
||||||
|
sort: {
|
||||||
|
//Reverse sorting so that arrays can be used as stacks with the first item on top
|
||||||
|
left: 1,
|
||||||
|
},
|
||||||
|
}).fetch();
|
||||||
|
|
||||||
|
const operations = calculateNestedSetOperations(docs);
|
||||||
|
|
||||||
|
await writeBulkOperations(collection, operations);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateNestedSetOperations(docs: TreeDoc[]) {
|
||||||
|
// Walk around the tree numbering left on the way down and right on the way up like so:
|
||||||
|
/*
|
||||||
|
* 1 Books 12
|
||||||
|
* ┃
|
||||||
|
* 2 Programming 11
|
||||||
|
* ┏━━━━━━━━┻━━━━━━━━━┓
|
||||||
|
* 3 Languages 4 5 Databases 10
|
||||||
|
* ┏━━━━━━━┻━━━━━━━┓
|
||||||
|
* 6 MongoDB 7 8 dbm 9
|
||||||
|
*/
|
||||||
|
// Get the forest, but in reverse order so that the stack always has the first documents on top
|
||||||
|
const { forest: stack, orphanIds } = docsToForestByParentId(reverse(docs));
|
||||||
|
const removeMissingParentsOp = orphanIds.length ? {
|
||||||
|
updateMany: {
|
||||||
|
filter: { _id: { $in: orphanIds } },
|
||||||
|
update: { $unset: { parentId: 1 } },
|
||||||
|
}
|
||||||
|
} : undefined;
|
||||||
|
const visitedNodes = new Set();
|
||||||
|
const visitedChildren = new Set();
|
||||||
|
const opsById: { [_id: string]: any } = {}
|
||||||
|
let count = 1;
|
||||||
|
|
||||||
|
while (stack.length) {
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (visitedNodes.has(top)) {
|
||||||
|
// We've arrived at this node again for some reason, this shouldn't happen
|
||||||
|
console.log('visited already, parent loop maybe?')
|
||||||
|
stack.pop();
|
||||||
|
} else if (visitedChildren.has(top)) {
|
||||||
|
// We've arrived at this node after visiting the children,
|
||||||
|
// we must be on the way up, mark the right number
|
||||||
|
visitedNodes.add(top);
|
||||||
|
stack.pop();
|
||||||
|
if (top.doc.right !== count) {
|
||||||
|
opsById[top.doc._id].updateOne.update.$set.right = count;
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
} else {
|
||||||
|
// We're arriving at this node for the first time
|
||||||
|
// We must be on the way down, mark the left number and go visit the children
|
||||||
|
visitedChildren.add(top);
|
||||||
|
stack.push(...top.children);
|
||||||
|
if (top.doc.left !== count) {
|
||||||
|
opsById[top.doc._id] = {
|
||||||
|
updateOne: {
|
||||||
|
filter: { _id: top.doc._id },
|
||||||
|
update: { $set: { left: count } },
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operations = [...Object.values(opsById)];
|
||||||
|
if (removeMissingParentsOp) operations.push(removeMissingParentsOp);
|
||||||
|
return operations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write some number of bulk operations to the collection, uses a bulk write on the server
|
||||||
|
* and iterates through regular updates on the client
|
||||||
|
* Resolves once all writes have completed
|
||||||
|
* @param collection The collection to write to
|
||||||
|
* @param operations An array of bulk operations to write
|
||||||
|
* @returns Promise<undefined>
|
||||||
|
*/
|
||||||
|
async function writeBulkOperations(collection: Mongo.Collection<TreeDoc>, operations) {
|
||||||
|
if (Meteor.isServer && operations.length) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
collection.rawCollection().bulkWrite(
|
||||||
|
operations,
|
||||||
|
{ ordered: false },
|
||||||
|
function (e) {
|
||||||
|
if (e) {
|
||||||
|
reject(e);
|
||||||
|
} else {
|
||||||
|
resolve(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const promises = operations.map(op => {
|
||||||
|
if (op.updateOne) {
|
||||||
|
return collection.updateAsync(
|
||||||
|
op.updateOne.filter,
|
||||||
|
op.updateOne.update,
|
||||||
|
{ selector: { type: 'any' } }
|
||||||
|
);
|
||||||
|
} else if (op.updateMany) {
|
||||||
|
return collection.updateAsync(
|
||||||
|
op.updateMany.filter,
|
||||||
|
op.updateMany.update,
|
||||||
|
{
|
||||||
|
selector: { type: 'any' },
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import getCollectionByName from '/imports/api/parenting/getCollectionByName.js';
|
|
||||||
import { updateDescendants } from '/imports/api/parenting/parenting.js';
|
|
||||||
|
|
||||||
export function softRemove({_id, collection}){
|
|
||||||
let removalDate = new Date();
|
|
||||||
if (typeof collection === 'string') {
|
|
||||||
collection = getCollectionByName(collection);
|
|
||||||
}
|
|
||||||
// Remove this document
|
|
||||||
collection.update(
|
|
||||||
_id, {
|
|
||||||
$set: {
|
|
||||||
removed: true,
|
|
||||||
removedAt: removalDate,
|
|
||||||
},
|
|
||||||
$unset: {
|
|
||||||
removedWith: 1,
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
selector: {type: 'any'},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Remove all the descendants that have not yet been removed, and set them to be
|
|
||||||
// removed with this document
|
|
||||||
updateDescendants({
|
|
||||||
collection,
|
|
||||||
ancestorId: _id,
|
|
||||||
filter: {removed: {$ne: true}},
|
|
||||||
modifier: {$set: {
|
|
||||||
removed: true,
|
|
||||||
removedAt: removalDate,
|
|
||||||
removedWith: _id,
|
|
||||||
}},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreError = function(){
|
|
||||||
throw new Meteor.Error('restore-failed',
|
|
||||||
'Could not restore this document, maybe it was removed by a parent?'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function restore({ _id, collection, extraUpdates}){
|
|
||||||
if (typeof collection === 'string') {
|
|
||||||
collection = getCollectionByName(collection);
|
|
||||||
}
|
|
||||||
const update = {
|
|
||||||
$unset: {
|
|
||||||
removed: 1,
|
|
||||||
removedAt: 1,
|
|
||||||
},
|
|
||||||
...extraUpdates
|
|
||||||
}
|
|
||||||
|
|
||||||
let numUpdated = collection.update({
|
|
||||||
_id,
|
|
||||||
removedWith: {$exists: false}
|
|
||||||
}, update , {
|
|
||||||
selector: {type: 'any'},
|
|
||||||
},);
|
|
||||||
if (numUpdated === 0) restoreError();
|
|
||||||
updateDescendants({
|
|
||||||
collection,
|
|
||||||
ancestorId: _id,
|
|
||||||
filter: {
|
|
||||||
removedWith: _id,
|
|
||||||
},
|
|
||||||
modifier: { $unset: {
|
|
||||||
removed: 1,
|
|
||||||
removedAt: 1,
|
|
||||||
removedWith: 1,
|
|
||||||
}},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
90
app/imports/api/parenting/softRemove.ts
Normal file
90
app/imports/api/parenting/softRemove.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { getCollectionByName, getFilter } from '/imports/api/parenting/parentingFunctions';
|
||||||
|
import { TreeDoc } from '/imports/api/parenting/ChildSchema';
|
||||||
|
|
||||||
|
export async function softRemove(collection: Mongo.Collection<TreeDoc> | string, doc?: TreeDoc | string) {
|
||||||
|
const removalDate = new Date();
|
||||||
|
if (typeof collection === 'string') {
|
||||||
|
collection = getCollectionByName(collection);
|
||||||
|
}
|
||||||
|
if (typeof doc === 'string') {
|
||||||
|
doc = await collection.findOneAsync(doc);
|
||||||
|
}
|
||||||
|
if (!doc) {
|
||||||
|
throw new Meteor.Error('not found', 'The document to remove was not found');
|
||||||
|
}
|
||||||
|
// Remove this document
|
||||||
|
const removeDocPromise = collection.updateAsync(
|
||||||
|
doc._id,
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
removed: true,
|
||||||
|
removedAt: removalDate,
|
||||||
|
},
|
||||||
|
$unset: {
|
||||||
|
removedWith: 1,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
selector: { type: 'any' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Remove all the descendants that have not yet been removed, and set them to be
|
||||||
|
// removed with this document
|
||||||
|
const removeDescendantsPromise = collection.updateAsync({
|
||||||
|
...getFilter.descendants(doc),
|
||||||
|
removed: { $ne: true },
|
||||||
|
}, {
|
||||||
|
$set: {
|
||||||
|
removed: true,
|
||||||
|
removedAt: removalDate,
|
||||||
|
removedWith: doc._id,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
selector: { type: 'any' },
|
||||||
|
multi: true,
|
||||||
|
});
|
||||||
|
return Promise.all([removeDocPromise, removeDescendantsPromise]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreError = function () {
|
||||||
|
throw new Meteor.Error('restore-failed',
|
||||||
|
'Could not restore this document, maybe it was removed by a parent?'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function restore(collection: Mongo.Collection<TreeDoc> | string, doc: TreeDoc | string, extraUpdates) {
|
||||||
|
if (typeof collection === 'string') {
|
||||||
|
collection = getCollectionByName(collection);
|
||||||
|
}
|
||||||
|
if (typeof doc === 'string') {
|
||||||
|
const foundDoc = await collection.findOneAsync(doc)
|
||||||
|
if (!foundDoc) {
|
||||||
|
throw new Meteor.Error('not found', 'The document to remove was not found');
|
||||||
|
}
|
||||||
|
doc = foundDoc;
|
||||||
|
}
|
||||||
|
const numUpdated: number = await collection.updateAsync({
|
||||||
|
_id: doc._id,
|
||||||
|
removedWith: { $exists: false }
|
||||||
|
}, {
|
||||||
|
$unset: {
|
||||||
|
removed: 1,
|
||||||
|
removedAt: 1,
|
||||||
|
},
|
||||||
|
...extraUpdates
|
||||||
|
}, {
|
||||||
|
selector: { type: 'any' },
|
||||||
|
});
|
||||||
|
if (numUpdated === 0) restoreError();
|
||||||
|
return collection.updateAsync({
|
||||||
|
removedWith: doc._id,
|
||||||
|
}, {
|
||||||
|
$unset: {
|
||||||
|
removed: 1,
|
||||||
|
removedAt: 1,
|
||||||
|
removedWith: 1,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
selector: { type: 'any' },
|
||||||
|
multi: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
1687
app/package-lock.json
generated
1687
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@
|
|||||||
"@babel/runtime": "^7.21.5",
|
"@babel/runtime": "^7.21.5",
|
||||||
"@chenfengyuan/vue-countdown": "^1.1.5",
|
"@chenfengyuan/vue-countdown": "^1.1.5",
|
||||||
"@tozd/vue-observer-utils": "^0.5.0",
|
"@tozd/vue-observer-utils": "^0.5.0",
|
||||||
|
"@types/meteor": "^2.9.4",
|
||||||
"aws-sdk": "^2.1373.0",
|
"aws-sdk": "^2.1373.0",
|
||||||
"bcrypt": "^5.1.0",
|
"bcrypt": "^5.1.0",
|
||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^2.4.2",
|
||||||
|
|||||||
@@ -7,12 +7,17 @@
|
|||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
"strictFunctionTypes": true,
|
"strictFunctionTypes": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
|
"preserveSymlinks": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"/*": [
|
"/*": [
|
||||||
"./*"
|
"./*"
|
||||||
],
|
],
|
||||||
|
"meteor/*": [
|
||||||
|
"node_modules/@types/meteor/*",
|
||||||
|
".meteor/local/types/packages.d.ts"
|
||||||
|
],
|
||||||
"meteor/aldeed:collection2": [
|
"meteor/aldeed:collection2": [
|
||||||
"packages\\collection2\\collection2.js"
|
"packages/collection2/collection2.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"checkJs": false,
|
"checkJs": false,
|
||||||
Reference in New Issue
Block a user