pages.js 25.3 KB
Newer Older
1
const Model = require('objection').Model
2
const _ = require('lodash')
3 4 5 6
const JSBinType = require('js-binary').Type
const pageHelper = require('../helpers/page')
const path = require('path')
const fs = require('fs-extra')
Nick's avatar
Nick committed
7
const yaml = require('js-yaml')
8 9
const striptags = require('striptags')
const emojiRegex = require('emoji-regex')
10
const he = require('he')
11

12 13
/* global WIKI */

Nick's avatar
Nick committed
14 15
const frontmatterRegex = {
  html: /^(<!-{2}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{2}>)?(?:\n|\r)*([\w\W]*)*/,
16
  legacy: /^(<!-- TITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)?(<!-- SUBTITLE: ?([\w\W]+?) ?-{2}>)?(?:\n|\r)*([\w\W]*)*/i,
Nick's avatar
Nick committed
17 18 19
  markdown: /^(-{3}(?:\n|\r)([\w\W]+?)(?:\n|\r)-{3})?(?:\n|\r)*([\w\W]*)*/
}

20
const punctuationRegex = /[!,:;/\\_+\-=()&#@<>$~%^*[\]{}"'|]+|(\.\s)|(\s\.)/ig
21
// const htmlEntitiesRegex = /(&#[0-9]{3};)|(&#x[a-zA-Z0-9]{2};)/ig
22

23 24 25 26 27 28 29 30 31 32 33 34 35 36
/**
 * 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'},
37
        hash: {type: 'string'},
38 39 40
        title: {type: 'string'},
        description: {type: 'string'},
        isPublished: {type: 'boolean'},
41
        privateNS: {type: 'string'},
42 43 44
        publishStartDate: {type: 'string'},
        publishEndDate: {type: 'string'},
        content: {type: 'string'},
45
        contentType: {type: 'string'},
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66

        createdAt: {type: 'string'},
        updatedAt: {type: 'string'}
      }
    }
  }

  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'
        }
      },
67
      links: {
Nick's avatar
Nick committed
68 69
        relation: Model.HasManyRelation,
        modelClass: require('./pageLinks'),
70 71
        join: {
          from: 'pages.id',
Nick's avatar
Nick committed
72
          to: 'pageLinks.pageId'
73 74
        }
      },
75 76 77 78 79 80 81 82
      author: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./users'),
        join: {
          from: 'pages.authorId',
          to: 'users.id'
        }
      },
83 84 85 86 87 88 89 90
      creator: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./users'),
        join: {
          from: 'pages.creatorId',
          to: 'users.id'
        }
      },
NGPixel's avatar
NGPixel committed
91 92 93 94 95 96 97 98
      editor: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./editors'),
        join: {
          from: 'pages.editorKey',
          to: 'editors.key'
        }
      },
99 100 101 102
      locale: {
        relation: Model.BelongsToOneRelation,
        modelClass: require('./locales'),
        join: {
NGPixel's avatar
NGPixel committed
103
          from: 'pages.localeCode',
104 105 106 107 108 109 110 111 112 113 114 115 116
          to: 'locales.code'
        }
      }
    }
  }

  $beforeUpdate() {
    this.updatedAt = new Date().toISOString()
  }
  $beforeInsert() {
    this.createdAt = new Date().toISOString()
    this.updatedAt = new Date().toISOString()
  }
117

118 119 120
  /**
   * Cache Schema
   */
121 122
  static get cacheSchema() {
    return new JSBinType({
Nicolas Giard's avatar
Nicolas Giard committed
123
      id: 'uint',
124 125 126 127 128 129 130 131 132 133 134
      authorId: 'uint',
      authorName: 'string',
      createdAt: 'string',
      creatorId: 'uint',
      creatorName: 'string',
      description: 'string',
      isPrivate: 'boolean',
      isPublished: 'boolean',
      publishEndDate: 'string',
      publishStartDate: 'string',
      render: 'string',
135 136 137 138 139 140
      tags: [
        {
          tag: 'string',
          title: 'string'
        }
      ],
141
      title: 'string',
142
      toc: 'string',
143 144
      updatedAt: 'string'
    })
145 146
  }

147 148
  /**
   * Inject page metadata into contents
149 150
   *
   * @returns {string} Page Contents with Injected Metadata
151 152
   */
  injectMetadata () {
153
    return pageHelper.injectPageMetadata(this)
154 155
  }

156 157
  /**
   * Get the page's file extension based on content type
158 159
   *
   * @returns {string} File Extension
160 161
   */
  getFileExtension() {
Nick's avatar
Nick committed
162
    return pageHelper.getFileExtension(this.contentType)
163 164
  }

Nick's avatar
Nick committed
165 166 167 168 169
  /**
   * Parse injected page metadata from raw content
   *
   * @param {String} raw Raw file contents
   * @param {String} contentType Content Type
170
   * @returns {Object} Parsed Page Metadata with Raw Content
Nick's avatar
Nick committed
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
   */
  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
    }
  }

