You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

389 lines
9.0 KiB

  1. "use strict";
  2. var Promise = require('bluebird'),
  3. path = require('path'),
  4. fs = Promise.promisifyAll(require("fs-extra")),
  5. _ = require('lodash'),
  6. farmhash = require('farmhash'),
  7. BSONModule = require('bson'),
  8. BSON = new BSONModule.BSONPure.BSON(),
  9. moment = require('moment');
  10. /**
  11. * Entries Model
  12. */
  13. module.exports = {
  14. _repoPath: 'repo',
  15. _cachePath: 'data/cache',
  16. /**
  17. * Initialize Entries model
  18. *
  19. * @param {Object} appconfig The application config
  20. * @return {Object} Entries model instance
  21. */
  22. init(appconfig) {
  23. let self = this;
  24. self._repoPath = appconfig.datadir.repo;
  25. self._cachePath = path.join(appconfig.datadir.db, 'cache');
  26. return self;
  27. },
  28. /**
  29. * Check if a document already exists
  30. *
  31. * @param {String} entryPath The entry path
  32. * @return {Promise<Boolean>} True if exists, false otherwise
  33. */
  34. exists(entryPath) {
  35. let self = this;
  36. return self.fetchOriginal(entryPath, {
  37. parseMarkdown: false,
  38. parseMeta: false,
  39. parseTree: false,
  40. includeMarkdown: false,
  41. includeParentInfo: false,
  42. cache: false
  43. }).then(() => {
  44. return true;
  45. }).catch((err) => {
  46. return false;
  47. });
  48. },
  49. /**
  50. * Fetch a document from cache, otherwise the original
  51. *
  52. * @param {String} entryPath The entry path
  53. * @return {Promise<Object>} Page Data
  54. */
  55. fetch(entryPath) {
  56. let self = this;
  57. let cpath = self.getCachePath(entryPath);
  58. return fs.statAsync(cpath).then((st) => {
  59. return st.isFile();
  60. }).catch((err) => {
  61. return false;
  62. }).then((isCache) => {
  63. if(isCache) {
  64. // Load from cache
  65. return fs.readFileAsync(cpath).then((contents) => {
  66. return BSON.deserialize(contents);
  67. }).catch((err) => {
  68. winston.error('Corrupted cache file. Deleting it...');
  69. fs.unlinkSync(cpath);
  70. return false;
  71. });
  72. } else {
  73. // Load original
  74. return self.fetchOriginal(entryPath);
  75. }
  76. });
  77. },
  78. /**
  79. * Fetches the original document entry
  80. *
  81. * @param {String} entryPath The entry path
  82. * @param {Object} options The options
  83. * @return {Promise<Object>} Page data
  84. */
  85. fetchOriginal(entryPath, options) {
  86. let self = this;
  87. let fpath = self.getFullPath(entryPath);
  88. let cpath = self.getCachePath(entryPath);
  89. options = _.defaults(options, {
  90. parseMarkdown: true,
  91. parseMeta: true,
  92. parseTree: true,
  93. includeMarkdown: false,
  94. includeParentInfo: true,
  95. cache: true
  96. });
  97. return fs.statAsync(fpath).then((st) => {
  98. if(st.isFile()) {
  99. return fs.readFileAsync(fpath, 'utf8').then((contents) => {
  100. // Parse contents
  101. let pageData = {
  102. markdown: (options.includeMarkdown) ? contents : '',
  103. html: (options.parseMarkdown) ? mark.parseContent(contents) : '',
  104. meta: (options.parseMeta) ? mark.parseMeta(contents) : {},
  105. tree: (options.parseTree) ? mark.parseTree(contents) : []
  106. };
  107. if(!pageData.meta.title) {
  108. pageData.meta.title = _.startCase(entryPath);
  109. }
  110. pageData.meta.path = entryPath;
  111. // Get parent
  112. let parentPromise = (options.includeParentInfo) ? self.getParentInfo(entryPath).then((parentData) => {
  113. return (pageData.parent = parentData);
  114. }).catch((err) => {
  115. return (pageData.parent = false);
  116. }) : Promise.resolve(true);
  117. return parentPromise.then(() => {
  118. // Cache to disk
  119. if(options.cache) {
  120. let cacheData = BSON.serialize(pageData, false, false, false);
  121. return fs.writeFileAsync(cpath, cacheData).catch((err) => {
  122. winston.error('Unable to write to cache! Performance may be affected.');
  123. return true;
  124. });
  125. } else {
  126. return true;
  127. }
  128. }).return(pageData);
  129. });
  130. } else {
  131. return false;
  132. }
  133. }).catch((err) => {
  134. return Promise.reject(new Error('Entry ' + entryPath + ' does not exist!'));
  135. });
  136. },
  137. /**
  138. * Parse raw url path and make it safe
  139. *
  140. * @param {String} urlPath The url path
  141. * @return {String} Safe entry path
  142. */
  143. parsePath(urlPath) {
  144. let wlist = new RegExp('[^a-z0-9/\-]','g');
  145. urlPath = _.toLower(urlPath).replace(wlist, '');
  146. if(urlPath === '/') {
  147. urlPath = 'home';
  148. }
  149. let urlParts = _.filter(_.split(urlPath, '/'), (p) => { return !_.isEmpty(p); });
  150. return _.join(urlParts, '/');
  151. },
  152. /**
  153. * Gets the parent information.
  154. *
  155. * @param {String} entryPath The entry path
  156. * @return {Promise<Object|False>} The parent information.
  157. */
  158. getParentInfo(entryPath) {
  159. let self = this;
  160. if(_.includes(entryPath, '/')) {
  161. let parentParts = _.initial(_.split(entryPath, '/'));
  162. let parentPath = _.join(parentParts,'/');
  163. let parentFile = _.last(parentParts);
  164. let fpath = self.getFullPath(parentPath);
  165. return fs.statAsync(fpath).then((st) => {
  166. if(st.isFile()) {
  167. return fs.readFileAsync(fpath, 'utf8').then((contents) => {
  168. let pageMeta = mark.parseMeta(contents);
  169. return {
  170. path: parentPath,
  171. title: (pageMeta.title) ? pageMeta.title : _.startCase(parentFile),
  172. subtitle: (pageMeta.subtitle) ? pageMeta.subtitle : false
  173. };
  174. });
  175. } else {
  176. return Promise.reject(new Error('Parent entry is not a valid file.'));
  177. }
  178. });
  179. } else {
  180. return Promise.reject(new Error('Parent entry is root.'));
  181. }
  182. },
  183. /**
  184. * Gets the full original path of a document.
  185. *
  186. * @param {String} entryPath The entry path
  187. * @return {String} The full path.
  188. */
  189. getFullPath(entryPath) {
  190. return path.join(this._repoPath, entryPath + '.md');
  191. },
  192. /**
  193. * Gets the full cache path of a document.
  194. *
  195. * @param {String} entryPath The entry path
  196. * @return {String} The full cache path.
  197. */
  198. getCachePath(entryPath) {
  199. return path.join(this._cachePath, farmhash.fingerprint32(entryPath) + '.bson');
  200. },
  201. /**
  202. * Gets the entry path from full path.
  203. *
  204. * @param {String} fullPath The full path
  205. * @return {String} The entry path
  206. */
  207. getEntryPathFromFullPath(fullPath) {
  208. let absRepoPath = path.resolve(ROOTPATH, this._repoPath);
  209. return _.chain(fullPath).replace(absRepoPath, '').replace('.md', '').replace(new RegExp('\\\\', 'g'),'/').value();
  210. },
  211. /**
  212. * Update an existing document
  213. *
  214. * @param {String} entryPath The entry path
  215. * @param {String} contents The markdown-formatted contents
  216. * @return {Promise<Boolean>} True on success, false on failure
  217. */
  218. update(entryPath, contents) {
  219. let self = this;
  220. let fpath = self.getFullPath(entryPath);
  221. return fs.statAsync(fpath).then((st) => {
  222. if(st.isFile()) {
  223. return self.makePersistent(entryPath, contents).then(() => {
  224. return self.fetchOriginal(entryPath, {});
  225. });
  226. } else {
  227. return Promise.reject(new Error('Entry does not exist!'));
  228. }
  229. }).catch((err) => {
  230. return Promise.reject(new Error('Entry does not exist!'));
  231. });
  232. },
  233. /**
  234. * Create a new document
  235. *
  236. * @param {String} entryPath The entry path
  237. * @param {String} contents The markdown-formatted contents
  238. * @return {Promise<Boolean>} True on success, false on failure
  239. */
  240. create(entryPath, contents) {
  241. let self = this;
  242. return self.exists(entryPath).then((docExists) => {
  243. if(!docExists) {
  244. return self.makePersistent(entryPath, contents).then(() => {
  245. return self.fetchOriginal(entryPath, {});
  246. });
  247. } else {
  248. return Promise.reject(new Error('Entry already exists!'));
  249. }
  250. }).catch((err) => {
  251. winston.error(err);
  252. return Promise.reject(new Error('Something went wrong.'));
  253. });
  254. },
  255. /**
  256. * Makes a document persistent to disk and git repository
  257. *
  258. * @param {String} entryPath The entry path
  259. * @param {String} contents The markdown-formatted contents
  260. * @return {Promise<Boolean>} True on success, false on failure
  261. */
  262. makePersistent(entryPath, contents) {
  263. let self = this;
  264. let fpath = self.getFullPath(entryPath);
  265. return fs.outputFileAsync(fpath, contents).then(() => {
  266. return git.commitDocument(entryPath);
  267. });
  268. },
  269. /**
  270. * Generate a starter page content based on the entry path
  271. *
  272. * @param {String} entryPath The entry path
  273. * @return {Promise<String>} Starter content
  274. */
  275. getStarter(entryPath) {
  276. let self = this;
  277. let formattedTitle = _.startCase(_.last(_.split(entryPath, '/')));
  278. return fs.readFileAsync(path.join(ROOTPATH, 'client/content/create.md'), 'utf8').then((contents) => {
  279. return _.replace(contents, new RegExp('{TITLE}', 'g'), formattedTitle);
  280. });
  281. },
  282. purgeStaleCache() {
  283. let self = this;
  284. let cacheJobs = [];
  285. fs.walk(self._repoPath)
  286. .on('data', function (item) {
  287. if(path.extname(item.path) === '.md') {
  288. let entryPath = self.parsePath(self.getEntryPathFromFullPath(item.path));
  289. let cachePath = self.getCachePath(entryPath);
  290. cacheJobs.push(fs.statAsync(cachePath).then((st) => {
  291. if(moment(st.mtime).isBefore(item.stats.mtime)) {
  292. return fs.unlinkAsync(cachePath);
  293. } else {
  294. return true;
  295. }
  296. }).catch((err) => {
  297. return (err.code !== 'EEXIST') ? err : true;
  298. }));
  299. }
  300. });
  301. return Promise.all(cacheJobs);
  302. }
  303. };