entries.js 9.04 KB
"use strict";

var Promise = require('bluebird'),
	path = require('path'),
	fs = Promise.promisifyAll(require("fs-extra")),
	_ = require('lodash'),
	farmhash = require('farmhash'),
	BSONModule = require('bson'),
	BSON = new BSONModule.BSONPure.BSON(),
	moment = require('moment');

/**
 * Entries Model
 */
module.exports = {

	_repoPath: 'repo',
	_cachePath: 'data/cache',

	/**
	 * Initialize Entries model
	 *
	 * @param      {Object}  appconfig  The application config
	 * @return     {Object}  Entries model instance
	 */
	init(appconfig) {

		let self = this;

		self._repoPath = appconfig.datadir.repo;
		self._cachePath = path.join(appconfig.datadir.db, 'cache');

		return self;

	},

	/**
	 * Check if a document already exists
	 *
	 * @param      {String}  entryPath  The entry path
	 * @return     {Promise<Boolean>}  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) => {
			return false;
		});

	},

	/**
	 * Fetch a document from cache, otherwise the original
	 *
	 * @param      {String}           entryPath  The entry path
	 * @return     {Promise<Object>}  Page Data
	 */
	fetch(entryPath) {

		let self = this;

		let cpath = self.getCachePath(entryPath);

		return fs.statAsync(cpath).then((st) => {
			return st.isFile();
		}).catch((err) => {
			return false;
		}).then((isCache) => {

			if(isCache) {

				// Load from cache

				return fs.readFileAsync(cpath).then((contents) => {
					return BSON.deserialize(contents);
				}).catch((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<Object>}  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) => {
						return (pageData.parent = false);
					}) : Promise.resolve(true);

					return parentPromise.then(() => {

						// Cache to disk

						if(options.cache) {
							let cacheData = BSON.serialize(pageData, false, false, false);
							return fs.writeFileAsync(cpath, cacheData).catch((err) => {
								winston.error('Unable to write to cache! Performance may be affected.');
								return true;
							});
						} else {
							return true;
						}

					}).return(pageData);

			 	});
			} else {
				return false;
			}
		}).catch((err) => {
			return Promise.reject(new Error('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<Object|False>}  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) + '.bson');
	},

	/**
	 * 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<Boolean>}  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.fetchOriginal(entryPath, {});
				});
			} else {
				return Promise.reject(new Error('Entry does not exist!'));
			}
		}).catch((err) => {
			return Promise.reject(new Error('Entry does not exist!'));
		});

	},

	/**
	 * Create a new document
	 *
	 * @param      {String}            entryPath  The entry path
	 * @param      {String}            contents   The markdown-formatted contents
	 * @return     {Promise<Boolean>}  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.fetchOriginal(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<Boolean>}  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);
		});

	},

	/**
	 * Generate a starter page content based on the entry path
	 *
	 * @param      {String}           entryPath  The entry path
	 * @return     {Promise<String>}  Starter content
	 */
	getStarter(entryPath) {

		let self = this;
		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);
		});

	},

	purgeStaleCache() {

		let self = this;

		let cacheJobs = [];

		fs.walk(self._repoPath)
		.on('data', function (item) {
			if(path.extname(item.path) === '.md') {

				let entryPath = self.parsePath(self.getEntryPathFromFullPath(item.path));
				let cachePath = self.getCachePath(entryPath);

				cacheJobs.push(fs.statAsync(cachePath).then((st) => {
					if(moment(st.mtime).isBefore(item.stats.mtime)) {
						return fs.unlinkAsync(cachePath);
					} else {
						return true;
					}
				}).catch((err) => {
					return (err.code !== 'EEXIST') ? err : true;
				}));
			}
		});

		return Promise.all(cacheJobs);

	}

};