209 210 211 212 213 214
  /**
   * Create a New Page
   *
   * @param {Object} opts Page Properties
   * @returns {Promise} Promise of the Page Model Instance
   */
215
  static async createPage(opts) {
NGPixel's avatar
NGPixel committed
216
    // -> Validate path
NGPixel's avatar
NGPixel committed
217
    if (opts.path.indexOf('.') >= 0 || opts.path.indexOf(' ') >= 0 || opts.path.indexOf('\\') >= 0) {
218 219 220
      throw new WIKI.Error.PageIllegalPath()
    }

221
    // -> Remove trailing slash
NGPixel's avatar
NGPixel committed
222
    if (opts.path.endsWith('/')) {
223 224 225
      opts.path = opts.path.slice(0, -1)
    }

NGPixel's avatar
NGPixel committed
226 227 228 229 230 231 232 233 234
    // -> 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
Nick's avatar
Nick committed
235 236 237 238 239
    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()
    }

NGPixel's avatar
NGPixel committed
240
    // -> Check for empty content
Nick's avatar
Nick committed
241 242 243 244
    if (!opts.content || _.trim(opts.content).length < 1) {
      throw new WIKI.Error.PageEmptyContent()
    }

NGPixel's avatar
NGPixel committed
245
    // -> Create page
246
    await WIKI.models.pages.query().insert({
NGPixel's avatar
NGPixel committed
247
      authorId: opts.user.id,
248
      content: opts.content,
NGPixel's avatar
NGPixel committed
249
      creatorId: opts.user.id,
250
      contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
251 252
      description: opts.description,
      editorKey: opts.editor,
253
      hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' }),
254 255 256 257
      isPrivate: opts.isPrivate,
      isPublished: opts.isPublished,
      localeCode: opts.locale,
      path: opts.path,
258 259 260 261
      publishEndDate: opts.publishEndDate || '',
      publishStartDate: opts.publishStartDate || '',
      title: opts.title,
      toc: '[]'
262
    })
263 264 265
    const page = await WIKI.models.pages.getPageFromDb({
      path: opts.path,
      locale: opts.locale,
NGPixel's avatar
NGPixel committed
266
      userId: opts.user.id,
267 268
      isPrivate: opts.isPrivate
    })
269

270
    // -> Save Tags
NGPixel's avatar
NGPixel committed
271
    if (opts.tags && opts.tags.length > 0) {
272 273 274
      await WIKI.models.tags.associateTags({ tags: opts.tags, page })
    }

275
    // -> Render page to HTML
276
    await WIKI.models.pages.renderPage(page)
277

278 279 280
    // -> Rebuild page tree
    await WIKI.models.pages.rebuildTree()

281 282 283
    // -> Add to Search Index
    const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
    page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
284
    await WIKI.data.searchEngine.created(page)
285 286

    // -> Add to Storage
Nick's avatar
Nick committed
287 288 289 290 291 292
    if (!opts.skipStorage) {
      await WIKI.models.storage.pageEvent({
        event: 'created',
        page
      })
    }
293

294 295 296 297 298 299 300
    // -> Reconnect Links
    await WIKI.models.pages.reconnectLinks({
      locale: page.localeCode,
      path: page.path,
      mode: 'create'
    })

NGPixel's avatar
NGPixel committed
301 302 303
    // -> Get latest updatedAt
    page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)

304 305 306
    return page
  }

307 308 309 310 311 312
  /**
   * Update an Existing Page
   *
   * @param {Object} opts Page Properties
   * @returns {Promise} Promise of the Page Model Instance
   */
