d7d00b44f6
For building suggest in elasticsearch, the safeContent field will be splitted into arrays. If the page is renamed or moved, the server will throw error: "Cannot read property 'split' of Undefined", and the index will be broken. Here two lines are added to fix this issue.
986 lines
28 KiB
JavaScript
986 lines
28 KiB
JavaScript
const Model = require('objection').Model
|
|
const _ = require('lodash')
|
|
const JSBinType = require('js-binary').Type
|
|
const pageHelper = require('../helpers/page')
|
|
const path = require('path')
|
|
const fs = require('fs-extra')
|
|
const yaml = require('js-yaml')
|
|
const striptags = require('striptags')
|
|
const emojiRegex = require('emoji-regex')
|
|
const he = require('he')
|
|
const CleanCSS = require('clean-css')
|
|
|
|
/* global WIKI */
|
|
|
|
const frontmatterRegex = {
|
|
html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
|
|
legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
|
|
markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
|
|
}
|
|
|
|
const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
|
|
// const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig
|
|
|
|
/**
|
|
* Pages model
|
|
*/
|
|
module.exports = class Page extends Model {
|
|
static get tableName() { return 'pages' }
|
|
|
|
static get jsonSchema () {
|
|
return {
|
|
type: 'object',
|
|
required: ['path', 'title'],
|
|
|
|
properties: {
|
|
id: {type: 'integer'},
|
|
path: {type: 'string'},
|
|
hash: {type: 'string'},
|
|
title: {type: 'string'},
|
|
description: {type: 'string'},
|
|
isPublished: {type: 'boolean'},
|
|
privateNS: {type: 'string'},
|
|
publishStartDate: {type: 'string'},
|
|
publishEndDate: {type: 'string'},
|
|
content: {type: 'string'},
|
|
contentType: {type: 'string'},
|
|
|
|
createdAt: {type: 'string'},
|
|
updatedAt: {type: 'string'}
|
|
}
|
|
}
|
|
}
|
|
|
|
static get jsonAttributes() {
|
|
return ['extra']
|
|
}
|
|
|
|
static get relationMappings() {
|
|
return {
|
|
tags: {
|
|
relation: Model.ManyToManyRelation,
|
|
modelClass: require('./tags'),
|
|
join: {
|
|
from: 'pages.id',
|
|
through: {
|
|
from: 'pageTags.pageId',
|
|
to: 'pageTags.tagId'
|
|
},
|
|
to: 'tags.id'
|
|
}
|
|
},
|
|
links: {
|
|
relation: Model.HasManyRelation,
|
|
modelClass: require('./pageLinks'),
|
|
join: {
|
|
from: 'pages.id',
|
|
to: 'pageLinks.pageId'
|
|
}
|
|
},
|
|
author: {
|
|
relation: Model.BelongsToOneRelation,
|
|
modelClass: require('./users'),
|
|
join: {
|
|
from: 'pages.authorId',
|
|
to: 'users.id'
|
|
}
|
|
},
|
|
creator: {
|
|
relation: Model.BelongsToOneRelation,
|
|
modelClass: require('./users'),
|
|
join: {
|
|
from: 'pages.creatorId',
|
|
to: 'users.id'
|
|
}
|
|
},
|
|
editor: {
|
|
relation: Model.BelongsToOneRelation,
|
|
modelClass: require('./editors'),
|
|
join: {
|
|
from: 'pages.editorKey',
|
|
to: 'editors.key'
|
|
}
|
|
},
|
|
locale: {
|
|
relation: Model.BelongsToOneRelation,
|
|
modelClass: require('./locales'),
|
|
join: {
|
|
from: 'pages.localeCode',
|
|
to: 'locales.code'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$beforeUpdate() {
|
|
this.updatedAt = new Date().toISOString()
|
|
}
|
|
$beforeInsert() {
|
|
this.createdAt = new Date().toISOString()
|
|
this.updatedAt = new Date().toISOString()
|
|
}
|
|
/**
|
|
* Solving the violates foreign key constraint using cascade strategy
|
|
* using static hooks
|
|
* @see https://vincit.github.io/objection.js/api/types/#type-statichookarguments
|
|
*/
|
|
static async beforeDelete({ asFindQuery }) {
|
|
const page = await asFindQuery().select('id')
|
|
await WIKI.models.comments.query().delete().where('pageId', page[0].id)
|
|
}
|
|
/**
|
|
* Cache Schema
|
|
*/
|
|
static get cacheSchema() {
|
|
return new JSBinType({
|
|
id: 'uint',
|
|
authorId: 'uint',
|
|
authorName: 'string',
|
|
createdAt: 'string',
|
|
creatorId: 'uint',
|
|
creatorName: 'string',
|
|
description: 'string',
|
|
isPrivate: 'boolean',
|
|
isPublished: 'boolean',
|
|
publishEndDate: 'string',
|
|
publishStartDate: 'string',
|
|
render: 'string',
|
|
tags: [
|
|
{
|
|
tag: 'string',
|
|
title: 'string'
|
|
}
|
|
],
|
|
extra: {
|
|
js: 'string',
|
|
css: 'string'
|
|
},
|
|
title: 'string',
|
|
toc: 'string',
|
|
updatedAt: 'string'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Inject page metadata into contents
|
|
*
|
|
* @returns {string} Page Contents with Injected Metadata
|
|
*/
|
|
injectMetadata () {
|
|
return pageHelper.injectPageMetadata(this)
|
|
}
|
|
|
|
/**
|
|
* Get the page's file extension based on content type
|
|
*
|
|
* @returns {string} File Extension
|
|
*/
|
|
getFileExtension() {
|
|
return pageHelper.getFileExtension(this.contentType)
|
|
}
|
|
|
|
/**
|
|
* Parse injected page metadata from raw content
|
|
*
|
|
* @param {String} raw Raw file contents
|
|
* @param {String} contentType Content Type
|
|
* @returns {Object} Parsed Page Metadata with Raw Content
|
|
*/
|
|
static parseMetadata (raw, contentType) {
|
|
let result
|
|
switch (contentType) {
|
|
case 'markdown':
|
|
result = frontmatterRegex.markdown.exec(raw)
|
|
if (result[2]) {
|
|
return {
|
|
...yaml.safeLoad(result[2]),
|
|
content: result[3]
|
|
}
|
|
} else {
|
|
// Attempt legacy v1 format
|
|
result = frontmatterRegex.legacy.exec(raw)
|
|
if (result[2]) {
|
|
return {
|
|
title: result[2],
|
|
description: result[4],
|
|
content: result[5]
|
|
}
|
|
}
|
|
}
|
|
break
|
|
case 'html':
|
|
result = frontmatterRegex.html.exec(raw)
|
|
if (result[2]) {
|
|
return {
|
|
...yaml.safeLoad(result[2]),
|
|
content: result[3]
|
|
}
|
|
}
|
|
break
|
|
}
|
|
return {
|
|
content: raw
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a New Page
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise of the Page Model Instance
|
|
*/
|
|
static async createPage(opts) {
|
|
// -> Validate path
|
|
if (opts.path.includes('.') || opts.path.includes(' ') || opts.path.includes('\\') || opts.path.includes('//')) {
|
|
throw new WIKI.Error.PageIllegalPath()
|
|
}
|
|
|
|
// -> Remove trailing slash
|
|
if (opts.path.endsWith('/')) {
|
|
opts.path = opts.path.slice(0, -1)
|
|
}
|
|
|
|
// -> Remove starting slash
|
|
if (opts.path.startsWith('/')) {
|
|
opts.path = opts.path.slice(1)
|
|
}
|
|
|
|
// -> Check for page access
|
|
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
|
|
locale: opts.locale,
|
|
path: opts.path
|
|
})) {
|
|
throw new WIKI.Error.PageDeleteForbidden()
|
|
}
|
|
|
|
// -> Check for duplicate
|
|
const dupCheck = await WIKI.models.pages.query().select('id').where('localeCode', opts.locale).where('path', opts.path).first()
|
|
if (dupCheck) {
|
|
throw new WIKI.Error.PageDuplicateCreate()
|
|
}
|
|
|
|
// -> Check for empty content
|
|
if (!opts.content || _.trim(opts.content).length < 1) {
|
|
throw new WIKI.Error.PageEmptyContent()
|
|
}
|
|
|
|
// -> Format CSS Scripts
|
|
let scriptCss = ''
|
|
if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
|
|
locale: opts.locale,
|
|
path: opts.path
|
|
})) {
|
|
if (!_.isEmpty(opts.scriptCss)) {
|
|
scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
|
|
} else {
|
|
scriptCss = ''
|
|
}
|
|
}
|
|
|
|
// -> Format JS Scripts
|
|
let scriptJs = ''
|
|
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
|
|
locale: opts.locale,
|
|
path: opts.path
|
|
})) {
|
|
scriptJs = opts.scriptJs || ''
|
|
}
|
|
|
|
// -> Create page
|
|
await WIKI.models.pages.query().insert({
|
|
authorId: opts.user.id,
|
|
content: opts.content,
|
|
creatorId: opts.user.id,
|
|
contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
|
|
description: opts.description,
|
|
editorKey: opts.editor,
|
|
hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' }),
|
|
isPrivate: opts.isPrivate,
|
|
isPublished: opts.isPublished,
|
|
localeCode: opts.locale,
|
|
path: opts.path,
|
|
publishEndDate: opts.publishEndDate || '',
|
|
publishStartDate: opts.publishStartDate || '',
|
|
title: opts.title,
|
|
toc: '[]',
|
|
extra: JSON.stringify({
|
|
js: scriptJs,
|
|
css: scriptCss
|
|
})
|
|
})
|
|
const page = await WIKI.models.pages.getPageFromDb({
|
|
path: opts.path,
|
|
locale: opts.locale,
|
|
userId: opts.user.id,
|
|
isPrivate: opts.isPrivate
|
|
})
|
|
|
|
// -> Save Tags
|
|
if (opts.tags && opts.tags.length > 0) {
|
|
await WIKI.models.tags.associateTags({ tags: opts.tags, page })
|
|
}
|
|
|
|
// -> Render page to HTML
|
|
await WIKI.models.pages.renderPage(page)
|
|
|
|
// -> Rebuild page tree
|
|
await WIKI.models.pages.rebuildTree()
|
|
|
|
// -> Add to Search Index
|
|
const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
|
|
page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
|
|
await WIKI.data.searchEngine.created(page)
|
|
|
|
// -> Add to Storage
|
|
if (!opts.skipStorage) {
|
|
await WIKI.models.storage.pageEvent({
|
|
event: 'created',
|
|
page
|
|
})
|
|
}
|
|
|
|
// -> Reconnect Links
|
|
await WIKI.models.pages.reconnectLinks({
|
|
locale: page.localeCode,
|
|
path: page.path,
|
|
mode: 'create'
|
|
})
|
|
|
|
// -> Get latest updatedAt
|
|
page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
|
|
|
|
return page
|
|
}
|
|
|
|
/**
|
|
* Update an Existing Page
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise of the Page Model Instance
|
|
*/
|
|
static async updatePage(opts) {
|
|
// -> Fetch original page
|
|
const ogPage = await WIKI.models.pages.query().findById(opts.id)
|
|
if (!ogPage) {
|
|
throw new Error('Invalid Page Id')
|
|
}
|
|
|
|
// -> Check for page access
|
|
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
|
|
locale: opts.locale,
|
|
path: opts.path
|
|
})) {
|
|
throw new WIKI.Error.PageUpdateForbidden()
|
|
}
|
|
|
|
// -> Check for empty content
|
|
if (!opts.content || _.trim(opts.content).length < 1) {
|
|
throw new WIKI.Error.PageEmptyContent()
|
|
}
|
|
|
|
// -> Create version snapshot
|
|
await WIKI.models.pageHistory.addVersion({
|
|
...ogPage,
|
|
isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
|
|
action: opts.action ? opts.action : 'updated',
|
|
versionDate: ogPage.updatedAt
|
|
})
|
|
|
|
// -> Format Extra Properties
|
|
if (!_.isPlainObject(ogPage.extra)) {
|
|
ogPage.extra = {}
|
|
}
|
|
|
|
// -> Format CSS Scripts
|
|
let scriptCss = _.get(ogPage, 'extra.css', '')
|
|
if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
|
|
locale: opts.locale,
|
|
path: opts.path
|
|
})) {
|
|
if (!_.isEmpty(opts.scriptCss)) {
|
|
scriptCss = new CleanCSS({ inline: false }).minify(opts.scriptCss).styles
|
|
} else {
|
|
scriptCss = ''
|
|
}
|
|
}
|
|
|
|
// -> Format JS Scripts
|
|
let scriptJs = _.get(ogPage, 'extra.js', '')
|
|
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
|
|
locale: opts.locale,
|
|
path: opts.path
|
|
})) {
|
|
scriptJs = opts.scriptJs || ''
|
|
}
|
|
|
|
// -> Update page
|
|
await WIKI.models.pages.query().patch({
|
|
authorId: opts.user.id,
|
|
content: opts.content,
|
|
description: opts.description,
|
|
isPublished: opts.isPublished === true || opts.isPublished === 1,
|
|
publishEndDate: opts.publishEndDate || '',
|
|
publishStartDate: opts.publishStartDate || '',
|
|
title: opts.title,
|
|
extra: JSON.stringify({
|
|
...ogPage.extra,
|
|
js: scriptJs,
|
|
css: scriptCss
|
|
})
|
|
}).where('id', ogPage.id)
|
|
let page = await WIKI.models.pages.getPageFromDb(ogPage.id)
|
|
|
|
// -> Save Tags
|
|
await WIKI.models.tags.associateTags({ tags: opts.tags, page })
|
|
|
|
// -> Render page to HTML
|
|
await WIKI.models.pages.renderPage(page)
|
|
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
|
|
|
|
// -> Update Search Index
|
|
const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
|
|
page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
|
|
await WIKI.data.searchEngine.updated(page)
|
|
|
|
// -> Update on Storage
|
|
if (!opts.skipStorage) {
|
|
await WIKI.models.storage.pageEvent({
|
|
event: 'updated',
|
|
page
|
|
})
|
|
}
|
|
|
|
// -> Perform move?
|
|
if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
|
|
await WIKI.models.pages.movePage({
|
|
id: page.id,
|
|
destinationLocale: opts.locale,
|
|
destinationPath: opts.path,
|
|
user: opts.user
|
|
})
|
|
} else {
|
|
// -> Update title of page tree entry
|
|
await WIKI.models.knex.table('pageTree').where({
|
|
pageId: page.id
|
|
}).update('title', page.title)
|
|
}
|
|
|
|
// -> Get latest updatedAt
|
|
page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
|
|
|
|
return page
|
|
}
|
|
|
|
/**
|
|
* Move a Page
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async movePage(opts) {
|
|
const page = await WIKI.models.pages.query().findById(opts.id)
|
|
if (!page) {
|
|
throw new WIKI.Error.PageNotFound()
|
|
}
|
|
|
|
// -> Validate path
|
|
if (opts.destinationPath.includes('.') || opts.destinationPath.includes(' ') || opts.destinationPath.includes('\\') || opts.destinationPath.includes('//')) {
|
|
throw new WIKI.Error.PageIllegalPath()
|
|
}
|
|
|
|
// -> Remove trailing slash
|
|
if (opts.destinationPath.endsWith('/')) {
|
|
opts.destinationPath = opts.destinationPath.slice(0, -1)
|
|
}
|
|
|
|
// -> Remove starting slash
|
|
if (opts.destinationPath.startsWith('/')) {
|
|
opts.destinationPath = opts.destinationPath.slice(1)
|
|
}
|
|
|
|
// -> Check for source page access
|
|
if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
|
|
locale: page.localeCode,
|
|
path: page.path
|
|
})) {
|
|
throw new WIKI.Error.PageMoveForbidden()
|
|
}
|
|
// -> Check for destination page access
|
|
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
|
|
locale: opts.destinationLocale,
|
|
path: opts.destinationPath
|
|
})) {
|
|
throw new WIKI.Error.PageMoveForbidden()
|
|
}
|
|
|
|
// -> Check for existing page at destination path
|
|
const destPage = await WIKI.models.pages.query().findOne({
|
|
path: opts.destinationPath,
|
|
localeCode: opts.destinationLocale
|
|
})
|
|
if (destPage) {
|
|
throw new WIKI.Error.PagePathCollision()
|
|
}
|
|
|
|
// -> Create version snapshot
|
|
await WIKI.models.pageHistory.addVersion({
|
|
...page,
|
|
action: 'moved',
|
|
versionDate: page.updatedAt
|
|
})
|
|
|
|
const destinationHash = pageHelper.generateHash({ path: opts.destinationPath, locale: opts.destinationLocale, privateNS: opts.isPrivate ? 'TODO' : '' })
|
|
|
|
// -> Move page
|
|
await WIKI.models.pages.query().patch({
|
|
path: opts.destinationPath,
|
|
localeCode: opts.destinationLocale,
|
|
hash: destinationHash
|
|
}).findById(page.id)
|
|
await WIKI.models.pages.deletePageFromCache(page.hash)
|
|
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
|
|
|
|
// -> Rebuild page tree
|
|
await WIKI.models.pages.rebuildTree()
|
|
|
|
// -> Rename in Search Index
|
|
const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
|
|
page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
|
|
await WIKI.data.searchEngine.renamed({
|
|
...page,
|
|
destinationPath: opts.destinationPath,
|
|
destinationLocaleCode: opts.destinationLocale,
|
|
destinationHash
|
|
})
|
|
|
|
// -> Rename in Storage
|
|
if (!opts.skipStorage) {
|
|
await WIKI.models.storage.pageEvent({
|
|
event: 'renamed',
|
|
page: {
|
|
...page,
|
|
destinationPath: opts.destinationPath,
|
|
destinationLocaleCode: opts.destinationLocale,
|
|
destinationHash,
|
|
moveAuthorId: opts.user.id,
|
|
moveAuthorName: opts.user.name,
|
|
moveAuthorEmail: opts.user.email
|
|
}
|
|
})
|
|
}
|
|
|
|
// -> Reconnect Links : Changing old links to the new path
|
|
await WIKI.models.pages.reconnectLinks({
|
|
sourceLocale: page.localeCode,
|
|
sourcePath: page.path,
|
|
locale: opts.destinationLocale,
|
|
path: opts.destinationPath,
|
|
mode: 'move'
|
|
})
|
|
|
|
// -> Reconnect Links : Validate invalid links to the new path
|
|
await WIKI.models.pages.reconnectLinks({
|
|
locale: opts.destinationLocale,
|
|
path: opts.destinationPath,
|
|
mode: 'create'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete an Existing Page
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async deletePage(opts) {
|
|
let page
|
|
if (_.has(opts, 'id')) {
|
|
page = await WIKI.models.pages.query().findById(opts.id)
|
|
} else {
|
|
page = await WIKI.models.pages.query().findOne({
|
|
path: opts.path,
|
|
localeCode: opts.locale
|
|
})
|
|
}
|
|
if (!page) {
|
|
throw new Error('Invalid Page Id')
|
|
}
|
|
|
|
// -> Check for page access
|
|
if (!WIKI.auth.checkAccess(opts.user, ['delete:pages'], {
|
|
locale: page.locale,
|
|
path: page.path
|
|
})) {
|
|
throw new WIKI.Error.PageDeleteForbidden()
|
|
}
|
|
|
|
// -> Create version snapshot
|
|
await WIKI.models.pageHistory.addVersion({
|
|
...page,
|
|
action: 'deleted',
|
|
versionDate: page.updatedAt
|
|
})
|
|
|
|
// -> Delete page
|
|
await WIKI.models.pages.query().delete().where('id', page.id)
|
|
await WIKI.models.pages.deletePageFromCache(page.hash)
|
|
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
|
|
|
|
// -> Rebuild page tree
|
|
await WIKI.models.pages.rebuildTree()
|
|
|
|
// -> Delete from Search Index
|
|
await WIKI.data.searchEngine.deleted(page)
|
|
|
|
// -> Delete from Storage
|
|
if (!opts.skipStorage) {
|
|
await WIKI.models.storage.pageEvent({
|
|
event: 'deleted',
|
|
page
|
|
})
|
|
}
|
|
|
|
// -> Reconnect Links
|
|
await WIKI.models.pages.reconnectLinks({
|
|
locale: page.localeCode,
|
|
path: page.path,
|
|
mode: 'delete'
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reconnect links to new/move/deleted page
|
|
*
|
|
* @param {Object} opts - Page parameters
|
|
* @param {string} opts.path - Page Path
|
|
* @param {string} opts.locale - Page Locale Code
|
|
* @param {string} [opts.sourcePath] - Previous Page Path (move only)
|
|
* @param {string} [opts.sourceLocale] - Previous Page Locale Code (move only)
|
|
* @param {string} opts.mode - Page Update mode (create, move, delete)
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async reconnectLinks (opts) {
|
|
const pageHref = `/${opts.locale}/${opts.path}`
|
|
let replaceArgs = {
|
|
from: '',
|
|
to: ''
|
|
}
|
|
switch (opts.mode) {
|
|
case 'create':
|
|
replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
|
|
replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
|
|
break
|
|
case 'move':
|
|
const prevPageHref = `/${opts.sourceLocale}/${opts.sourcePath}`
|
|
replaceArgs.from = `<a href="${prevPageHref}" class="is-internal-link is-valid-page">`
|
|
replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
|
|
break
|
|
case 'delete':
|
|
replaceArgs.from = `<a href="${pageHref}" class="is-internal-link is-valid-page">`
|
|
replaceArgs.to = `<a href="${pageHref}" class="is-internal-link is-invalid-page">`
|
|
break
|
|
default:
|
|
return false
|
|
}
|
|
|
|
let affectedHashes = []
|
|
// -> Perform replace and return affected page hashes (POSTGRES only)
|
|
if (WIKI.config.db.type === 'postgres') {
|
|
const qryHashes = await WIKI.models.pages.query()
|
|
.returning('hash')
|
|
.patch({
|
|
render: WIKI.models.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
|
|
})
|
|
.whereIn('pages.id', function () {
|
|
this.select('pageLinks.pageId').from('pageLinks').where({
|
|
'pageLinks.path': opts.path,
|
|
'pageLinks.localeCode': opts.locale
|
|
})
|
|
})
|
|
affectedHashes = qryHashes.map(h => h.hash)
|
|
} else {
|
|
// -> Perform replace, then query affected page hashes (MYSQL, MARIADB, MSSQL, SQLITE only)
|
|
await WIKI.models.pages.query()
|
|
.patch({
|
|
render: WIKI.models.knex.raw('REPLACE(??, ?, ?)', ['render', replaceArgs.from, replaceArgs.to])
|
|
})
|
|
.whereIn('pages.id', function () {
|
|
this.select('pageLinks.pageId').from('pageLinks').where({
|
|
'pageLinks.path': opts.path,
|
|
'pageLinks.localeCode': opts.locale
|
|
})
|
|
})
|
|
const qryHashes = await WIKI.models.pages.query()
|
|
.column('hash')
|
|
.whereIn('pages.id', function () {
|
|
this.select('pageLinks.pageId').from('pageLinks').where({
|
|
'pageLinks.path': opts.path,
|
|
'pageLinks.localeCode': opts.locale
|
|
})
|
|
})
|
|
affectedHashes = qryHashes.map(h => h.hash)
|
|
}
|
|
for (const hash of affectedHashes) {
|
|
await WIKI.models.pages.deletePageFromCache(hash)
|
|
WIKI.events.outbound.emit('deletePageFromCache', hash)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebuild page tree for new/updated/deleted page
|
|
*
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async rebuildTree() {
|
|
const rebuildJob = await WIKI.scheduler.registerJob({
|
|
name: 'rebuild-tree',
|
|
immediate: true,
|
|
worker: true
|
|
})
|
|
return rebuildJob.finished
|
|
}
|
|
|
|
/**
|
|
* Trigger the rendering of a page
|
|
*
|
|
* @param {Object} page Page Model Instance
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async renderPage(page) {
|
|
const renderJob = await WIKI.scheduler.registerJob({
|
|
name: 'render-page',
|
|
immediate: true,
|
|
worker: true
|
|
}, page.id)
|
|
return renderJob.finished
|
|
}
|
|
|
|
/**
|
|
* Fetch an Existing Page from Cache if possible, from DB otherwise and save render to Cache
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise of the Page Model Instance
|
|
*/
|
|
static async getPage(opts) {
|
|
// -> Get from cache first
|
|
let page = await WIKI.models.pages.getPageFromCache(opts)
|
|
if (!page) {
|
|
// -> Get from DB
|
|
page = await WIKI.models.pages.getPageFromDb(opts)
|
|
if (page) {
|
|
if (page.render) {
|
|
// -> Save render to cache
|
|
await WIKI.models.pages.savePageToCache(page)
|
|
} else {
|
|
// -> No render? Possible duplicate issue
|
|
/* TODO: Detect duplicate and delete */
|
|
throw new Error('Error while fetching page. Duplicate entry detected. Reload the page to try again.')
|
|
}
|
|
}
|
|
}
|
|
return page
|
|
}
|
|
|
|
/**
|
|
* Fetch an Existing Page from the Database
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise of the Page Model Instance
|
|
*/
|
|
static async getPageFromDb(opts) {
|
|
const queryModeID = _.isNumber(opts)
|
|
try {
|
|
return WIKI.models.pages.query()
|
|
.column([
|
|
'pages.id',
|
|
'pages.path',
|
|
'pages.hash',
|
|
'pages.title',
|
|
'pages.description',
|
|
'pages.isPrivate',
|
|
'pages.isPublished',
|
|
'pages.privateNS',
|
|
'pages.publishStartDate',
|
|
'pages.publishEndDate',
|
|
'pages.content',
|
|
'pages.render',
|
|
'pages.toc',
|
|
'pages.contentType',
|
|
'pages.createdAt',
|
|
'pages.updatedAt',
|
|
'pages.editorKey',
|
|
'pages.localeCode',
|
|
'pages.authorId',
|
|
'pages.creatorId',
|
|
'pages.extra',
|
|
{
|
|
authorName: 'author.name',
|
|
authorEmail: 'author.email',
|
|
creatorName: 'creator.name',
|
|
creatorEmail: 'creator.email'
|
|
}
|
|
])
|
|
.joinRelated('author')
|
|
.joinRelated('creator')
|
|
.withGraphJoined('tags')
|
|
.modifyGraph('tags', builder => {
|
|
builder.select('tag', 'title')
|
|
})
|
|
.where(queryModeID ? {
|
|
'pages.id': opts
|
|
} : {
|
|
'pages.path': opts.path,
|
|
'pages.localeCode': opts.locale
|
|
})
|
|
// .andWhere(builder => {
|
|
// if (queryModeID) return
|
|
// builder.where({
|
|
// 'pages.isPublished': true
|
|
// }).orWhere({
|
|
// 'pages.isPublished': false,
|
|
// 'pages.authorId': opts.userId
|
|
// })
|
|
// })
|
|
// .andWhere(builder => {
|
|
// if (queryModeID) return
|
|
// if (opts.isPrivate) {
|
|
// builder.where({ 'pages.isPrivate': true, 'pages.privateNS': opts.privateNS })
|
|
// } else {
|
|
// builder.where({ 'pages.isPrivate': false })
|
|
// }
|
|
// })
|
|
.first()
|
|
} catch (err) {
|
|
WIKI.logger.warn(err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save a Page Model Instance to Cache
|
|
*
|
|
* @param {Object} page Page Model Instance
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async savePageToCache(page) {
|
|
const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${page.hash}.bin`)
|
|
await fs.outputFile(cachePath, WIKI.models.pages.cacheSchema.encode({
|
|
id: page.id,
|
|
authorId: page.authorId,
|
|
authorName: page.authorName,
|
|
createdAt: page.createdAt,
|
|
creatorId: page.creatorId,
|
|
creatorName: page.creatorName,
|
|
description: page.description,
|
|
extra: {
|
|
css: _.get(page, 'extra.css', ''),
|
|
js: _.get(page, 'extra.js', '')
|
|
},
|
|
isPrivate: page.isPrivate === 1 || page.isPrivate === true,
|
|
isPublished: page.isPublished === 1 || page.isPublished === true,
|
|
publishEndDate: page.publishEndDate,
|
|
publishStartDate: page.publishStartDate,
|
|
render: page.render,
|
|
tags: page.tags.map(t => _.pick(t, ['tag', 'title'])),
|
|
title: page.title,
|
|
toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
|
|
updatedAt: page.updatedAt
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Fetch an Existing Page from Cache
|
|
*
|
|
* @param {Object} opts Page Properties
|
|
* @returns {Promise} Promise of the Page Model Instance
|
|
*/
|
|
static async getPageFromCache(opts) {
|
|
const pageHash = pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' })
|
|
const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${pageHash}.bin`)
|
|
|
|
try {
|
|
const pageBuffer = await fs.readFile(cachePath)
|
|
let page = WIKI.models.pages.cacheSchema.decode(pageBuffer)
|
|
return {
|
|
...page,
|
|
path: opts.path,
|
|
localeCode: opts.locale,
|
|
isPrivate: opts.isPrivate
|
|
}
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
return false
|
|
}
|
|
WIKI.logger.error(err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete an Existing Page from Cache
|
|
*
|
|
* @param {String} page Page Unique Hash
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async deletePageFromCache(hash) {
|
|
return fs.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${hash}.bin`))
|
|
}
|
|
|
|
/**
|
|
* Flush the contents of the Cache
|
|
*/
|
|
static async flushCache() {
|
|
return fs.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache`))
|
|
}
|
|
|
|
/**
|
|
* Migrate all pages from a source locale to the target locale
|
|
*
|
|
* @param {Object} opts Migration properties
|
|
* @param {string} opts.sourceLocale Source Locale Code
|
|
* @param {string} opts.targetLocale Target Locale Code
|
|
* @returns {Promise} Promise with no value
|
|
*/
|
|
static async migrateToLocale({ sourceLocale, targetLocale }) {
|
|
return WIKI.models.pages.query()
|
|
.patch({
|
|
localeCode: targetLocale
|
|
})
|
|
.where({
|
|
localeCode: sourceLocale
|
|
})
|
|
.whereNotExists(function() {
|
|
this.select('id').from('pages AS pagesm').where('pagesm.localeCode', targetLocale).andWhereRaw('pagesm.path = pages.path')
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Clean raw HTML from content for use in search engines
|
|
*
|
|
* @param {string} rawHTML Raw HTML
|
|
* @returns {string} Cleaned Content Text
|
|
*/
|
|
static cleanHTML(rawHTML = '') {
|
|
let data = striptags(rawHTML || '', [], ' ')
|
|
.replace(emojiRegex(), '')
|
|
// .replace(htmlEntitiesRegex, '')
|
|
return he.decode(data)
|
|
.replace(punctuationRegex, ' ')
|
|
.replace(/(\r\n|\n|\r)/gm, ' ')
|
|
.replace(/\s\s+/g, ' ')
|
|
.split(' ').filter(w => w.length > 1).join(' ').toLowerCase()
|
|
}
|
|
|
|
/**
|
|
* Subscribe to HA propagation events
|
|
*/
|
|
static subscribeToEvents() {
|
|
WIKI.events.inbound.on('deletePageFromCache', hash => {
|
|
WIKI.models.pages.deletePageFromCache(hash)
|
|
})
|
|
WIKI.events.inbound.on('flushCache', () => {
|
|
WIKI.models.pages.flushCache()
|
|
})
|
|
}
|
|
}
|