pages.js 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325
  1. const Model = require('objection').Model
  2. const _ = require('lodash')
  3. const JSBinType = require('js-binary').Type
  4. const pageHelper = require('../helpers/page')
  5. const path = require('path')
  6. const fs = require('fs-extra')
  7. const yaml = require('js-yaml')
  8. const striptags = require('striptags')
  9. const emojiRegex = require('emoji-regex')
  10. const he = require('he')
  11. const CleanCSS = require('clean-css')
  12. const TurndownService = require('turndown')
  13. const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
  14. const cheerio = require('cheerio')
  15. const pageRegex = /^[a-zA0-90-9-_/]*$/
  16. const frontmatterRegex = {
  17. html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
  18. legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
  19. markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
  20. }
  21. const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
  22. // const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig
  23. /**
  24. * Pages model
  25. */
  26. module.exports = class Page extends Model {
  27. static get tableName() { return 'pages' }
  28. static get jsonSchema () {
  29. return {
  30. type: 'object',
  31. required: ['path', 'title'],
  32. properties: {
  33. id: {type: 'string'},
  34. path: {type: 'string'},
  35. hash: {type: 'string'},
  36. title: {type: 'string'},
  37. description: {type: 'string'},
  38. publishState: {type: 'string'},
  39. publishStartDate: {type: 'string'},
  40. publishEndDate: {type: 'string'},
  41. content: {type: 'string'},
  42. contentType: {type: 'string'},
  43. siteId: {type: 'string'},
  44. createdAt: {type: 'string'},
  45. updatedAt: {type: 'string'}
  46. }
  47. }
  48. }
  49. static get jsonAttributes() {
  50. return ['config', 'historyData', 'relations', 'scripts', 'toc']
  51. }
  52. static get relationMappings() {
  53. return {
  54. tags: {
  55. relation: Model.ManyToManyRelation,
  56. modelClass: require('./tags'),
  57. join: {
  58. from: 'pages.id',
  59. through: {
  60. from: 'pageTags.pageId',
  61. to: 'pageTags.tagId'
  62. },
  63. to: 'tags.id'
  64. }
  65. },
  66. links: {
  67. relation: Model.HasManyRelation,
  68. modelClass: require('./pageLinks'),
  69. join: {
  70. from: 'pages.id',
  71. to: 'pageLinks.pageId'
  72. }
  73. },
  74. author: {
  75. relation: Model.BelongsToOneRelation,
  76. modelClass: require('./users'),
  77. join: {
  78. from: 'pages.authorId',
  79. to: 'users.id'
  80. }
  81. },
  82. creator: {
  83. relation: Model.BelongsToOneRelation,
  84. modelClass: require('./users'),
  85. join: {
  86. from: 'pages.creatorId',
  87. to: 'users.id'
  88. }
  89. },
  90. locale: {
  91. relation: Model.BelongsToOneRelation,
  92. modelClass: require('./locales'),
  93. join: {
  94. from: 'pages.localeCode',
  95. to: 'locales.code'
  96. }
  97. }
  98. }
  99. }
  100. $beforeUpdate() {
  101. this.updatedAt = new Date().toISOString()
  102. }
  103. $beforeInsert() {
  104. this.createdAt = new Date().toISOString()
  105. this.updatedAt = new Date().toISOString()
  106. }
  107. /**
  108. * Solving the violates foreign key constraint using cascade strategy
  109. * using static hooks
  110. * @see https://vincit.github.io/objection.js/api/types/#type-statichookarguments
  111. */
  112. static async beforeDelete({ asFindQuery }) {
  113. const page = await asFindQuery().select('id')
  114. await WIKI.db.comments.query().delete().where('pageId', page[0].id)
  115. }
  116. /**
  117. * Cache Schema
  118. */
  119. static get cacheSchema() {
  120. return new JSBinType({
  121. id: 'string',
  122. authorId: 'string',
  123. authorName: 'string',
  124. createdAt: 'string',
  125. creatorId: 'string',
  126. creatorName: 'string',
  127. description: 'string',
  128. editor: 'string',
  129. publishState: 'string',
  130. publishEndDate: 'string',
  131. publishStartDate: 'string',
  132. render: 'string',
  133. siteId: 'string',
  134. tags: [
  135. {
  136. tag: 'string'
  137. }
  138. ],
  139. extra: {
  140. js: 'string',
  141. css: 'string'
  142. },
  143. title: 'string',
  144. toc: 'string',
  145. updatedAt: 'string'
  146. })
  147. }
  148. /**
  149. * Inject page metadata into contents
  150. *
  151. * @returns {string} Page Contents with Injected Metadata
  152. */
  153. injectMetadata () {
  154. return pageHelper.injectPageMetadata(this)
  155. }
  156. /**
  157. * Get the page's file extension based on content type
  158. *
  159. * @returns {string} File Extension
  160. */
  161. getFileExtension() {
  162. return pageHelper.getFileExtension(this.contentType)
  163. }
  164. /**
  165. * Parse injected page metadata from raw content
  166. *
  167. * @param {String} raw Raw file contents
  168. * @param {String} contentType Content Type
  169. * @returns {Object} Parsed Page Metadata with Raw Content
  170. */
  171. static parseMetadata (raw, contentType) {
  172. let result
  173. try {
  174. switch (contentType) {
  175. case 'markdown':
  176. result = frontmatterRegex.markdown.exec(raw)
  177. if (result[2]) {
  178. return {
  179. ...yaml.safeLoad(result[2]),
  180. content: result[3]
  181. }
  182. } else {
  183. // Attempt legacy v1 format
  184. result = frontmatterRegex.legacy.exec(raw)
  185. if (result[2]) {
  186. return {
  187. title: result[2],
  188. description: result[4],
  189. content: result[5]
  190. }
  191. }
  192. }
  193. break
  194. case 'html':
  195. result = frontmatterRegex.html.exec(raw)
  196. if (result[2]) {
  197. return {
  198. ...yaml.safeLoad(result[2]),
  199. content: result[3]
  200. }
  201. }
  202. break
  203. }
  204. } catch (err) {
  205. WIKI.logger.warn('Failed to parse page metadata. Invalid syntax.')
  206. }
  207. return {
  208. content: raw
  209. }
  210. }
  211. /**
  212. * Create a New Page
  213. *
  214. * @param {Object} opts Page Properties
  215. * @returns {Promise} Promise of the Page Model Instance
  216. */
  217. static async createPage(opts) {
  218. // -> Validate site
  219. if (!WIKI.sites[opts.siteId]) {
  220. throw new WIKI.Error.Custom('InvalidSiteId', 'Site ID is invalid.')
  221. }
  222. // -> Remove trailing slash
  223. if (opts.path.endsWith('/')) {
  224. opts.path = opts.path.slice(0, -1)
  225. }
  226. // -> Remove starting slash
  227. if (opts.path.startsWith('/')) {
  228. opts.path = opts.path.slice(1)
  229. }
  230. // -> Validate path
  231. if (!pageRegex.test(opts.path)) {
  232. throw new Error('ERR_INVALID_PATH')
  233. }
  234. opts.path = opts.path.toLowerCase()
  235. // const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
  236. // -> Check for page access
  237. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  238. locale: opts.locale,
  239. path: opts.path
  240. })) {
  241. throw new WIKI.Error.PageDeleteForbidden()
  242. }
  243. // -> Check for duplicate
  244. const dupCheck = await WIKI.db.pages.query().select('id').where('localeCode', opts.locale).where('path', opts.path).first()
  245. if (dupCheck) {
  246. throw new WIKI.Error.PageDuplicateCreate()
  247. }
  248. // -> Check for empty content
  249. if (!opts.content || _.trim(opts.content).length < 1) {
  250. throw new WIKI.Error.PageEmptyContent()
  251. }
  252. // -> Format CSS Scripts
  253. let scriptCss = ''
  254. if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
  255. locale: opts.locale,
  256. path: opts.path
  257. })) {
  258. if (!_.isEmpty(opts.scriptCss)) {
  259. scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
  260. } else {
  261. scriptCss = ''
  262. }
  263. }
  264. // -> Format JS Scripts
  265. let scriptJsLoad = ''
  266. let scriptJsUnload = ''
  267. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  268. locale: opts.locale,
  269. path: opts.path
  270. })) {
  271. scriptJsLoad = opts.scriptJsLoad || ''
  272. scriptJsUnload = opts.scriptJsUnload || ''
  273. }
  274. // -> Create page
  275. const page = await WIKI.db.pages.query().insert({
  276. authorId: opts.user.id,
  277. content: opts.content,
  278. creatorId: opts.user.id,
  279. config: {
  280. allowComments: opts.allowComments ?? true,
  281. allowContributions: opts.allowContributions ?? true,
  282. allowRatings: opts.allowRatings ?? true,
  283. showSidebar: opts.showSidebar ?? true,
  284. showTags: opts.showTags ?? true,
  285. showToc: opts.showToc ?? true,
  286. tocDepth: opts.tocDepth ?? WIKI.sites[opts.siteId].config?.defaults.tocDepth
  287. },
  288. contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
  289. description: opts.description,
  290. editor: opts.editor,
  291. hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
  292. icon: opts.icon,
  293. isBrowsable: opts.isBrowsable ?? true,
  294. localeCode: opts.locale,
  295. ownerId: opts.user.id,
  296. path: opts.path,
  297. publishState: opts.publishState,
  298. publishEndDate: opts.publishEndDate?.toISO(),
  299. publishStartDate: opts.publishStartDate?.toISO(),
  300. relations: opts.relations ?? [],
  301. siteId: opts.siteId,
  302. title: opts.title,
  303. toc: '[]',
  304. scripts: JSON.stringify({
  305. jsLoad: scriptJsLoad,
  306. jsUnload: scriptJsUnload,
  307. css: scriptCss
  308. })
  309. }).returning('*')
  310. // -> Save Tags
  311. if (opts.tags && opts.tags.length > 0) {
  312. await WIKI.db.tags.associateTags({ tags: opts.tags, page })
  313. }
  314. // -> Render page to HTML
  315. await WIKI.db.pages.renderPage(page)
  316. // -> Add to tree
  317. const pathParts = page.path.split('/')
  318. await WIKI.db.tree.addPage({
  319. id: page.id,
  320. parentPath: _.initial(pathParts).join('/'),
  321. fileName: _.last(pathParts),
  322. locale: page.localeCode,
  323. title: page.title,
  324. meta: {
  325. authorId: page.authorId,
  326. contentType: page.contentType,
  327. creatorId: page.creatorId,
  328. description: page.description,
  329. isBrowsable: page.isBrowsable,
  330. ownerId: page.ownerId,
  331. publishState: page.publishState,
  332. publishEndDate: page.publishEndDate,
  333. publishStartDate: page.publishStartDate
  334. },
  335. siteId: page.siteId
  336. })
  337. return page
  338. // TODO: Handle remaining flow
  339. // -> Rebuild page tree
  340. await WIKI.db.pages.rebuildTree()
  341. // -> Add to Search Index
  342. const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
  343. page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
  344. await WIKI.data.searchEngine.created(page)
  345. // -> Add to Storage
  346. if (!opts.skipStorage) {
  347. await WIKI.db.storage.pageEvent({
  348. event: 'created',
  349. page
  350. })
  351. }
  352. // -> Reconnect Links
  353. await WIKI.db.pages.reconnectLinks({
  354. locale: page.localeCode,
  355. path: page.path,
  356. mode: 'create'
  357. })
  358. // -> Get latest updatedAt
  359. page.updatedAt = await WIKI.db.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
  360. return page
  361. }
  362. /**
  363. * Update an Existing Page
  364. *
  365. * @param {Object} opts Page Properties
  366. * @returns {Promise} Promise of the Page Model Instance
  367. */
  368. static async updatePage(opts) {
  369. // -> Fetch original page
  370. const ogPage = await WIKI.db.pages.query().findById(opts.id)
  371. if (!ogPage) {
  372. throw new Error('ERR_PAGE_NOT_FOUND')
  373. }
  374. // -> Check for page access
  375. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  376. locale: ogPage.localeCode,
  377. path: ogPage.path
  378. })) {
  379. throw new Error('ERR_PAGE_UPDATE_FORBIDDEN')
  380. }
  381. const patch = {}
  382. const historyData = {
  383. action: 'updated',
  384. affectedFields: []
  385. }
  386. // -> Create version snapshot
  387. await WIKI.db.pageHistory.addVersion(ogPage)
  388. // -> Basic fields
  389. if ('title' in opts.patch) {
  390. patch.title = opts.patch.title.trim()
  391. historyData.affectedFields.push('title')
  392. if (patch.title.length < 1) {
  393. throw new Error('ERR_PAGE_TITLE_MISSING')
  394. }
  395. }
  396. if ('description' in opts.patch) {
  397. patch.description = opts.patch.description.trim()
  398. historyData.affectedFields.push('description')
  399. }
  400. if ('icon' in opts.patch) {
  401. patch.icon = opts.patch.icon.trim()
  402. historyData.affectedFields.push('icon')
  403. }
  404. if ('content' in opts.patch) {
  405. patch.content = opts.patch.content
  406. historyData.affectedFields.push('content')
  407. }
  408. // -> Publish State
  409. if (opts.patch.publishState) {
  410. patch.publishState = opts.patch.publishState
  411. historyData.affectedFields.push('publishState')
  412. if (patch.publishState === 'scheduled' && (!opts.patch.publishStartDate || !opts.patch.publishEndDate)) {
  413. throw new Error('ERR_PAGE_MISSING_SCHEDULED_DATES')
  414. }
  415. }
  416. if (opts.patch.publishStartDate) {
  417. patch.publishStartDate = opts.patch.publishStartDate
  418. historyData.affectedFields.push('publishStartDate')
  419. }
  420. if (opts.patch.publishEndDate) {
  421. patch.publishEndDate = opts.patch.publishEndDate
  422. historyData.affectedFields.push('publishEndDate')
  423. }
  424. // -> Page Config
  425. if ('isBrowsable' in opts.patch) {
  426. patch.config = {
  427. ...patch.config ?? ogPage.config ?? {},
  428. isBrowsable: opts.patch.isBrowsable
  429. }
  430. historyData.affectedFields.push('isBrowsable')
  431. }
  432. if ('allowComments' in opts.patch) {
  433. patch.config = {
  434. ...patch.config ?? ogPage.config ?? {},
  435. allowComments: opts.patch.allowComments
  436. }
  437. historyData.affectedFields.push('allowComments')
  438. }
  439. if ('allowContributions' in opts.patch) {
  440. patch.config = {
  441. ...patch.config ?? ogPage.config ?? {},
  442. allowContributions: opts.patch.allowContributions
  443. }
  444. historyData.affectedFields.push('allowContributions')
  445. }
  446. if ('allowRatings' in opts.patch) {
  447. patch.config = {
  448. ...patch.config ?? ogPage.config ?? {},
  449. allowRatings: opts.patch.allowRatings
  450. }
  451. historyData.affectedFields.push('allowRatings')
  452. }
  453. if ('showSidebar' in opts.patch) {
  454. patch.config = {
  455. ...patch.config ?? ogPage.config ?? {},
  456. showSidebar: opts.patch.showSidebar
  457. }
  458. historyData.affectedFields.push('showSidebar')
  459. }
  460. if ('showTags' in opts.patch) {
  461. patch.config = {
  462. ...patch.config ?? ogPage.config ?? {},
  463. showTags: opts.patch.showTags
  464. }
  465. historyData.affectedFields.push('showTags')
  466. }
  467. if ('showToc' in opts.patch) {
  468. patch.config = {
  469. ...patch.config ?? ogPage.config ?? {},
  470. showToc: opts.patch.showToc
  471. }
  472. historyData.affectedFields.push('showToc')
  473. }
  474. if ('tocDepth' in opts.patch) {
  475. patch.config = {
  476. ...patch.config ?? ogPage.config ?? {},
  477. tocDepth: opts.patch.tocDepth
  478. }
  479. historyData.affectedFields.push('tocDepth')
  480. if (patch.config.tocDepth?.min < 1 || patch.config.tocDepth?.min > 6) {
  481. throw new Error('ERR_PAGE_INVALID_TOC_DEPTH')
  482. }
  483. if (patch.config.tocDepth?.max < 1 || patch.config.tocDepth?.max > 6) {
  484. throw new Error('ERR_PAGE_INVALID_TOC_DEPTH')
  485. }
  486. }
  487. // -> Relations
  488. if ('relations' in opts.patch) {
  489. patch.relations = opts.patch.relations.map(r => {
  490. if (r.label.length < 1) {
  491. throw new Error('ERR_PAGE_RELATION_LABEL_MISSING')
  492. } else if (r.label.length > 255) {
  493. throw new Error('ERR_PAGE_RELATION_LABEL_TOOLONG')
  494. } else if (r.icon.length > 255) {
  495. throw new Error('ERR_PAGE_RELATION_ICON_INVALID')
  496. } else if (r.target.length > 1024) {
  497. throw new Error('ERR_PAGE_RELATION_TARGET_INVALID')
  498. }
  499. return r
  500. })
  501. historyData.affectedFields.push('relations')
  502. }
  503. // -> Format CSS Scripts
  504. if (opts.patch.scriptCss) {
  505. if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
  506. locale: ogPage.localeCode,
  507. path: ogPage.path
  508. })) {
  509. patch.scripts = {
  510. ...patch.scripts ?? ogPage.scripts ?? {},
  511. css: !_.isEmpty(opts.patch.scriptCss) ? new CleanCSS({ inline: false }).minify(opts.patch.scriptCss).styles : ''
  512. }
  513. historyData.affectedFields.push('scripts.css')
  514. }
  515. }
  516. // -> Format JS Scripts
  517. if (opts.patch.scriptJsLoad) {
  518. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  519. locale: ogPage.localeCode,
  520. path: ogPage.path
  521. })) {
  522. patch.scripts = {
  523. ...patch.scripts ?? ogPage.scripts ?? {},
  524. jsLoad: opts.patch.scriptJsLoad ?? ''
  525. }
  526. historyData.affectedFields.push('scripts.jsLoad')
  527. }
  528. }
  529. if (opts.patch.scriptJsUnload) {
  530. if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
  531. locale: ogPage.localeCode,
  532. path: ogPage.path
  533. })) {
  534. patch.scripts = {
  535. ...patch.scripts ?? ogPage.scripts ?? {},
  536. jsUnload: opts.patch.scriptJsUnload ?? ''
  537. }
  538. historyData.affectedFields.push('scripts.jsUnload')
  539. }
  540. }
  541. // -> Tags
  542. if ('tags' in opts.patch) {
  543. historyData.affectedFields.push('tags')
  544. }
  545. // -> Update page
  546. await WIKI.db.pages.query().patch({
  547. ...patch,
  548. authorId: opts.user.id,
  549. historyData
  550. }).where('id', ogPage.id)
  551. let page = await WIKI.db.pages.getPageFromDb(ogPage.id)
  552. // -> Save Tags
  553. if (opts.patch.tags) {
  554. await WIKI.db.tags.associateTags({ tags: opts.patch.tags, page })
  555. }
  556. // -> Render page to HTML
  557. if (opts.patch.content) {
  558. await WIKI.db.pages.renderPage(page)
  559. }
  560. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  561. // -> Update tree
  562. await WIKI.db.knex('tree').where('id', page.id).update({
  563. title: page.title,
  564. meta: {
  565. authorId: page.authorId,
  566. contentType: page.contentType,
  567. creatorId: page.creatorId,
  568. description: page.description,
  569. isBrowsable: page.isBrowsable,
  570. ownerId: page.ownerId,
  571. publishState: page.publishState,
  572. publishEndDate: page.publishEndDate,
  573. publishStartDate: page.publishStartDate
  574. },
  575. updatedAt: page.updatedAt
  576. })
  577. // // -> Update Search Index
  578. // const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
  579. // page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
  580. // await WIKI.data.searchEngine.updated(page)
  581. // -> Update on Storage
  582. // if (!opts.skipStorage) {
  583. // await WIKI.db.storage.pageEvent({
  584. // event: 'updated',
  585. // page
  586. // })
  587. // }
  588. // -> Perform move?
  589. if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
  590. // -> Check target path access
  591. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  592. locale: opts.locale,
  593. path: opts.path
  594. })) {
  595. throw new WIKI.Error.PageMoveForbidden()
  596. }
  597. await WIKI.db.pages.movePage({
  598. id: page.id,
  599. destinationLocale: opts.locale,
  600. destinationPath: opts.path,
  601. user: opts.user
  602. })
  603. }
  604. // -> Get latest updatedAt
  605. page.updatedAt = await WIKI.db.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
  606. return page
  607. }
  608. /**
  609. * Convert an Existing Page
  610. *
  611. * @param {Object} opts Page Properties
  612. * @returns {Promise} Promise of the Page Model Instance
  613. */
  614. static async convertPage(opts) {
  615. // -> Fetch original page
  616. const ogPage = await WIKI.db.pages.query().findById(opts.id)
  617. if (!ogPage) {
  618. throw new Error('Invalid Page Id')
  619. }
  620. if (ogPage.editor === opts.editor) {
  621. throw new Error('Page is already using this editor. Nothing to convert.')
  622. }
  623. // -> Check for page access
  624. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  625. locale: ogPage.localeCode,
  626. path: ogPage.path
  627. })) {
  628. throw new WIKI.Error.PageUpdateForbidden()
  629. }
  630. // -> Check content type
  631. const sourceContentType = ogPage.contentType
  632. const targetContentType = _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text')
  633. const shouldConvert = sourceContentType !== targetContentType
  634. let convertedContent = null
  635. // -> Convert content
  636. if (shouldConvert) {
  637. // -> Markdown => HTML
  638. if (sourceContentType === 'markdown' && targetContentType === 'html') {
  639. if (!ogPage.render) {
  640. throw new Error('Aborted conversion because rendered page content is empty!')
  641. }
  642. convertedContent = ogPage.render
  643. const $ = cheerio.load(convertedContent, {
  644. decodeEntities: true
  645. })
  646. if ($.root().children().length > 0) {
  647. // Remove header anchors
  648. $('.toc-anchor').remove()
  649. // Attempt to convert tabsets
  650. $('tabset').each((tabI, tabElm) => {
  651. const tabHeaders = []
  652. // -> Extract templates
  653. $(tabElm).children('template').each((tmplI, tmplElm) => {
  654. if ($(tmplElm).attr('v-slot:tabs') === '') {
  655. $(tabElm).before('<ul class="tabset-headers">' + $(tmplElm).html() + '</ul>')
  656. } else {
  657. $(tabElm).after('<div class="markdown-tabset">' + $(tmplElm).html() + '</div>')
  658. }
  659. })
  660. // -> Parse tab headers
  661. $(tabElm).prev('.tabset-headers').children((i, elm) => {
  662. tabHeaders.push($(elm).html())
  663. })
  664. $(tabElm).prev('.tabset-headers').remove()
  665. // -> Inject tab headers
  666. $(tabElm).next('.markdown-tabset').children((i, elm) => {
  667. if (tabHeaders.length > i) {
  668. $(elm).prepend(`<h2>${tabHeaders[i]}</h2>`)
  669. }
  670. })
  671. $(tabElm).next('.markdown-tabset').prepend('<h1>Tabset</h1>')
  672. $(tabElm).remove()
  673. })
  674. convertedContent = $.html('body').replace('<body>', '').replace('</body>', '').replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {
  675. code = parseInt(code, 16)
  676. // Don't unescape ASCII characters, assuming they're encoded for a good reason
  677. if (code < 0x80) return entity
  678. return String.fromCodePoint(code)
  679. })
  680. }
  681. // -> HTML => Markdown
  682. } else if (sourceContentType === 'html' && targetContentType === 'markdown') {
  683. const td = new TurndownService({
  684. bulletListMarker: '-',
  685. codeBlockStyle: 'fenced',
  686. emDelimiter: '*',
  687. fence: '```',
  688. headingStyle: 'atx',
  689. hr: '---',
  690. linkStyle: 'inlined',
  691. preformattedCode: true,
  692. strongDelimiter: '**'
  693. })
  694. td.use(turndownPluginGfm)
  695. td.keep(['kbd'])
  696. td.addRule('subscript', {
  697. filter: ['sub'],
  698. replacement: c => `~${c}~`
  699. })
  700. td.addRule('superscript', {
  701. filter: ['sup'],
  702. replacement: c => `^${c}^`
  703. })
  704. td.addRule('underline', {
  705. filter: ['u'],
  706. replacement: c => `_${c}_`
  707. })
  708. td.addRule('taskList', {
  709. filter: (n, o) => {
  710. return n.nodeName === 'INPUT' && n.getAttribute('type') === 'checkbox'
  711. },
  712. replacement: (c, n) => {
  713. return n.getAttribute('checked') ? '[x] ' : '[ ] '
  714. }
  715. })
  716. td.addRule('removeTocAnchors', {
  717. filter: (n, o) => {
  718. return n.nodeName === 'A' && n.classList.contains('toc-anchor')
  719. },
  720. replacement: c => ''
  721. })
  722. convertedContent = td.turndown(ogPage.content)
  723. // -> Unsupported
  724. } else {
  725. throw new Error('Unsupported source / destination content types combination.')
  726. }
  727. }
  728. // -> Create version snapshot
  729. if (shouldConvert) {
  730. await WIKI.db.pageHistory.addVersion({
  731. ...ogPage,
  732. action: 'updated',
  733. versionDate: ogPage.updatedAt
  734. })
  735. }
  736. // -> Update page
  737. await WIKI.db.pages.query().patch({
  738. contentType: targetContentType,
  739. editor: opts.editor,
  740. ...(convertedContent ? { content: convertedContent } : {})
  741. }).where('id', ogPage.id)
  742. const page = await WIKI.db.pages.getPageFromDb(ogPage.id)
  743. await WIKI.db.pages.deletePageFromCache(page.hash)
  744. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  745. // -> Update on Storage
  746. await WIKI.db.storage.pageEvent({
  747. event: 'updated',
  748. page
  749. })
  750. }
  751. /**
  752. * Move a Page
  753. *
  754. * @param {Object} opts Page Properties
  755. * @returns {Promise} Promise with no value
  756. */
  757. static async movePage(opts) {
  758. let page
  759. if (_.has(opts, 'id')) {
  760. page = await WIKI.db.pages.query().findById(opts.id)
  761. } else {
  762. page = await WIKI.db.pages.query().findOne({
  763. path: opts.path,
  764. localeCode: opts.locale
  765. })
  766. }
  767. if (!page) {
  768. throw new WIKI.Error.PageNotFound()
  769. }
  770. // -> Validate path
  771. if (opts.destinationPath.includes('.') || opts.destinationPath.includes(' ') || opts.destinationPath.includes('\\') || opts.destinationPath.includes('//')) {
  772. throw new WIKI.Error.PageIllegalPath()
  773. }
  774. // -> Remove trailing slash
  775. if (opts.destinationPath.endsWith('/')) {
  776. opts.destinationPath = opts.destinationPath.slice(0, -1)
  777. }
  778. // -> Remove starting slash
  779. if (opts.destinationPath.startsWith('/')) {
  780. opts.destinationPath = opts.destinationPath.slice(1)
  781. }
  782. // -> Check for source page access
  783. if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
  784. locale: page.localeCode,
  785. path: page.path
  786. })) {
  787. throw new WIKI.Error.PageMoveForbidden()
  788. }
  789. // -> Check for destination page access
  790. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  791. locale: opts.destinationLocale,
  792. path: opts.destinationPath
  793. })) {
  794. throw new WIKI.Error.PageMoveForbidden()
  795. }
  796. // -> Check for existing page at destination path
  797. const destPage = await WIKI.db.pages.query().findOne({
  798. path: opts.destinationPath,
  799. localeCode: opts.destinationLocale
  800. })
  801. if (destPage) {
  802. throw new WIKI.Error.PagePathCollision()
  803. }
  804. // -> Create version snapshot
  805. await WIKI.db.pageHistory.addVersion({
  806. ...page,
  807. action: 'moved',
  808. versionDate: page.updatedAt
  809. })
  810. const destinationHash = pageHelper.generateHash({ path: opts.destinationPath, locale: opts.destinationLocale })
  811. // -> Move page
  812. const destinationTitle = (page.title === page.path ? opts.destinationPath : page.title)
  813. await WIKI.db.pages.query().patch({
  814. path: opts.destinationPath,
  815. localeCode: opts.destinationLocale,
  816. title: destinationTitle,
  817. hash: destinationHash
  818. }).findById(page.id)
  819. await WIKI.db.pages.deletePageFromCache(page.hash)
  820. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  821. // -> Rebuild page tree
  822. await WIKI.db.pages.rebuildTree()
  823. // -> Rename in Search Index
  824. const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
  825. page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
  826. await WIKI.data.searchEngine.renamed({
  827. ...page,
  828. destinationPath: opts.destinationPath,
  829. destinationLocaleCode: opts.destinationLocale,
  830. destinationHash
  831. })
  832. // -> Rename in Storage
  833. if (!opts.skipStorage) {
  834. await WIKI.db.storage.pageEvent({
  835. event: 'renamed',
  836. page: {
  837. ...page,
  838. destinationPath: opts.destinationPath,
  839. destinationLocaleCode: opts.destinationLocale,
  840. destinationHash,
  841. moveAuthorId: opts.user.id,
  842. moveAuthorName: opts.user.name,
  843. moveAuthorEmail: opts.user.email
  844. }
  845. })
  846. }
  847. // -> Reconnect Links : Changing old links to the new path
  848. await WIKI.db.pages.reconnectLinks({
  849. sourceLocale: page.localeCode,
  850. sourcePath: page.path,
  851. locale: opts.destinationLocale,
  852. path: opts.destinationPath,
  853. mode: 'move'
  854. })
  855. // -> Reconnect Links : Validate invalid links to the new path
  856. await WIKI.db.pages.reconnectLinks({
  857. locale: opts.destinationLocale,
  858. path: opts.destinationPath,
  859. mode: 'create'
  860. })
  861. }
  862. /**
  863. * Delete an Existing Page
  864. *
  865. * @param {Object} opts Page Properties
  866. * @returns {Promise} Promise with no value
  867. */
  868. static async deletePage(opts) {
  869. const page = await WIKI.db.pages.getPageFromDb(_.has(opts, 'id') ? opts.id : opts)
  870. if (!page) {
  871. throw new WIKI.Error.PageNotFound()
  872. }
  873. // -> Check for page access
  874. if (!WIKI.auth.checkAccess(opts.user, ['delete:pages'], {
  875. locale: page.locale,
  876. path: page.path
  877. })) {
  878. throw new WIKI.Error.PageDeleteForbidden()
  879. }
  880. // -> Create version snapshot
  881. await WIKI.db.pageHistory.addVersion({
  882. ...page,
  883. action: 'deleted',
  884. versionDate: page.updatedAt
  885. })
  886. // -> Delete page
  887. await WIKI.db.pages.query().delete().where('id', page.id)
  888. await WIKI.db.knex('tree').where('id', page.id).del()
  889. await WIKI.db.pages.deletePageFromCache(page.hash)
  890. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  891. // -> Delete from Search Index
  892. await WIKI.data.searchEngine.deleted(page)
  893. // -> Delete from Storage
  894. if (!opts.skipStorage) {
  895. await WIKI.db.storage.pageEvent({
  896. event: 'deleted',
  897. page
  898. })
  899. }
  900. // -> Reconnect Links
  901. await WIKI.db.pages.reconnectLinks({
  902. locale: page.localeCode,
  903. path: page.path,
  904. mode: 'delete'
  905. })
  906. }
  907. /**
  908. * Reconnect links to new/move/deleted page
  909. *
  910. * @param {Object} opts - Page parameters
  911. * @param {string} opts.path - Page Path
  912. * @param {string} opts.locale - Page Locale Code
  913. * @param {string} [opts.sourcePath] - Previous Page Path (move only)
  914. * @param {string} [opts.sourceLocale] - Previous Page Locale Code (move only)
  915. * @param {string} opts.mode - Page Update mode (create, move, delete)
  916. * @returns {Promise} Promise with no value
  917. */
  918. static async reconnectLinks (opts) {
  919. const pageHref = `/${opts.locale}/${opts.path}`
  920. let replaceArgs = {
  921. from: '',
  922. to: ''
  923. }
  924. switch (opts.mode) {
  925. case 'create':
  926. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  927. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  928. break
  929. case 'move':
  930. const prevPageHref = `/${opts.sourceLocale}/${opts.sourcePath}`
  931. replaceArgs.from = `<a href="${prevPageHref}" class="is-internal-link is-valid-page">`
  932. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  933. break
  934. case 'delete':
  935. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  936. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  937. break
  938. default:
  939. return false
  940. }
  941. let affectedHashes = []
  942. // -> Perform replace and return affected page hashes (POSTGRES only)
  943. if (WIKI.config.db.type === 'postgres') {
  944. const qryHashes = await WIKI.db.pages.query()
  945. .returning('hash')
  946. .patch({
  947. render: WIKI.db.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  948. })
  949. .whereIn('pages.id', function () {
  950. this.select('pageLinks.pageId').from('pageLinks').where({
  951. 'pageLinks.path': opts.path,
  952. 'pageLinks.localeCode': opts.locale
  953. })
  954. })
  955. affectedHashes = qryHashes.map(h => h.hash)
  956. } else {
  957. // -> Perform replace, then query affected page hashes (MYSQL, MARIADB, MSSQL, SQLITE only)
  958. await WIKI.db.pages.query()
  959. .patch({
  960. render: WIKI.db.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  961. })
  962. .whereIn('pages.id', function () {
  963. this.select('pageLinks.pageId').from('pageLinks').where({
  964. 'pageLinks.path': opts.path,
  965. 'pageLinks.localeCode': opts.locale
  966. })
  967. })
  968. const qryHashes = await WIKI.db.pages.query()
  969. .column('hash')
  970. .whereIn('pages.id', function () {
  971. this.select('pageLinks.pageId').from('pageLinks').where({
  972. 'pageLinks.path': opts.path,
  973. 'pageLinks.localeCode': opts.locale
  974. })
  975. })
  976. affectedHashes = qryHashes.map(h => h.hash)
  977. }
  978. for (const hash of affectedHashes) {
  979. await WIKI.db.pages.deletePageFromCache(hash)
  980. WIKI.events.outbound.emit('deletePageFromCache', hash)
  981. }
  982. }
  983. /**
  984. * Rebuild page tree for new/updated/deleted page
  985. *
  986. * @returns {Promise} Promise with no value
  987. */
  988. static async rebuildTree() {
  989. const rebuildJob = await WIKI.scheduler.registerJob({
  990. name: 'rebuild-tree',
  991. immediate: true,
  992. worker: true
  993. })
  994. return rebuildJob.finished
  995. }
  996. /**
  997. * Trigger the rendering of a page
  998. *
  999. * @param {Object} page Page Model Instance
  1000. * @returns {Promise} Promise with no value
  1001. */
  1002. static async renderPage(page) {
  1003. const renderJob = await WIKI.scheduler.addJob({
  1004. task: 'render-page',
  1005. payload: {
  1006. id: page.id
  1007. },
  1008. maxRetries: 0,
  1009. promise: true
  1010. })
  1011. return renderJob.promise
  1012. }
  1013. /**
  1014. * Fetch an Existing Page from Cache if possible, from DB otherwise and save render to Cache
  1015. *
  1016. * @param {Object} opts Page Properties
  1017. * @returns {Promise} Promise of the Page Model Instance
  1018. */
  1019. static async getPage(opts) {
  1020. return WIKI.db.pages.getPageFromDb(opts)
  1021. // -> Get from cache first
  1022. let page = await WIKI.db.pages.getPageFromCache(opts)
  1023. if (!page) {
  1024. // -> Get from DB
  1025. page = await WIKI.db.pages.getPageFromDb(opts)
  1026. if (page) {
  1027. if (page.render) {
  1028. // -> Save render to cache
  1029. await WIKI.db.pages.savePageToCache(page)
  1030. } else {
  1031. // -> No render? Possible duplicate issue
  1032. /* TODO: Detect duplicate and delete */
  1033. throw new Error('Error while fetching page. No rendered version of this page exists. Try to edit the page and save it again.')
  1034. }
  1035. }
  1036. }
  1037. return page
  1038. }
  1039. /**
  1040. * Fetch an Existing Page from the Database
  1041. *
  1042. * @param {Object} opts Page Properties
  1043. * @returns {Promise} Promise of the Page Model Instance
  1044. */
  1045. static async getPageFromDb(opts) {
  1046. const queryModeID = typeof opts === 'string'
  1047. try {
  1048. return WIKI.db.pages.query()
  1049. .column([
  1050. 'pages.*',
  1051. {
  1052. authorName: 'author.name',
  1053. authorEmail: 'author.email',
  1054. creatorName: 'creator.name',
  1055. creatorEmail: 'creator.email'
  1056. }
  1057. ])
  1058. .joinRelated('author')
  1059. .joinRelated('creator')
  1060. .withGraphJoined('tags')
  1061. .modifyGraph('tags', builder => {
  1062. builder.select('tag')
  1063. })
  1064. .where(queryModeID ? {
  1065. 'pages.id': opts
  1066. } : {
  1067. 'pages.siteId': opts.siteId,
  1068. 'pages.path': opts.path,
  1069. 'pages.localeCode': opts.locale
  1070. })
  1071. // .andWhere(builder => {
  1072. // if (queryModeID) return
  1073. // builder.where({
  1074. // 'pages.isPublished': true
  1075. // }).orWhere({
  1076. // 'pages.isPublished': false,
  1077. // 'pages.authorId': opts.userId
  1078. // })
  1079. // })
  1080. .first()
  1081. } catch (err) {
  1082. WIKI.logger.warn(err)
  1083. throw err
  1084. }
  1085. }
  1086. /**
  1087. * Save a Page Model Instance to Cache
  1088. *
  1089. * @param {Object} page Page Model Instance
  1090. * @returns {Promise} Promise with no value
  1091. */
  1092. static async savePageToCache(page) {
  1093. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${page.hash}.bin`)
  1094. await fs.outputFile(cachePath, WIKI.db.pages.cacheSchema.encode({
  1095. id: page.id,
  1096. authorId: page.authorId,
  1097. authorName: page.authorName,
  1098. createdAt: page.createdAt.toISOString(),
  1099. creatorId: page.creatorId,
  1100. creatorName: page.creatorName,
  1101. description: page.description,
  1102. editor: page.editor,
  1103. extra: {
  1104. css: _.get(page, 'extra.css', ''),
  1105. js: _.get(page, 'extra.js', '')
  1106. },
  1107. publishState: page.publishState ?? '',
  1108. publishEndDate: page.publishEndDate ?? '',
  1109. publishStartDate: page.publishStartDate ?? '',
  1110. render: page.render,
  1111. siteId: page.siteId,
  1112. tags: page.tags.map(t => _.pick(t, ['tag'])),
  1113. title: page.title,
  1114. toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
  1115. updatedAt: page.updatedAt.toISOString()
  1116. }))
  1117. }
  1118. /**
  1119. * Fetch an Existing Page from Cache
  1120. *
  1121. * @param {Object} opts Page Properties
  1122. * @returns {Promise} Promise of the Page Model Instance
  1123. */
  1124. static async getPageFromCache(opts) {
  1125. const pageHash = pageHelper.generateHash({ path: opts.path, locale: opts.locale })
  1126. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${pageHash}.bin`)
  1127. try {
  1128. const pageBuffer = await fs.readFile(cachePath)
  1129. let page = WIKI.db.pages.cacheSchema.decode(pageBuffer)
  1130. return {
  1131. ...page,
  1132. path: opts.path,
  1133. localeCode: opts.locale
  1134. }
  1135. } catch (err) {
  1136. if (err.code === 'ENOENT') {
  1137. return false
  1138. }
  1139. WIKI.logger.error(err)
  1140. throw err
  1141. }
  1142. }
  1143. /**
  1144. * Delete an Existing Page from Cache
  1145. *
  1146. * @param {String} page Page Unique Hash
  1147. * @returns {Promise} Promise with no value
  1148. */
  1149. static async deletePageFromCache(hash) {
  1150. return fs.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${hash}.bin`))
  1151. }
  1152. /**
  1153. * Flush the contents of the Cache
  1154. */
  1155. static async flushCache() {
  1156. return fs.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache`))
  1157. }
  1158. /**
  1159. * Migrate all pages from a source locale to the target locale
  1160. *
  1161. * @param {Object} opts Migration properties
  1162. * @param {string} opts.sourceLocale Source Locale Code
  1163. * @param {string} opts.targetLocale Target Locale Code
  1164. * @returns {Promise} Promise with no value
  1165. */
  1166. static async migrateToLocale({ sourceLocale, targetLocale }) {
  1167. return WIKI.db.pages.query()
  1168. .patch({
  1169. localeCode: targetLocale
  1170. })
  1171. .where({
  1172. localeCode: sourceLocale
  1173. })
  1174. .whereNotExists(function() {
  1175. this.select('id').from('pages AS pagesm').where('pagesm.localeCode', targetLocale).andWhereRaw('pagesm.path = pages.path')
  1176. })
  1177. }
  1178. /**
  1179. * Clean raw HTML from content for use in search engines
  1180. *
  1181. * @param {string} rawHTML Raw HTML
  1182. * @returns {string} Cleaned Content Text
  1183. */
  1184. static cleanHTML(rawHTML = '') {
  1185. let data = striptags(rawHTML || '', [], ' ')
  1186. .replace(emojiRegex(), '')
  1187. // .replace(htmlEntitiesRegex, '')
  1188. return he.decode(data)
  1189. .replace(punctuationRegex, ' ')
  1190. .replace(/(\r\n|\n|\r)/gm, ' ')
  1191. .replace(/\s\s+/g, ' ')
  1192. .split(' ').filter(w => w.length > 1).join(' ').toLowerCase()
  1193. }
  1194. /**
  1195. * Subscribe to HA propagation events
  1196. */
  1197. static subscribeToEvents() {
  1198. WIKI.events.inbound.on('deletePageFromCache', hash => {
  1199. WIKI.db.pages.deletePageFromCache(hash)
  1200. })
  1201. WIKI.events.inbound.on('flushCache', () => {
  1202. WIKI.db.pages.flushCache()
  1203. })
  1204. }
  1205. }