313
  static async updatePage(opts) {
NGPixel's avatar
NGPixel committed
314
    // -> Fetch original page
315
    const ogPage = await WIKI.models.pages.query().findById(opts.id)
316 317 318
    if (!ogPage) {
      throw new Error('Invalid Page Id')
    }
Nick's avatar
Nick committed
319

NGPixel's avatar
NGPixel committed
320 321 322 323 324 325 326 327 328
    // -> 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
Nick's avatar
Nick committed
329 330 331 332
    if (!opts.content || _.trim(opts.content).length < 1) {
      throw new WIKI.Error.PageEmptyContent()
    }

NGPixel's avatar
NGPixel committed
333
    // -> Create version snapshot
Nicolas Giard's avatar
Nicolas Giard committed
334 335
    await WIKI.models.pageHistory.addVersion({
      ...ogPage,
Nick's avatar
Nick committed
336
      isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
NGPixel's avatar
NGPixel committed
337 338
      action: opts.action ? opts.action : 'updated',
      versionDate: ogPage.updatedAt
Nicolas Giard's avatar
Nicolas Giard committed
339
    })
NGPixel's avatar
NGPixel committed
340 341

    // -> Update page
342
    await WIKI.models.pages.query().patch({
NGPixel's avatar
NGPixel committed
343
      authorId: opts.user.id,
344 345
      content: opts.content,
      description: opts.description,
Nick's avatar
Nick committed
346
      isPublished: opts.isPublished === true || opts.isPublished === 1,
347 348
      publishEndDate: opts.publishEndDate || '',
      publishStartDate: opts.publishStartDate || '',
349
      title: opts.title
350
    }).where('id', ogPage.id)
NGPixel's avatar
NGPixel committed
351
    let page = await WIKI.models.pages.getPageFromDb(ogPage.id)
352

353 354 355
    // -> Save Tags
    await WIKI.models.tags.associateTags({ tags: opts.tags, page })

356
    // -> Render page to HTML
357
    await WIKI.models.pages.renderPage(page)
NGPixel's avatar
NGPixel committed
358
    WIKI.events.outbound.emit('deletePageFromCache', page.hash)
359 360 361 362

    // -> Update Search Index
    const pageContents = await WIKI.models.pages.query().findById(page.id).select('render')
    page.safeContent = WIKI.models.pages.cleanHTML(pageContents.render)
363
    await WIKI.data.searchEngine.updated(page)
364 365

    // -> Update on Storage
Nick's avatar
Nick committed
366 367 368 369 370 371
    if (!opts.skipStorage) {
      await WIKI.models.storage.pageEvent({
        event: 'updated',
        page
      })
    }
NGPixel's avatar
NGPixel committed
372 373

    // -> Perform move?
374
    if ((opts.locale && opts.locale !== page.localeCode) || (opts.path && opts.path !== page.path)) {
NGPixel's avatar
NGPixel committed
375 376 377 378 379 380
      await WIKI.models.pages.movePage({
        id: page.id,
        destinationLocale: opts.locale,
        destinationPath: opts.path,
        user: opts.user
      })
381 382 383 384 385
    } else {
      // -> Update title of page tree entry
      await WIKI.models.knex.table('pageTree').where({
        pageId: page.id
      }).update('title', page.title)
NGPixel's avatar
NGPixel committed
386 387
    }

NGPixel's avatar
NGPixel committed
388 389 390
    // -> Get latest updatedAt
    page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)

391 392
    return page
  }
393

NGPixel's avatar
NGPixel committed
394 395 396 397 398 399 400 401 402 403 404 405
  /**
   * 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()
    }

406
    // -> Validate path
NGPixel's avatar
NGPixel committed
407
    if (opts.destinationPath.indexOf('.') >= 0 || opts.destinationPath.indexOf(' ') >= 0 || opts.destinationPath.indexOf('\\') >= 0) {
408 409 410 411
      throw new WIKI.Error.PageIllegalPath()
    }

    // -> Remove trailing slash
NGPixel's avatar
NGPixel committed
412
    if (opts.destinationPath.endsWith('/')) {
413 414 415
      opts.destinationPath = opts.destinationPath.slice(0, -1)
    }

NGPixel's avatar
NGPixel committed
416 417
    // -> Check for source page access
    if (!WIKI.auth.checkAccess(opts.user, ['manage:pages'], {
418 419
      locale: page.localeCode,
      path: page.path
NGPixel's avatar
NGPixel committed
420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
    })) {
      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 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,
NGPixel's avatar
NGPixel committed
443 444
      action: 'moved',
      versionDate: page.updatedAt
NGPixel's avatar
NGPixel committed
445 446 447 448 449 450 451 452 453 454
    })

    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)
455 456
    await WIKI.models.pages.deletePageFromCache(page.hash)
    WIKI.events.outbound.emit('deletePageFromCache', page.hash)
NGPixel's avatar
NGPixel committed
457

458 459 460
    // -> Rebuild page tree
    await WIKI.models.pages.rebuildTree()

NGPixel's avatar
NGPixel committed
461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494
    // -> Rename in Search Index
    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
    await WIKI.models.pages.reconnectLinks({
      sourceLocale: page.localeCode,
      sourcePath: page.path,
      locale: opts.destinationLocale,
      path: opts.destinationPath,
      mode: 'move'
    })
  }

495 496 497 498 499 500
  /**
   * Delete an Existing Page
   *
   * @param {Object} opts Page Properties
   * @returns {Promise} Promise with no value
   */
