diff --git a/app/imports/api/docs/Docs.js b/app/imports/api/docs/Docs.js index 91961b16..6f97d27d 100644 --- a/app/imports/api/docs/Docs.js +++ b/app/imports/api/docs/Docs.js @@ -1,3 +1,278 @@ -if (Meteor.isServer) throw 'Client side only collection, don\'t import on server'; +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 { softRemove } from '/imports/api/parenting/softRemove.js'; +import SoftRemovableSchema from '/imports/api/parenting/SoftRemovableSchema.js'; +import { storedIconsSchema } from '/imports/api/icons/Icons.js'; +import '/imports/api/library/methods/index.js'; +import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import { restore } from '/imports/api/parenting/softRemove.js'; +import { reorderDocs } from '/imports/api/parenting/order.js'; +import { getAncestry } from '/imports/api/parenting/parenting.js'; + const Docs = new Mongo.Collection('docs'); + +const RefSchema = new SimpleSchema({ + id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + index: 1 + }, + collection: { + type: String, + max: STORAGE_LIMITS.collectionName, + }, + urlName: { + type: String, + regEx: /[a-z]+(?:[a-z]|-)+/, + min: 2, + max: STORAGE_LIMITS.variableName, + optional: true, + }, + name: { + type: String, + max: STORAGE_LIMITS.description, + optional: true, + }, +}); + +let ChildSchema = new SimpleSchema({ + order: { + type: Number, + }, + parent: { + type: RefSchema, + optional: true, + }, + ancestors: { + type: Array, + defaultValue: [], + maxCount: STORAGE_LIMITS.ancestorCount, + }, + 'ancestors.$': { + type: RefSchema, + }, +}); + +let DocSchema = new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, + name: { + type: String, + max: STORAGE_LIMITS.description, + }, + urlName: { + type: String, + regEx: /[a-z]+(?:[a-z]|-)+/, + min: 2, + max: STORAGE_LIMITS.variableName, + }, + href: { + type: String, + }, + description: { + type: String, + optional: true, + }, + published: { + type: Boolean, + optional: true, + }, + icon: { + type: storedIconsSchema, + optional: true, + max: STORAGE_LIMITS.icon, + }, +}); + +let schema = new SimpleSchema({}); +schema.extend(DocSchema); +schema.extend(ChildSchema); +schema.extend(SoftRemovableSchema); +Docs.attachSchema(schema); + +function assertDocsEditPermission(userId) { + if (!userId || typeof userId !== 'string') throw new Meteor.Error('No user id provided'); + const user = Meteor.users.findOne(userId); + if (!user) throw new Meteor.Error('User does not exist'); + if (!user?.roles?.includes?.('docsWriter')) throw ('Permission denied') +} + +function getDocLink(doc, urlName) { + if (!urlName) urlName = doc.urlName; + const address = ['/docs']; + doc.ancestors?.forEach(a => { + address.push(a.urlName); + }); + address.push(urlName); + return address.join('/'); +} + +const insertDoc = new ValidatedMethod({ + name: 'docs.insert', + validate: null, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ doc, parentRef }) { + delete doc._id; + assertDocsEditPermission(this.userId); + // get the new ancestry for the properties + if (parentRef) { + var { ancestors } = getAncestry({ + parentRef, + inheritedFields: { name: 1, urlName: 1 }, + }); + } + doc.parent = parentRef; + doc.ancestors = ancestors; + + const lastOrder = Docs.find({}, { sort: { order: -1 } }).fetch()[0]?.order || 0; + doc.order = lastOrder + 1; + doc.urlName = 'new-doc-' + (lastOrder + 1); + + doc.href = getDocLink(doc); + if (Docs.findOne({ href: doc.href })) { + throw new Meteor.Error('Link collision', 'A document with the same URL already exists'); + } + + const docId = Docs.insert(doc); + reorderDocs({ + collection: Docs, + ancestorId: 'root', + }); + return docId; + }, +}); + +const updateDoc = new ValidatedMethod({ + name: 'docs.update', + validate({ _id, path }) { + if (!_id) return false; + // We cannot change these fields with a simple update + switch (path[0]) { + case '_is': + return false; + } + }, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id, path, value }) { + assertDocsEditPermission(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 } }; + } + if (pathString === 'urlName') { + const doc = Docs.findOne(_id); + const newLink = getDocLink(doc, value); + if (Docs.findOne({ href: newLink })) { + throw new Meteor.Error('Link collision', 'A document with the same URL already exists'); + } + modifier.$set = modifier.$set || {}; + modifier.$set.href = newLink; + } + reorderDocs({ + collection: Docs, + ancestorId: 'root', + }); + return Docs.update(_id, modifier); + }, +}); + +const pushToDoc = new ValidatedMethod({ + name: 'docs.push', + validate: null, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id, path, value }) { + assertDocsEditPermission(this.userId); + return Docs.update(_id, { + $push: { [path.join('.')]: value }, + }); + } +}); + +const pullFromDoc = new ValidatedMethod({ + name: 'docs.pull', + validate: null, + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id, path, itemId }) { + assertDocsEditPermission(this.userId); + return Docs.update(_id, { + $pull: { [path.join('.')]: { _id: itemId } }, + }); + } +}); + +const softRemoveDoc = new ValidatedMethod({ + name: 'docs.softRemove', + validate: new SimpleSchema({ + _id: SimpleSchema.RegEx.Id + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id }) { + assertDocsEditPermission(this.userId); + softRemove({ _id, collection: Docs }); + reorderDocs({ + collection: Docs, + ancestorId: 'root', + }); + } +}); + +const restoreDoc = new ValidatedMethod({ + name: 'docs.restore', + validate: new SimpleSchema({ + _id: SimpleSchema.RegEx.Id + }).validator(), + mixins: [RateLimiterMixin], + rateLimit: { + numRequests: 5, + timeInterval: 5000, + }, + run({ _id }) { + assertDocsEditPermission(this.userId); + restore({ _id, collection: Docs }); + reorderDocs({ + collection: Docs, + ancestorId: 'root', + }); + } +}); + +export { + DocSchema, + insertDoc, + updateDoc, + pushToDoc, + pullFromDoc, + softRemoveDoc, + restoreDoc, +}; + export default Docs; diff --git a/app/imports/client/ui/components/global/SmartBtn.vue b/app/imports/client/ui/components/global/SmartBtn.vue index 219824ff..30e82c52 100644 --- a/app/imports/client/ui/components/global/SmartBtn.vue +++ b/app/imports/client/ui/components/global/SmartBtn.vue @@ -23,6 +23,7 @@ export default { type: Number, default: undefined, }, + singleClick: Boolean, }, data() { return { @@ -52,18 +53,23 @@ export default { }, methods: { click() { - this.timesClicked += 1; - this.debounceClicks(); + if (this.singleClick) { + this.loading = true; + } else { + this.timesClicked += 1; + this.debounceClicks(); + } this.$emit('click', this.acknowledgeChange); }, clicks() { - this.$emit('clicks', this.timesClicked, this.acknowledgeChange); this.loading = true; + this.$emit('clicks', this.timesClicked, this.acknowledgeChange); this.timesClicked = 0; }, acknowledgeChange(error){ this.loading = false; if (error) { + console.error(error) snackbar({ text: error.reason || error.message || error.toString() }); } }, diff --git a/app/imports/client/ui/docs/DocBreadcrumbs.vue b/app/imports/client/ui/docs/DocBreadcrumbs.vue new file mode 100644 index 00000000..c7bde66f --- /dev/null +++ b/app/imports/client/ui/docs/DocBreadcrumbs.vue @@ -0,0 +1,44 @@ + + + \ No newline at end of file diff --git a/app/imports/client/ui/docs/DocCard.vue b/app/imports/client/ui/docs/DocCard.vue new file mode 100644 index 00000000..7ad6c8bd --- /dev/null +++ b/app/imports/client/ui/docs/DocCard.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/app/imports/client/ui/docs/DocEditForm.vue b/app/imports/client/ui/docs/DocEditForm.vue new file mode 100644 index 00000000..42d1ae54 --- /dev/null +++ b/app/imports/client/ui/docs/DocEditForm.vue @@ -0,0 +1,213 @@ + + + \ No newline at end of file diff --git a/app/imports/client/ui/docs/DocListItem.vue b/app/imports/client/ui/docs/DocListItem.vue new file mode 100644 index 00000000..3b7f1675 --- /dev/null +++ b/app/imports/client/ui/docs/DocListItem.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/imports/client/ui/docs/DocToolbar.vue b/app/imports/client/ui/docs/DocToolbar.vue new file mode 100644 index 00000000..56c77859 --- /dev/null +++ b/app/imports/client/ui/docs/DocToolbar.vue @@ -0,0 +1,55 @@ + + + diff --git a/app/imports/client/ui/docs/DocViewer.vue b/app/imports/client/ui/docs/DocViewer.vue new file mode 100644 index 00000000..f49de442 --- /dev/null +++ b/app/imports/client/ui/docs/DocViewer.vue @@ -0,0 +1,135 @@ + + + \ No newline at end of file diff --git a/app/imports/client/ui/docs/getDocLink.js b/app/imports/client/ui/docs/getDocLink.js new file mode 100644 index 00000000..0d3be2cb --- /dev/null +++ b/app/imports/client/ui/docs/getDocLink.js @@ -0,0 +1,9 @@ +export default function getDocLink(doc, urlName) { + if (!urlName) urlName = doc.urlName; + const address = ['/docs']; + doc.ancestors?.forEach(a => { + address.push(a.urlName); + }); + address.push(urlName); + return address.join('/'); +} \ No newline at end of file diff --git a/app/imports/client/ui/pages/DocsPage.vue b/app/imports/client/ui/pages/DocsPage.vue new file mode 100644 index 00000000..875af5d1 --- /dev/null +++ b/app/imports/client/ui/pages/DocsPage.vue @@ -0,0 +1,150 @@ + + + + + \ No newline at end of file diff --git a/app/imports/client/ui/router.js b/app/imports/client/ui/router.js index a822afe1..f26f720d 100644 --- a/app/imports/client/ui/router.js +++ b/app/imports/client/ui/router.js @@ -36,7 +36,8 @@ const TabletopRightDrawer = () => import('/imports/client/ui/tabletop/TabletopRi const Admin = () => import('/imports/client/ui/pages/Admin.vue'); const Maintenance = () => import('/imports/client/ui/pages/Maintenance.vue'); const Files = () => import('/imports/client/ui/pages/Files.vue'); -const Documentation = () => import('/imports/client/ui/pages/Documentation.vue'); +const DocsPage = () => import('/imports/client/ui/pages/DocsPage.vue'); +const DocToolbar = () => import('/imports/client/ui/docs/DocToolbar.vue'); // Not found const NotFound = () => import('/imports/client/ui/pages/NotFound.vue'); @@ -264,17 +265,10 @@ RouterFactory.configure(router => { title: 'Functions', }, }, { - path: '/docs/:docPath([^/]+.*)', + path: '/docs/:docPath([^/]+.*)?', components: { - default: Documentation, - }, - meta: { - title: 'Documentation', - }, - }, { - path: '/docs', - components: { - default: Documentation, + default: DocsPage, + toolbar: DocToolbar, }, meta: { title: 'Documentation', diff --git a/app/imports/server/publications/docs.js b/app/imports/server/publications/docs.js index 2bc3a7b4..ed1afb09 100644 --- a/app/imports/server/publications/docs.js +++ b/app/imports/server/publications/docs.js @@ -1,35 +1,17 @@ -import { propsByDocsPath } from '/imports/constants/PROPERTIES.js'; +import Docs from '/imports/api/docs/Docs.js'; - -// Manual doc paths -const docPaths = [ - 'computed-fields', - 'inline-calculations', - 'dependency-loops', - 'docs', - 'tags', - 'walkthroughs/create-a-class', -]; -const docs = new Map(); -docPaths.forEach(path => { - docs.set(path, Assets.getText(`docs/${path}.md`)) -}); - -// Doc paths for properties -propsByDocsPath.forEach(prop => { - docs.set(prop.docsPath, Assets.getText(`docs/${prop.docsPath}.md`)); -}); - -Meteor.publish('docs', function (path) { - if (!path) { - docs.forEach((text, path) => { - this.added('docs', path, { text }); +Meteor.publish('docs', function () { + const filter = { published: true, removed: { $ne: true } }; + if (this.userId) { + const user = Meteor.users.findOne(this.userId, { + fields: { + 'roles': 1, + } }); - } else { - const text = docs.get(path); - if (text) { - this.added('docs', path, { text }); + if (user?.roles?.includes('docsWriter')) { + delete filter.published; + delete filter.removed } } - this.ready(); + return Docs.find(filter); });