pages.mjs 40 KB

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