Nicolas Giard's avatar
Nicolas Giard committed
501
  static async deletePage(opts) {
Nick's avatar
Nick committed
502 503 504 505 506 507 508 509 510
    let page
    if (_.has(opts, 'id')) {
      page = await WIKI.models.pages.query().findById(opts.id)
    } else {
      page = await await WIKI.models.pages.query().findOne({
        path: opts.path,
        localeCode: opts.locale
      })
    }
Nicolas Giard's avatar
Nicolas Giard committed
511 512 513
    if (!page) {
      throw new Error('Invalid Page Id')
    }
NGPixel's avatar
NGPixel committed
514 515 516 517 518 519 520 521 522 523

    // -> 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
Nicolas Giard's avatar
Nicolas Giard committed
524 525
    await WIKI.models.pageHistory.addVersion({
      ...page,
NGPixel's avatar
NGPixel committed
526 527
      action: 'deleted',
      versionDate: page.updatedAt
Nicolas Giard's avatar
Nicolas Giard committed
528
    })
NGPixel's avatar
NGPixel committed
529 530

    // -> Delete page
Nicolas Giard's avatar
Nicolas Giard committed
531
    await WIKI.models.pages.query().delete().where('id', page.id)
532 533
    await WIKI.models.pages.deletePageFromCache(page.hash)
    WIKI.events.outbound.emit('deletePageFromCache', page.hash)
534

535 536 537
    // -> Rebuild page tree
    await WIKI.models.pages.rebuildTree()

538
    // -> Delete from Search Index
539
    await WIKI.data.searchEngine.deleted(page)
540 541

    // -> Delete from Storage
Nick's avatar
Nick committed
542 543 544 545 546 547
    if (!opts.skipStorage) {
      await WIKI.models.storage.pageEvent({
        event: 'deleted',
        page
      })
    }
548 549 550 551 552 553 554 555 556 557

    // -> Reconnect Links
    await WIKI.models.pages.reconnectLinks({
      locale: page.localeCode,
      path: page.path,
      mode: 'delete'
    })
  }

  /**
558
   * Reconnect links to new/move/deleted page
559 560 561 562 563 564
   *
   * @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)
565
   * @param {string} opts.mode - Page Update mode (create, move, delete)
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592
   * @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-invalid-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 = []
593 594
    // -> Perform replace and return affected page hashes (POSTGRES only)
    if (WIKI.config.db.type === 'postgres') {
595
      const qryHashes = await WIKI.models.pages.query()
596 597 598 599 600 601 602 603 604 605
        .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
          })
        })
606
      affectedHashes = qryHashes.map(h => h.hash)
607
    } else {
608
      // -> Perform replace, then query affected page hashes (MYSQL, MARIADB, MSSQL, SQLITE only)
609 610 611 612 613 614 615 616 617 618
      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
          })
        })
619
      const qryHashes = await WIKI.models.pages.query()
620 621 622 623 624 625 626
        .column('hash')
        .whereIn('pages.id', function () {
          this.select('pageLinks.pageId').from('pageLinks').where({
            'pageLinks.path': opts.path,
            'pageLinks.localeCode': opts.locale
          })
        })
627
      affectedHashes = qryHashes.map(h => h.hash)
628 629
    }
    for (const hash of affectedHashes) {
630 631
      await WIKI.models.pages.deletePageFromCache(hash)
      WIKI.events.outbound.emit('deletePageFromCache', hash)
632
    }
Nicolas Giard's avatar
Nicolas Giard committed
633 634
  }

635 636 637 638 639 640 641 642 643 644 645 646 647 648
  /**
   * 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
  }

649 650 651 652 653 654
  /**
   * Trigger the rendering of a page
   *
   * @param {Object} page Page Model Instance
   * @returns {Promise} Promise with no value
   */
655
  static async renderPage(page) {
656 657 658 659 660 661
    const renderJob = await WIKI.scheduler.registerJob({
      name: 'render-page',
      immediate: true,
      worker: true
    }, page.id)
    return renderJob.finished
662
  }
663

664 665 666 667 668 669
  /**
   * 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
   */
670
  static async getPage(opts) {
671
    // -> Get from cache first
672 673
    let page = await WIKI.models.pages.getPageFromCache(opts)
    if (!page) {
674
      // -> Get from DB
675
      page = await WIKI.models.pages.getPageFromDb(opts)
676
      if (page) {
677 678 679 680 681 682 683 684
        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.')
        }
685
      }
686 687 688 689
    }
    return page
  }

