pages.mjs 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351
  1. import { Model } from 'objection'
  2. import { 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 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. let 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. let page
  803. if (has(opts, 'id')) {
  804. page = await WIKI.db.pages.query().findById(opts.id)
  805. } else {
  806. page = await WIKI.db.pages.query().findOne({
  807. path: opts.path,
  808. locale: opts.locale
  809. })
  810. }
  811. if (!page) {
  812. throw new WIKI.Error.PageNotFound()
  813. }
  814. // -> Validate path
  815. if (opts.destinationPath.includes('.') || opts.destinationPath.includes(' ') || opts.destinationPath.includes('\\') || opts.destinationPath.includes('//')) {
  816. throw new WIKI.Error.PageIllegalPath()
  817. }
  818. // -> Remove trailing slash
  819. if (opts.destinationPath.endsWith('/')) {
  820. opts.destinationPath = opts.destinationPath.slice(0, -1)
  821. }
  822. // -> Remove starting slash
  823. if (opts.destinationPath.startsWith('/')) {
  824. opts.destinationPath = opts.destinationPath.slice(1)
  825. }
  826. // -> Check for source page access
  827. if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
  828. locale: page.locale,
  829. path: page.path
  830. })) {
  831. throw new WIKI.Error.PageMoveForbidden()
  832. }
  833. // -> Check for destination page access
  834. if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
  835. locale: opts.destinationLocale,
  836. path: opts.destinationPath
  837. })) {
  838. throw new WIKI.Error.PageMoveForbidden()
  839. }
  840. // -> Check for existing page at destination path
  841. const destPage = await WIKI.db.pages.query().findOne({
  842. path: opts.destinationPath,
  843. locale: opts.destinationLocale
  844. })
  845. if (destPage) {
  846. throw new WIKI.Error.PagePathCollision()
  847. }
  848. // -> Create version snapshot
  849. await WIKI.db.pageHistory.addVersion({
  850. ...page,
  851. action: 'moved',
  852. versionDate: page.updatedAt
  853. })
  854. const destinationHash = generateHash({ path: opts.destinationPath, locale: opts.destinationLocale })
  855. // -> Move page
  856. const destinationTitle = (page.title === page.path ? opts.destinationPath : page.title)
  857. await WIKI.db.pages.query().patch({
  858. path: opts.destinationPath,
  859. locale: opts.destinationLocale,
  860. title: destinationTitle,
  861. hash: destinationHash
  862. }).findById(page.id)
  863. await WIKI.db.pages.deletePageFromCache(page.hash)
  864. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  865. // -> Rebuild page tree
  866. await WIKI.db.pages.rebuildTree()
  867. // -> Rename in Search Index
  868. const pageContents = await WIKI.db.pages.query().findById(page.id).select('render')
  869. page.safeContent = WIKI.db.pages.cleanHTML(pageContents.render)
  870. await WIKI.data.searchEngine.renamed({
  871. ...page,
  872. destinationPath: opts.destinationPath,
  873. destinationLocale: opts.destinationLocale,
  874. destinationHash
  875. })
  876. // -> Rename in Storage
  877. if (!opts.skipStorage) {
  878. await WIKI.db.storage.pageEvent({
  879. event: 'renamed',
  880. page: {
  881. ...page,
  882. destinationPath: opts.destinationPath,
  883. destinationLocale: opts.destinationLocale,
  884. destinationHash,
  885. moveAuthorId: opts.user.id,
  886. moveAuthorName: opts.user.name,
  887. moveAuthorEmail: opts.user.email
  888. }
  889. })
  890. }
  891. // -> Reconnect Links : Changing old links to the new path
  892. await WIKI.db.pages.reconnectLinks({
  893. sourceLocale: page.locale,
  894. sourcePath: page.path,
  895. locale: opts.destinationLocale,
  896. path: opts.destinationPath,
  897. mode: 'move'
  898. })
  899. // -> Reconnect Links : Validate invalid links to the new path
  900. await WIKI.db.pages.reconnectLinks({
  901. locale: opts.destinationLocale,
  902. path: opts.destinationPath,
  903. mode: 'create'
  904. })
  905. }
  906. /**
  907. * Delete an Existing Page
  908. *
  909. * @param {Object} opts Page Properties
  910. * @returns {Promise} Promise with no value
  911. */
  912. static async deletePage(opts) {
  913. const page = await WIKI.db.pages.getPageFromDb(has(opts, 'id') ? opts.id : opts)
  914. if (!page) {
  915. throw new WIKI.Error.PageNotFound()
  916. }
  917. // -> Check for page access
  918. if (!WIKI.auth.checkAccess(opts.user, ['delete:pages'], {
  919. locale: page.locale,
  920. path: page.path
  921. })) {
  922. throw new WIKI.Error.PageDeleteForbidden()
  923. }
  924. // -> Create version snapshot
  925. await WIKI.db.pageHistory.addVersion({
  926. ...page,
  927. action: 'deleted',
  928. versionDate: page.updatedAt
  929. })
  930. // -> Delete page
  931. await WIKI.db.pages.query().delete().where('id', page.id)
  932. await WIKI.db.knex('tree').where('id', page.id).del()
  933. await WIKI.db.pages.deletePageFromCache(page.hash)
  934. WIKI.events.outbound.emit('deletePageFromCache', page.hash)
  935. // -> Delete from Storage
  936. if (!opts.skipStorage) {
  937. // await WIKI.db.storage.pageEvent({
  938. // event: 'deleted',
  939. // page
  940. // })
  941. }
  942. // -> Reconnect Links
  943. await WIKI.db.pages.reconnectLinks({
  944. locale: page.locale,
  945. path: page.path,
  946. mode: 'delete'
  947. })
  948. }
  949. /**
  950. * Reconnect links to new/move/deleted page
  951. *
  952. * @param {Object} opts - Page parameters
  953. * @param {string} opts.path - Page Path
  954. * @param {string} opts.locale - Page Locale Code
  955. * @param {string} [opts.sourcePath] - Previous Page Path (move only)
  956. * @param {string} [opts.sourceLocale] - Previous Page Locale Code (move only)
  957. * @param {string} opts.mode - Page Update mode (create, move, delete)
  958. * @returns {Promise} Promise with no value
  959. */
  960. static async reconnectLinks (opts) {
  961. return
  962. // TODO: fix this
  963. const pageHref = `/${opts.locale}/${opts.path}`
  964. let replaceArgs = {
  965. from: '',
  966. to: ''
  967. }
  968. switch (opts.mode) {
  969. case 'create':
  970. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  971. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  972. break
  973. case 'move':
  974. const prevPageHref = `/${opts.sourceLocale}/${opts.sourcePath}`
  975. replaceArgs.from = `<a href="${prevPageHref}" class="is-internal-link is-valid-page">`
  976. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  977. break
  978. case 'delete':
  979. replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
  980. replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
  981. break
  982. default:
  983. return false
  984. }
  985. let affectedHashes = []
  986. // -> Perform replace and return affected page hashes (POSTGRES only)
  987. if (WIKI.config.db.type === 'postgres') {
  988. const qryHashes = await WIKI.db.pages.query()
  989. .returning('hash')
  990. .patch({
  991. render: WIKI.db.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  992. })
  993. .whereIn('pages.id', function () {
  994. this.select('pageLinks.pageId').from('pageLinks').where({
  995. 'pageLinks.path': opts.path,
  996. 'pageLinks.locale': opts.locale
  997. })
  998. })
  999. affectedHashes = qryHashes.map(h => h.hash)
  1000. } else {
  1001. // -> Perform replace, then query affected page hashes (MYSQL, MARIADB, MSSQL, SQLITE only)
  1002. await WIKI.db.pages.query()
  1003. .patch({
  1004. render: WIKI.db.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
  1005. })
  1006. .whereIn('pages.id', function () {
  1007. this.select('pageLinks.pageId').from('pageLinks').where({
  1008. 'pageLinks.path': opts.path,
  1009. 'pageLinks.locale': opts.locale
  1010. })
  1011. })
  1012. const qryHashes = await WIKI.db.pages.query()
  1013. .column('hash')
  1014. .whereIn('pages.id', function () {
  1015. this.select('pageLinks.pageId').from('pageLinks').where({
  1016. 'pageLinks.path': opts.path,
  1017. 'pageLinks.locale': opts.locale
  1018. })
  1019. })
  1020. affectedHashes = qryHashes.map(h => h.hash)
  1021. }
  1022. for (const hash of affectedHashes) {
  1023. await WIKI.db.pages.deletePageFromCache(hash)
  1024. WIKI.events.outbound.emit('deletePageFromCache', hash)
  1025. }
  1026. }
  1027. /**
  1028. * Trigger the rendering of a page
  1029. *
  1030. * @param {Object} page Page Model Instance
  1031. * @returns {Promise} Promise with no value
  1032. */
  1033. static async renderPage(page) {
  1034. const renderJob = await WIKI.scheduler.addJob({
  1035. task: 'render-page',
  1036. payload: {
  1037. id: page.id
  1038. },
  1039. maxRetries: 0,
  1040. promise: true
  1041. })
  1042. return renderJob.promise
  1043. }
  1044. /**
  1045. * Fetch an Existing Page from Cache if possible, from DB otherwise and save render to Cache
  1046. *
  1047. * @param {Object} opts Page Properties
  1048. * @returns {Promise} Promise of the Page Model Instance
  1049. */
  1050. static async getPage(opts) {
  1051. return WIKI.db.pages.getPageFromDb(opts)
  1052. // -> Get from cache first
  1053. let page = await WIKI.db.pages.getPageFromCache(opts)
  1054. if (!page) {
  1055. // -> Get from DB
  1056. page = await WIKI.db.pages.getPageFromDb(opts)
  1057. if (page) {
  1058. if (page.render) {
  1059. // -> Save render to cache
  1060. await WIKI.db.pages.savePageToCache(page)
  1061. } else {
  1062. // -> No render? Possible duplicate issue
  1063. /* TODO: Detect duplicate and delete */
  1064. throw new Error('Error while fetching page. No rendered version of this page exists. Try to edit the page and save it again.')
  1065. }
  1066. }
  1067. }
  1068. return page
  1069. }
  1070. /**
  1071. * Fetch an Existing Page from the Database
  1072. *
  1073. * @param {Object} opts Page Properties
  1074. * @returns {Promise} Promise of the Page Model Instance
  1075. */
  1076. static async getPageFromDb(opts) {
  1077. const queryModeID = typeof opts === 'string'
  1078. try {
  1079. return WIKI.db.pages.query()
  1080. .column([
  1081. 'pages.*',
  1082. {
  1083. authorName: 'author.name',
  1084. authorEmail: 'author.email',
  1085. creatorName: 'creator.name',
  1086. creatorEmail: 'creator.email'
  1087. },
  1088. 'tree.navigationId',
  1089. 'tree.navigationMode'
  1090. ])
  1091. .joinRelated('author')
  1092. .joinRelated('creator')
  1093. .leftJoin('tree', 'pages.id', 'tree.id')
  1094. .where(queryModeID ? {
  1095. 'pages.id': opts
  1096. } : {
  1097. 'pages.siteId': opts.siteId,
  1098. 'pages.path': opts.path,
  1099. 'pages.locale': opts.locale
  1100. })
  1101. // .andWhere(builder => {
  1102. // if (queryModeID) return
  1103. // builder.where({
  1104. // 'pages.isPublished': true
  1105. // }).orWhere({
  1106. // 'pages.isPublished': false,
  1107. // 'pages.authorId': opts.userId
  1108. // })
  1109. // })
  1110. .first()
  1111. } catch (err) {
  1112. WIKI.logger.warn(err)
  1113. throw err
  1114. }
  1115. }
  1116. /**
  1117. * Save a Page Model Instance to Cache
  1118. *
  1119. * @param {Object} page Page Model Instance
  1120. * @returns {Promise} Promise with no value
  1121. */
  1122. static async savePageToCache(page) {
  1123. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${page.hash}.bin`)
  1124. await fse.outputFile(cachePath, WIKI.db.pages.cacheSchema.encode({
  1125. id: page.id,
  1126. authorId: page.authorId,
  1127. authorName: page.authorName,
  1128. createdAt: page.createdAt.toISOString(),
  1129. creatorId: page.creatorId,
  1130. creatorName: page.creatorName,
  1131. description: page.description,
  1132. editor: page.editor,
  1133. extra: {
  1134. css: get(page, 'extra.css', ''),
  1135. js: get(page, 'extra.js', '')
  1136. },
  1137. publishState: page.publishState ?? '',
  1138. publishEndDate: page.publishEndDate ?? '',
  1139. publishStartDate: page.publishStartDate ?? '',
  1140. render: page.render,
  1141. siteId: page.siteId,
  1142. tags: page.tags.map(t => pick(t, ['tag'])),
  1143. title: page.title,
  1144. toc: isString(page.toc) ? page.toc : JSON.stringify(page.toc),
  1145. updatedAt: page.updatedAt.toISOString()
  1146. }))
  1147. }
  1148. /**
  1149. * Fetch an Existing Page from Cache
  1150. *
  1151. * @param {Object} opts Page Properties
  1152. * @returns {Promise} Promise of the Page Model Instance
  1153. */
  1154. static async getPageFromCache(opts) {
  1155. const pageHash = generateHash({ path: opts.path, locale: opts.locale })
  1156. const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${pageHash}.bin`)
  1157. try {
  1158. const pageBuffer = await fse.readFile(cachePath)
  1159. let page = WIKI.db.pages.cacheSchema.decode(pageBuffer)
  1160. return {
  1161. ...page,
  1162. path: opts.path,
  1163. locale: opts.locale
  1164. }
  1165. } catch (err) {
  1166. if (err.code === 'ENOENT') {
  1167. return false
  1168. }
  1169. WIKI.logger.error(err)
  1170. throw err
  1171. }
  1172. }
  1173. /**
  1174. * Delete an Existing Page from Cache
  1175. *
  1176. * @param {String} page Page Unique Hash
  1177. * @returns {Promise} Promise with no value
  1178. */
  1179. static async deletePageFromCache(hash) {
  1180. return fse.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${hash}.bin`))
  1181. }
  1182. /**
  1183. * Flush the contents of the Cache
  1184. */
  1185. static async flushCache() {
  1186. return fse.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache`))
  1187. }
  1188. /**
  1189. * Migrate all pages from a source locale to the target locale
  1190. *
  1191. * @param {Object} opts Migration properties
  1192. * @param {string} opts.sourceLocale Source Locale Code
  1193. * @param {string} opts.targetLocale Target Locale Code
  1194. * @returns {Promise} Promise with no value
  1195. */
  1196. static async migrateToLocale({ sourceLocale, targetLocale }) {
  1197. return WIKI.db.pages.query()
  1198. .patch({
  1199. locale: targetLocale
  1200. })
  1201. .where({
  1202. locale: sourceLocale
  1203. })
  1204. .whereNotExists(function() {
  1205. this.select('id').from('pages AS pagesm').where('pagesm.locale', targetLocale).andWhereRaw('pagesm.path = pages.path')
  1206. })
  1207. }
  1208. /**
  1209. * Clean raw HTML from content for use in search engines
  1210. *
  1211. * @param {string} rawHTML Raw HTML
  1212. * @returns {string} Cleaned Content Text
  1213. */
  1214. static cleanHTML(rawHTML = '') {
  1215. const data = striptags(rawHTML || '', [], ' ')
  1216. .replace(emojiRegex(), '')
  1217. return he.decode(data)
  1218. .replace(/(\r\n|\n|\r)/gm, ' ')
  1219. .replace(/\s\s+/g, ' ')
  1220. }
  1221. /**
  1222. * Subscribe to HA propagation events
  1223. */
  1224. static subscribeToEvents() {
  1225. WIKI.events.inbound.on('deletePageFromCache', hash => {
  1226. WIKI.db.pages.deletePageFromCache(hash)
  1227. })
  1228. WIKI.events.inbound.on('flushCache', () => {
  1229. WIKI.db.pages.flushCache()
  1230. })
  1231. }
  1232. }