'use strict' const Promise = require('bluebird') const path = require('path') const fs = Promise.promisifyAll(require('fs-extra')) const _ = require('lodash') const farmhash = require('farmhash') /** * Entries Model */ module.exports = { _repoPath: 'repo', _cachePath: 'data/cache', /** * Initialize Entries model * * @return {Object} Entries model instance */ init () { let self = this self._repoPath = path.resolve(ROOTPATH, appconfig.paths.repo) self._cachePath = path.resolve(ROOTPATH, appconfig.paths.data, 'cache') return self }, /** * Check if a document already exists * * @param {String} entryPath The entry path * @return {Promise} True if exists, false otherwise */ exists (entryPath) { let self = this return self.fetchOriginal(entryPath, { parseMarkdown: false, parseMeta: false, parseTree: false, includeMarkdown: false, includeParentInfo: false, cache: false }).then(() => { return true }).catch((err) => { // eslint-disable-line handle-callback-err return false }) }, /** * Fetch a document from cache, otherwise the original * * @param {String} entryPath The entry path * @return {Promise} Page Data */ fetch (entryPath) { let self = this let cpath = self.getCachePath(entryPath) return fs.statAsync(cpath).then((st) => { return st.isFile() }).catch((err) => { // eslint-disable-line handle-callback-err return false }).then((isCache) => { if (isCache) { // Load from cache return fs.readFileAsync(cpath).then((contents) => { return JSON.parse(contents) }).catch((err) => { // eslint-disable-line handle-callback-err winston.error('Corrupted cache file. Deleting it...') fs.unlinkSync(cpath) return false }) } else { // Load original return self.fetchOriginal(entryPath) } }) }, /** * Fetches the original document entry * * @param {String} entryPath The entry path * @param {Object} options The options * @return {Promise} Page data */ fetchOriginal (entryPath, options) { let self = this let fpath = self.getFullPath(entryPath) let cpath = self.getCachePath(entryPath) options = _.defaults(options, { parseMarkdown: true, parseMeta: true, parseTree: true, includeMarkdown: false, includeParentInfo: true, cache: true }) return fs.statAsync(fpath).then((st) => { if (st.isFile()) { return fs.readFileAsync(fpath, 'utf8').then((contents) => { // Parse contents let pageData = { markdown: (options.includeMarkdown) ? contents : '', html: (options.parseMarkdown) ? mark.parseContent(contents) : '', meta: (options.parseMeta) ? mark.parseMeta(contents) : {}, tree: (options.parseTree) ? mark.parseTree(contents) : [] } if (!pageData.meta.title) { pageData.meta.title = _.startCase(entryPath) } pageData.meta.path = entryPath // Get parent let parentPromise = (options.includeParentInfo) ? self.getParentInfo(entryPath).then((parentData) => { return (pageData.parent = parentData) }).catch((err) => { // eslint-disable-line handle-callback-err return (pageData.parent = false) }) : Promise.resolve(true) return parentPromise.then(() => { // Cache to disk if (options.cache) { let cacheData = JSON.stringify(_.pick(pageData, ['html', 'meta', 'tree', 'parent']), false, false, false) return fs.writeFileAsync(cpath, cacheData).catch((err) => { winston.error('Unable to write to cache! Performance may be affected.') winston.error(err) return true }) } else { return true } }).return(pageData) }) } else { return false } }).catch((err) => { // eslint-disable-line handle-callback-err return Promise.reject(new Promise.OperationalError('Entry ' + entryPath + ' does not exist!')) }) }, /** * Parse raw url path and make it safe * * @param {String} urlPath The url path * @return {String} Safe entry path */ parsePath (urlPath) { let wlist = new RegExp('[^a-z0-9/-]', 'g') urlPath = _.toLower(urlPath).replace(wlist, '') if (urlPath === '/') { urlPath = 'home' } let urlParts = _.filter(_.split(urlPath, '/'), (p) => { return !_.isEmpty(p) }) return _.join(urlParts, '/') }, /** * Gets the parent information. * * @param {String} entryPath The entry path * @return {Promise} The parent information. */ getParentInfo (entryPath) { let self = this if (_.includes(entryPath, '/')) { let parentParts = _.initial(_.split(entryPath, '/')) let parentPath = _.join(parentParts, '/') let parentFile = _.last(parentParts) let fpath = self.getFullPath(parentPath) return fs.statAsync(fpath).then((st) => { if (st.isFile()) { return fs.readFileAsync(fpath, 'utf8').then((contents) => { let pageMeta = mark.parseMeta(contents) return { path: parentPath, title: (pageMeta.title) ? pageMeta.title : _.startCase(parentFile), subtitle: (pageMeta.subtitle) ? pageMeta.subtitle : false } }) } else { return Promise.reject(new Error('Parent entry is not a valid file.')) } }) } else { return Promise.reject(new Error('Parent entry is root.')) } }, /** * Gets the full original path of a document. * * @param {String} entryPath The entry path * @return {String} The full path. */ getFullPath (entryPath) { return path.join(this._repoPath, entryPath + '.md') }, /** * Gets the full cache path of a document. * * @param {String} entryPath The entry path * @return {String} The full cache path. */ getCachePath (entryPath) { return path.join(this._cachePath, farmhash.fingerprint32(entryPath) + '.json') }, /** * Gets the entry path from full path. * * @param {String} fullPath The full path * @return {String} The entry path */ getEntryPathFromFullPath (fullPath) { let absRepoPath = path.resolve(ROOTPATH, this._repoPath) return _.chain(fullPath).replace(absRepoPath, '').replace('.md', '').replace(new RegExp('\\\\', 'g'), '/').value() }, /** * Update an existing document * * @param {String} entryPath The entry path * @param {String} contents The markdown-formatted contents * @return {Promise} True on success, false on failure */ update (entryPath, contents) { let self = this let fpath = self.getFullPath(entryPath) return fs.statAsync(fpath).then((st) => { if (st.isFile()) { return self.makePersistent(entryPath, contents).then(() => { return self.updateCache(entryPath).then(entry => { return search.add(entry) }) }) } else { return Promise.reject(new Error('Entry does not exist!')) } }).catch((err) => { winston.error(err) return Promise.reject(new Error('Failed to save document.')) }) }, /** * Update local cache and search index * * @param {String} entryPath The entry path * @return {Promise} Promise of the operation */ updateCache (entryPath) { let self = this return self.fetchOriginal(entryPath, { parseMarkdown: true, parseMeta: true, parseTree: true, includeMarkdown: true, includeParentInfo: true, cache: true }).then((pageData) => { return { entryPath, meta: pageData.meta, parent: pageData.parent || {}, text: mark.removeMarkdown(pageData.markdown) } }).then((content) => { return db.Entry.findOneAndUpdate({ _id: content.entryPath }, { _id: content.entryPath, title: content.meta.title || content.entryPath, subtitle: content.meta.subtitle || '', parent: content.parent.title || '', content: content.text || '' }, { new: true, upsert: true }) }) }, /** * Create a new document * * @param {String} entryPath The entry path * @param {String} contents The markdown-formatted contents * @return {Promise} True on success, false on failure */ create (entryPath, contents) { let self = this return self.exists(entryPath).then((docExists) => { if (!docExists) { return self.makePersistent(entryPath, contents).then(() => { return self.updateCache(entryPath) }) } else { return Promise.reject(new Error('Entry already exists!')) } }).catch((err) => { winston.error(err) return Promise.reject(new Error('Something went wrong.')) }) }, /** * Makes a document persistent to disk and git repository * * @param {String} entryPath The entry path * @param {String} contents The markdown-formatted contents * @return {Promise} True on success, false on failure */ makePersistent (entryPath, contents) { let self = this let fpath = self.getFullPath(entryPath) return fs.outputFileAsync(fpath, contents).then(() => { return git.commitDocument(entryPath) }) }, /** * Move a document * * @param {String} entryPath The current entry path * @param {String} newEntryPath The new entry path * @return {Promise} Promise of the operation */ move (entryPath, newEntryPath) { let self = this if (_.isEmpty(entryPath) || entryPath === 'home') { return Promise.reject(new Error('Invalid path!')) } return git.moveDocument(entryPath, newEntryPath).then(() => { return git.commitDocument(newEntryPath).then(() => { // Delete old cache version let oldEntryCachePath = self.getCachePath(entryPath) fs.unlinkAsync(oldEntryCachePath).catch((err) => { return true }) // eslint-disable-line handle-callback-err // Delete old index entry search.delete(entryPath) // Create cache for new entry return self.updateCache(newEntryPath).then(entry => { return search.add(entry) }) }) }) }, /** * Generate a starter page content based on the entry path * * @param {String} entryPath The entry path * @return {Promise} Starter content */ getStarter (entryPath) { let formattedTitle = _.startCase(_.last(_.split(entryPath, '/'))) return fs.readFileAsync(path.join(ROOTPATH, 'client/content/create.md'), 'utf8').then((contents) => { return _.replace(contents, new RegExp('{TITLE}', 'g'), formattedTitle) }) }, /** * Searches entries based on terms. * * @param {String} terms The terms to search for * @return {Promise} Promise of the search results */ search (terms) { terms = _.chain(terms) .deburr() .toLower() .trim() .replace(/[^a-z0-9\- ]/g, '') .split(' ') .filter((f) => { return !_.isEmpty(f) }) .join(' ') .value() return db.Entry.find( { $text: { $search: terms } }, { score: { $meta: 'textScore' }, title: 1 } ) .sort({ score: { $meta: 'textScore' } }) .limit(10) .exec() .then((hits) => { if (hits.length < 5) { let regMatch = new RegExp('^' + _.split(terms, ' ')[0]) return db.Entry.find({ _id: { $regex: regMatch } }, '_id') .sort('_id') .limit(5) .exec() .then((matches) => { return { match: hits, suggest: (matches) ? _.map(matches, '_id') : [] } }) } else { return { match: _.filter(hits, (h) => { return h._doc.score >= 1 }), suggest: [] } } }).catch((err) => { winston.error(err) return { match: [], suggest: [] } }) } }