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.

457 lines
13 KiB

  1. 'use strict'
  2. /* global db, git, lang, mark, rights, search, winston */
  3. const Promise = require('bluebird')
  4. const path = require('path')
  5. const fs = Promise.promisifyAll(require('fs-extra'))
  6. const _ = require('lodash')
  7. const entryHelper = require('../helpers/entry')
  8. /**
  9. * Entries Model
  10. */
  11. module.exports = {
  12. _repoPath: 'repo',
  13. _cachePath: 'data/cache',
  14. /**
  15. * Initialize Entries model
  16. *
  17. * @return {Object} Entries model instance
  18. */
  19. init() {
  20. let self = this
  21. self._repoPath = path.resolve(ROOTPATH, appconfig.paths.repo)
  22. self._cachePath = path.resolve(ROOTPATH, appconfig.paths.data, 'cache')
  23. appdata.repoPath = self._repoPath
  24. appdata.cachePath = self._cachePath
  25. return self
  26. },
  27. /**
  28. * Check if a document already exists
  29. *
  30. * @param {String} entryPath The entry path
  31. * @return {Promise<Boolean>} True if exists, false otherwise
  32. */
  33. exists(entryPath) {
  34. let self = this
  35. return self.fetchOriginal(entryPath, {
  36. parseMarkdown: false,
  37. parseMeta: false,
  38. parseTree: false,
  39. includeMarkdown: false,
  40. includeParentInfo: false,
  41. cache: false
  42. }).then(() => {
  43. return true
  44. }).catch((err) => { // eslint-disable-line handle-callback-err
  45. return false
  46. })
  47. },
  48. /**
  49. * Fetch a document from cache, otherwise the original
  50. *
  51. * @param {String} entryPath The entry path
  52. * @return {Promise<Object>} Page Data
  53. */
  54. fetch(entryPath) {
  55. let self = this
  56. let cpath = entryHelper.getCachePath(entryPath)
  57. return fs.statAsync(cpath).then((st) => {
  58. return st.isFile()
  59. }).catch((err) => { // eslint-disable-line handle-callback-err
  60. return false
  61. }).then((isCache) => {
  62. if (isCache) {
  63. // Load from cache
  64. return fs.readFileAsync(cpath).then((contents) => {
  65. return JSON.parse(contents)
  66. }).catch((err) => { // eslint-disable-line handle-callback-err
  67. winston.error('Corrupted cache file. Deleting it...')
  68. fs.unlinkSync(cpath)
  69. return false
  70. })
  71. } else {
  72. // Load original
  73. return self.fetchOriginal(entryPath)
  74. }
  75. })
  76. },
  77. /**
  78. * Fetches the original document entry
  79. *
  80. * @param {String} entryPath The entry path
  81. * @param {Object} options The options
  82. * @return {Promise<Object>} Page data
  83. */
  84. fetchOriginal(entryPath, options) {
  85. let self = this
  86. let fpath = entryHelper.getFullPath(entryPath)
  87. let cpath = entryHelper.getCachePath(entryPath)
  88. options = _.defaults(options, {
  89. parseMarkdown: true,
  90. parseMeta: true,
  91. parseTree: true,
  92. includeMarkdown: false,
  93. includeParentInfo: true,
  94. cache: true
  95. })
  96. return fs.statAsync(fpath).then((st) => {
  97. if (st.isFile()) {
  98. return fs.readFileAsync(fpath, 'utf8').then((contents) => {
  99. let htmlProcessor = (options.parseMarkdown) ? mark.parseContent(contents) : Promise.resolve('')
  100. // Parse contents
  101. return htmlProcessor.then(html => {
  102. let pageData = {
  103. markdown: (options.includeMarkdown) ? contents : '',
  104. html,
  105. meta: (options.parseMeta) ? mark.parseMeta(contents) : {},
  106. tree: (options.parseTree) ? mark.parseTree(contents) : []
  107. }
  108. if (!pageData.meta.title) {
  109. pageData.meta.title = _.startCase(entryPath)
  110. }
  111. pageData.meta.path = entryPath
  112. // Get parent
  113. let parentPromise = (options.includeParentInfo) ? self.getParentInfo(entryPath).then((parentData) => {
  114. return (pageData.parent = parentData)
  115. }).catch((err) => { // eslint-disable-line handle-callback-err
  116. return (pageData.parent = false)
  117. }) : Promise.resolve(true)
  118. return parentPromise.then(() => {
  119. // Cache to disk
  120. if (options.cache) {
  121. let cacheData = JSON.stringify(_.pick(pageData, ['html', 'meta', 'tree', 'parent']), false, false, false)
  122. return fs.writeFileAsync(cpath, cacheData).catch((err) => {
  123. winston.error('Unable to write to cache! Performance may be affected.')
  124. winston.error(err)
  125. return true
  126. })
  127. } else {
  128. return true
  129. }
  130. }).return(pageData)
  131. })
  132. })
  133. } else {
  134. return false
  135. }
  136. }).catch((err) => { // eslint-disable-line handle-callback-err
  137. throw new Promise.OperationalError(lang.t('errors:notexist', { path: entryPath }))
  138. })
  139. },
  140. /**
  141. * Gets the parent information.
  142. *
  143. * @param {String} entryPath The entry path
  144. * @return {Promise<Object|False>} The parent information.
  145. */
  146. getParentInfo(entryPath) {
  147. if (_.includes(entryPath, '/')) {
  148. let parentParts = _.initial(_.split(entryPath, '/'))
  149. let parentPath = _.join(parentParts, '/')
  150. let parentFile = _.last(parentParts)
  151. let fpath = entryHelper.getFullPath(parentPath)
  152. return fs.statAsync(fpath).then((st) => {
  153. if (st.isFile()) {
  154. return fs.readFileAsync(fpath, 'utf8').then((contents) => {
  155. let pageMeta = mark.parseMeta(contents)
  156. return {
  157. path: parentPath,
  158. title: (pageMeta.title) ? pageMeta.title : _.startCase(parentFile),
  159. subtitle: (pageMeta.subtitle) ? pageMeta.subtitle : false
  160. }
  161. })
  162. } else {
  163. return Promise.reject(new Error(lang.t('errors:parentinvalid')))
  164. }
  165. })
  166. } else {
  167. return Promise.reject(new Error(lang.t('errors:parentisroot')))
  168. }
  169. },
  170. /**
  171. * Update an existing document
  172. *
  173. * @param {String} entryPath The entry path
  174. * @param {String} contents The markdown-formatted contents
  175. * @param {Object} author The author user object
  176. * @return {Promise<Boolean>} True on success, false on failure
  177. */
  178. update(entryPath, contents, author) {
  179. let self = this
  180. let fpath = entryHelper.getFullPath(entryPath)
  181. return fs.statAsync(fpath).then((st) => {
  182. if (st.isFile()) {
  183. return self.makePersistent(entryPath, contents, author).then(() => {
  184. return self.updateCache(entryPath).then(entry => {
  185. return search.add(entry)
  186. })
  187. })
  188. } else {
  189. return Promise.reject(new Error(lang.t('errors:notexist', { path: entryPath })))
  190. }
  191. }).catch((err) => {
  192. winston.error(err)
  193. return Promise.reject(new Error(lang.t('errors:savefailed')))
  194. })
  195. },
  196. /**
  197. * Update local cache
  198. *
  199. * @param {String} entryPath The entry path
  200. * @return {Promise} Promise of the operation
  201. */
  202. updateCache(entryPath) {
  203. let self = this
  204. return self.fetchOriginal(entryPath, {
  205. parseMarkdown: true,
  206. parseMeta: true,
  207. parseTree: true,
  208. includeMarkdown: true,
  209. includeParentInfo: true,
  210. cache: true
  211. }).catch(err => {
  212. winston.error(err)
  213. return err
  214. }).then((pageData) => {
  215. return {
  216. entryPath,
  217. meta: pageData.meta,
  218. parent: pageData.parent || {},
  219. text: mark.removeMarkdown(pageData.markdown)
  220. }
  221. }).catch(err => {
  222. winston.error(err)
  223. return err
  224. }).then((content) => {
  225. let parentPath = _.chain(content.entryPath).split('/').initial().join('/').value()
  226. return db.Entry.findOneAndUpdate({
  227. _id: content.entryPath
  228. }, {
  229. _id: content.entryPath,
  230. title: content.meta.title || content.entryPath,
  231. subtitle: content.meta.subtitle || '',
  232. parentTitle: content.parent.title || '',
  233. parentPath: parentPath,
  234. isDirectory: false,
  235. isEntry: true
  236. }, {
  237. new: true,
  238. upsert: true
  239. }).then(result => {
  240. let plainResult = result.toObject()
  241. plainResult.text = content.text
  242. return plainResult
  243. })
  244. }).then(result => {
  245. return self.updateTreeInfo().then(() => {
  246. return result
  247. })
  248. }).catch(err => {
  249. winston.error(err)
  250. return err
  251. })
  252. },
  253. /**
  254. * Update tree info for all directory and parent entries
  255. *
  256. * @returns {Promise<Boolean>} Promise of the operation
  257. */
  258. updateTreeInfo() {
  259. return db.Entry.distinct('parentPath', { parentPath: { $ne: '' } }).then(allPaths => {
  260. if (allPaths.length > 0) {
  261. return Promise.map(allPaths, pathItem => {
  262. let parentPath = _.chain(pathItem).split('/').initial().join('/').value()
  263. let guessedTitle = _.chain(pathItem).split('/').last().startCase().value()
  264. return db.Entry.update({ _id: pathItem }, {
  265. $set: { isDirectory: true },
  266. $setOnInsert: { isEntry: false, title: guessedTitle, parentPath }
  267. }, { upsert: true })
  268. })
  269. } else {
  270. return true
  271. }
  272. })
  273. },
  274. /**
  275. * Create a new document
  276. *
  277. * @param {String} entryPath The entry path
  278. * @param {String} contents The markdown-formatted contents
  279. * @param {Object} author The author user object
  280. * @return {Promise<Boolean>} True on success, false on failure
  281. */
  282. create(entryPath, contents, author) {
  283. let self = this
  284. return self.exists(entryPath).then((docExists) => {
  285. if (!docExists) {
  286. return self.makePersistent(entryPath, contents, author).then(() => {
  287. return self.updateCache(entryPath).then(entry => {
  288. return search.add(entry)
  289. })
  290. })
  291. } else {
  292. return Promise.reject(new Error(lang.t('errors:alreadyexists')))
  293. }
  294. }).catch((err) => {
  295. winston.error(err)
  296. return Promise.reject(new Error(lang.t('errors:generic')))
  297. })
  298. },
  299. /**
  300. * Makes a document persistent to disk and git repository
  301. *
  302. * @param {String} entryPath The entry path
  303. * @param {String} contents The markdown-formatted contents
  304. * @param {Object} author The author user object
  305. * @return {Promise<Boolean>} True on success, false on failure
  306. */
  307. makePersistent(entryPath, contents, author) {
  308. let fpath = entryHelper.getFullPath(entryPath)
  309. return fs.outputFileAsync(fpath, contents).then(() => {
  310. return git.commitDocument(entryPath, author)
  311. })
  312. },
  313. /**
  314. * Move a document
  315. *
  316. * @param {String} entryPath The current entry path
  317. * @param {String} newEntryPath The new entry path
  318. * @param {Object} author The author user object
  319. * @return {Promise} Promise of the operation
  320. */
  321. move(entryPath, newEntryPath, author) {
  322. let self = this
  323. if (_.isEmpty(entryPath) || entryPath === 'home') {
  324. return Promise.reject(new Error(lang.t('errors:invalidpath')))
  325. }
  326. return git.moveDocument(entryPath, newEntryPath).then(() => {
  327. return git.commitDocument(newEntryPath, author).then(() => {
  328. // Delete old cache version
  329. let oldEntryCachePath = entryHelper.getCachePath(entryPath)
  330. fs.unlinkAsync(oldEntryCachePath).catch((err) => { return true }) // eslint-disable-line handle-callback-err
  331. // Delete old index entry
  332. search.delete(entryPath)
  333. // Create cache for new entry
  334. return Promise.join(
  335. db.Entry.deleteOne({ _id: entryPath }),
  336. self.updateCache(newEntryPath).then(entry => {
  337. return search.add(entry)
  338. })
  339. )
  340. })
  341. })
  342. },
  343. /**
  344. * Delete a document
  345. *
  346. * @param {String} entryPath The current entry path
  347. * @param {Object} author The author user object
  348. * @return {Promise} Promise of the operation
  349. */
  350. remove(entryPath, author) {
  351. if (_.isEmpty(entryPath) || entryPath === 'home') {
  352. return Promise.reject(new Error(lang.t('errors:invalidpath')))
  353. }
  354. return git.deleteDocument(entryPath, author).then(() => {
  355. // Delete old cache version
  356. let oldEntryCachePath = entryHelper.getCachePath(entryPath)
  357. fs.unlinkAsync(oldEntryCachePath).catch((err) => { return true }) // eslint-disable-line handle-callback-err
  358. // Delete old index entry
  359. search.delete(entryPath)
  360. // Delete entry
  361. return db.Entry.deleteOne({ _id: entryPath })
  362. })
  363. },
  364. /**
  365. * Generate a starter page content based on the entry path
  366. *
  367. * @param {String} entryPath The entry path
  368. * @return {Promise<String>} Starter content
  369. */
  370. getStarter(entryPath) {
  371. let formattedTitle = _.startCase(_.last(_.split(entryPath, '/')))
  372. return fs.readFileAsync(path.join(SERVERPATH, 'app/content/create.md'), 'utf8').then((contents) => {
  373. return _.replace(contents, new RegExp('{TITLE}', 'g'), formattedTitle)
  374. })
  375. },
  376. /**
  377. * Get all entries from base path
  378. *
  379. * @param {String} basePath Path to list from
  380. * @param {Object} usr Current user
  381. * @return {Promise<Array>} List of entries
  382. */
  383. getFromTree(basePath, usr) {
  384. return db.Entry.find({ parentPath: basePath }, 'title parentPath isDirectory isEntry').sort({ title: 'asc' }).then(results => {
  385. return _.filter(results, r => {
  386. return rights.checkRole('/' + r._id, usr.rights, 'read')
  387. })
  388. })
  389. },
  390. getHistory(entryPath) {
  391. return db.Entry.findOne({ _id: entryPath, isEntry: true }).then(entry => {
  392. if (!entry) { return false }
  393. return git.getHistory(entryPath).then(history => {
  394. return {
  395. meta: entry,
  396. history
  397. }
  398. })
  399. })
  400. }
  401. }