690 691 692 693 694 695
  /**
   * Fetch an Existing Page from the Database
   *
   * @param {Object} opts Page Properties
   * @returns {Promise} Promise of the Page Model Instance
   */
696
  static async getPageFromDb(opts) {
697
    const queryModeID = _.isNumber(opts)
698 699 700
    try {
      return WIKI.models.pages.query()
        .column([
701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720
          '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',
721 722 723 724 725 726 727
          {
            authorName: 'author.name',
            authorEmail: 'author.email',
            creatorName: 'creator.name',
            creatorEmail: 'creator.email'
          }
        ])
NGPixel's avatar
NGPixel committed
728 729
        .joinRelated('author')
        .joinRelated('creator')
730 731 732
        .withGraphJoined('tags')
        .modifyGraph('tags', builder => {
          builder.select('tag', 'title')
733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761
        })
        .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
    }
762 763
  }

764 765 766 767 768 769
  /**
   * Save a Page Model Instance to Cache
   *
   * @param {Object} page Page Model Instance
   * @returns {Promise} Promise with no value
   */
770
  static async savePageToCache(page) {
771
    const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${page.hash}.bin`)
772
    await fs.outputFile(cachePath, WIKI.models.pages.cacheSchema.encode({
Nicolas Giard's avatar
Nicolas Giard committed
773
      id: page.id,
774 775 776 777 778 779
      authorId: page.authorId,
      authorName: page.authorName,
      createdAt: page.createdAt,
      creatorId: page.creatorId,
      creatorName: page.creatorName,
      description: page.description,
Nicolas Giard's avatar
Nicolas Giard committed
780 781
      isPrivate: page.isPrivate === 1 || page.isPrivate === true,
      isPublished: page.isPublished === 1 || page.isPublished === true,
782 783 784
      publishEndDate: page.publishEndDate,
      publishStartDate: page.publishStartDate,
      render: page.render,
785
      tags: page.tags.map(t => _.pick(t, ['tag', 'title'])),
786
      title: page.title,
Nicolas Giard's avatar
Nicolas Giard committed
787
      toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
788 789 790 791
      updatedAt: page.updatedAt
    }))
  }

792 793 794 795 796 797
  /**
   * Fetch an Existing Page from Cache
   *
   * @param {Object} opts Page Properties
   * @returns {Promise} Promise of the Page Model Instance
   */
798 799
  static async getPageFromCache(opts) {
    const pageHash = pageHelper.generateHash({ path: opts.path, locale: opts.locale, privateNS: opts.isPrivate ? 'TODO' : '' })
800
    const cachePath = path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${pageHash}.bin`)
801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818

    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
    }
  }
Nicolas Giard's avatar
Nicolas Giard committed
819

820 821 822
  /**
   * Delete an Existing Page from Cache
   *
NGPixel's avatar
NGPixel committed
823
   * @param {String} page Page Unique Hash
824 825
   * @returns {Promise} Promise with no value
   */
NGPixel's avatar
NGPixel committed
826 827
  static async deletePageFromCache(hash) {
    return fs.remove(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache/${hash}.bin`))
Nicolas Giard's avatar
Nicolas Giard committed
828
  }
829

830 831 832
  /**
   * Flush the contents of the Cache
   */
Nick's avatar
Nick committed
833
  static async flushCache() {
834
    return fs.emptyDir(path.resolve(WIKI.ROOTPATH, WIKI.config.dataPath, `cache`))
Nick's avatar
Nick committed
835 836
  }

837 838 839 840 841 842 843 844
  /**
   * 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
   */
845 846 847 848 849 850 851 852 853 854 855 856 857
  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')
      })
  }

858 859 860 861 862 863
  /**
   * Clean raw HTML from content for use in search engines
   *
   * @param {string} rawHTML Raw HTML
   * @returns {string} Cleaned Content Text
   */
864
  static cleanHTML(rawHTML = '') {
865
    let data = striptags(rawHTML || '', [], ' ')
866
      .replace(emojiRegex(), '')
867 868
      // .replace(htmlEntitiesRegex, '')
    return he.decode(data)
869 870 871 872 873
      .replace(punctuationRegex, ' ')
      .replace(/(\r\n|\n|\r)/gm, ' ')
      .replace(/\s\s+/g, ' ')
      .split(' ').filter(w => w.length > 1).join(' ').toLowerCase()
  }
874 875 876 877 878 879 880 881 882 883 884 885

  /**
   * 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()
    })
  }
886
}