Przeglądaj źródła

feat: convert page

NGPixel 4 lat temu
rodzic
commit
26f1c0f372

+ 13 - 1
client/components/common/nav-header.vue

@@ -150,6 +150,9 @@
                 v-list-item.pl-4(@click='pageSource', v-if='mode !== `source` && hasReadSourcePermission')
                   v-list-item-avatar(size='24', tile): v-icon(color='indigo') mdi-code-tags
                   v-list-item-title.body-2 {{$t('common:header.viewSource')}}
+                v-list-item.pl-4(@click='pageConvert', v-if='hasWritePagesPermission')
+                  v-list-item-avatar(size='24', tile): v-icon(color='indigo') mdi-lightning-bolt
+                  v-list-item-title.body-2 {{$t('common:header.convert')}}
                 v-list-item.pl-4(@click='pageDuplicate', v-if='hasWritePagesPermission')
                   v-list-item-avatar(size='24', tile): v-icon(color='indigo') mdi-content-duplicate
                   v-list-item-title.body-2 {{$t('common:header.duplicate')}}
@@ -237,6 +240,7 @@
     page-selector(mode='move', v-model='movePageModal', :open-handler='pageMoveRename', :path='path', :locale='locale')
     page-selector(mode='create', v-model='duplicateOpts.modal', :open-handler='pageDuplicateHandle', :path='duplicateOpts.path', :locale='duplicateOpts.locale')
     page-delete(v-model='deletePageModal', v-if='path && path.length')
+    page-convert(v-model='convertPageModal', v-if='path && path.length')
 
     .nav-header-dev(v-if='isDevMode')
       v-icon mdi-alert
@@ -255,7 +259,8 @@ import movePageMutation from 'gql/common/common-pages-mutation-move.gql'
 
 export default {
   components: {
-    PageDelete: () => import('./page-delete.vue')
+    PageDelete: () => import('./page-delete.vue'),
+    PageConvert: () => import('./page-convert.vue')
   },
   props: {
     dense: {
@@ -274,6 +279,7 @@ export default {
       searchAdvMenuShown: false,
       newPageModal: false,
       movePageModal: false,
+      convertPageModal: false,
       deletePageModal: false,
       locales: siteLangs,
       isDevMode: false,
@@ -354,6 +360,9 @@ export default {
     this.$root.$on('pageMove', () => {
       this.pageMove()
     })
+    this.$root.$on('pageConvert', () => {
+      this.pageConvert()
+    })
     this.$root.$on('pageDuplicate', () => {
       this.pageDuplicate()
     })
@@ -416,6 +425,9 @@ export default {
     pageDuplicateHandle ({ locale, path }) {
       window.location.assign(`/e/${locale}/${path}?from=${this.$store.get('page/id')}`)
     },
+    pageConvert () {
+      this.convertPageModal = true
+    },
     pageMove () {
       this.movePageModal = true
     },

+ 122 - 0
client/components/common/page-convert.vue

@@ -0,0 +1,122 @@
+<template lang='pug'>
+  v-dialog(
+    v-model='isShown'
+    max-width='550'
+    persistent
+    overlay-color='blue-grey darken-4'
+    overlay-opacity='.7'
+    )
+    v-card
+      .dialog-header.is-short.is-dark
+        v-icon.mr-2(color='white') mdi-lightning-bolt
+        span {{$t('common:page.convert')}}
+      v-card-text.pt-5
+        i18next.body-2(path='common:page.convertTitle', tag='div')
+          span.blue-grey--text.text--darken-2(place='title') {{pageTitle}}
+        v-select.mt-5(
+          :items=`[
+            { value: 'markdown', text: 'Markdown' },
+            { value: 'ckeditor', text: 'Visual Editor' },
+            { value: 'code', text: 'Raw HTML' }
+          ]`
+          outlined
+          dense
+          hide-details
+          v-model='newEditor'
+        )
+        .caption.mt-5 {{$t('common:page.convertSubtitle')}}
+      v-card-chin
+        v-spacer
+        v-btn(text, @click='discard', :disabled='loading') {{$t('common:actions.cancel')}}
+        v-btn.px-4(color='grey darken-3', @click='convertPage', :loading='loading').white--text {{$t('common:actions.convert')}}
+</template>
+
+<script>
+import _ from 'lodash'
+import { get } from 'vuex-pathify'
+import gql from 'graphql-tag'
+
+export default {
+  props: {
+    value: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      loading: false,
+      newEditor: ''
+    }
+  },
+  computed: {
+    isShown: {
+      get() { return this.value },
+      set(val) { this.$emit('input', val) }
+    },
+    pageTitle: get('page/title'),
+    pagePath: get('page/path'),
+    pageLocale: get('page/locale'),
+    pageId: get('page/id'),
+    pageEditor: get('page/editor')
+  },
+  mounted () {
+    this.newEditor = this.pageEditor
+  },
+  methods: {
+    discard() {
+      this.isShown = false
+    },
+    async convertPage() {
+      this.loading = true
+      this.$store.commit(`loadingStart`, 'page-convert')
+      this.$nextTick(async () => {
+        try {
+          const resp = await this.$apollo.mutate({
+            mutation: gql`
+              mutation (
+                $id: Int!
+                $editor: String!
+                ) {
+                  pages {
+                    convert(
+                      id: $id
+                      editor: $editor
+                    ) {
+                      responseResult {
+                        succeeded
+                        errorCode
+                        slug
+                        message
+                      }
+                    }
+                  }
+              }
+            `,
+            variables: {
+              id: this.pageId,
+              editor: this.newEditor
+            }
+          })
+          if (_.get(resp, 'data.pages.convert.responseResult.succeeded', false)) {
+            this.isShown = false
+            _.delay(() => {
+              window.location.assign(`/e/${this.pageLocale}/${this.pagePath}`)
+            }, 400)
+          } else {
+            throw new Error(_.get(resp, 'data.pages.convert.responseResult.message', this.$t('common:error.unexpected')))
+          }
+        } catch (err) {
+          this.$store.commit('pushGraphError', err)
+        }
+        this.$store.commit(`loadingStop`, 'page-convert')
+        this.loading = false
+      })
+    }
+  }
+}
+</script>
+
+<style lang='scss'>
+
+</style>

+ 1 - 0
client/store/page.js

@@ -14,6 +14,7 @@ const state = {
   tags: [],
   title: '',
   updatedAt: '',
+  editor: '',
   mode: '',
   scriptJs: '',
   scriptCss: '',

+ 21 - 1
client/themes/default/components/page.vue

@@ -234,6 +234,18 @@
                         )
                         v-icon(size='20') mdi-code-tags
                     span {{$t('common:header.viewSource')}}
+                  v-tooltip(:right='$vuetify.rtl', :left='!$vuetify.rtl', v-if='hasWritePagesPermission')
+                    template(v-slot:activator='{ on }')
+                      v-btn(
+                        fab
+                        small
+                        color='white'
+                        light
+                        v-on='on'
+                        @click='pageConvert'
+                        )
+                        v-icon(size='20') mdi-lightning-bolt
+                    span {{$t('common:header.convert')}}
                   v-tooltip(:right='$vuetify.rtl', :left='!$vuetify.rtl', v-if='hasWritePagesPermission')
                     template(v-slot:activator='{ on }')
                       v-btn(
@@ -314,7 +326,7 @@ import _ from 'lodash'
 import ClipboardJS from 'clipboard'
 import Vue from 'vue'
 
-Vue.component('tabset', Tabset)
+Vue.component('Tabset', Tabset)
 
 Prism.plugins.autoloader.languages_path = '/_assets/js/prism/'
 Prism.plugins.NormalizeWhitespace.setDefaults({
@@ -397,6 +409,10 @@ export default {
       type: Number,
       default: 0
     },
+    editor: {
+      type: String,
+      default: ''
+    },
     isPublished: {
       type: Boolean,
       default: false
@@ -516,6 +532,7 @@ export default {
     this.$store.set('page/path', this.path)
     this.$store.set('page/tags', this.tags)
     this.$store.set('page/title', this.title)
+    this.$store.set('page/editor', this.editor)
     this.$store.set('page/updatedAt', this.updatedAt)
     if (this.effectivePermissions) {
       this.$store.set('page/effectivePermissions', JSON.parse(Buffer.from(this.effectivePermissions, 'base64').toString()))
@@ -597,6 +614,9 @@ export default {
     pageSource () {
       this.$root.$emit('pageSource')
     },
+    pageConvert () {
+      this.$root.$emit('pageConvert')
+    },
     pageDuplicate () {
       this.$root.$emit('pageDuplicate')
     },

+ 2 - 0
package.json

@@ -39,6 +39,7 @@
     "@aoberoi/passport-slack": "1.0.5",
     "@azure/storage-blob": "12.2.1",
     "@exlinc/keycloak-passport": "1.0.2",
+    "@joplin/turndown-plugin-gfm": "1.0.27",
     "@root/csr": "0.8.1",
     "@root/keypairs": "0.10.1",
     "@root/pem": "1.0.4",
@@ -176,6 +177,7 @@
     "striptags": "3.1.1",
     "subscriptions-transport-ws": "0.9.18",
     "tar-fs": "2.1.0",
+    "turndown": "7.0.0",
     "twemoji": "13.0.1",
     "uslug": "1.0.4",
     "uuid": "8.3.1",

+ 16 - 0
server/graph/resolvers/page.js

@@ -398,6 +398,22 @@ module.exports = {
         return graphHelper.generateError(err)
       }
     },
+    /**
+     * CONVERT PAGE
+     */
+    async convert(obj, args, context) {
+      try {
+        await WIKI.models.pages.convertPage({
+          ...args,
+          user: context.req.user
+        })
+        return {
+          responseResult: graphHelper.generateSuccess('Page has been converted.')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
     /**
      * MOVE PAGE
      */

+ 5 - 0
server/graph/schemas/page.graphql

@@ -112,6 +112,11 @@ type PageMutation {
     title: String
   ): PageResponse @auth(requires: ["write:pages", "manage:pages", "manage:system"])
 
+  convert(
+    id: Int!
+    editor: String!
+  ): DefaultResponse @auth(requires: ["write:pages", "manage:pages", "manage:system"])
+
   move(
     id: Int!
     destinationPath: String!

+ 133 - 0
server/models/pages.js

@@ -9,6 +9,9 @@ const striptags = require('striptags')
 const emojiRegex = require('emoji-regex')
 const he = require('he')
 const CleanCSS = require('clean-css')
+const TurndownService = require('turndown')
+const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
+const cheerio = require('cheerio')
 
 /* global WIKI */
 
@@ -140,6 +143,7 @@ module.exports = class Page extends Model {
       creatorId: 'uint',
       creatorName: 'string',
       description: 'string',
+      editorKey: 'string',
       isPrivate: 'boolean',
       isPublished: 'boolean',
       publishEndDate: 'string',
@@ -471,6 +475,134 @@ module.exports = class Page extends Model {
     return page
   }
 
+  /**
+   * Convert an Existing Page
+   *
+   * @param {Object} opts Page Properties
+   * @returns {Promise} Promise of the Page Model Instance
+   */
+  static async convertPage(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: ogPage.localeCode,
+      path: ogPage.path
+    })) {
+      throw new WIKI.Error.PageUpdateForbidden()
+    }
+
+    // -> Check content type
+    const sourceContentType = ogPage.contentType
+    const targetContentType = _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text')
+    const shouldConvert = sourceContentType !== targetContentType
+    let convertedContent = null
+
+    // -> Convert content
+    if (shouldConvert) {
+      // -> Markdown => HTML
+      if (sourceContentType === 'markdown' && targetContentType === 'html') {
+        if (!ogPage.render) {
+          throw new Error('Aborted conversion because rendered page content is empty!')
+        }
+        convertedContent = ogPage.render
+
+        const $ = cheerio.load(convertedContent, {
+          decodeEntities: true
+        })
+
+        if ($.root().children().length > 0) {
+          $('.toc-anchor').remove()
+
+          convertedContent = $.html('body').replace('<body>', '').replace('</body>', '').replace(/&#x([0-9a-f]{1,6});/ig, (entity, code) => {
+            code = parseInt(code, 16)
+
+            // Don't unescape ASCII characters, assuming they're encoded for a good reason
+            if (code < 0x80) return entity
+
+            return String.fromCodePoint(code)
+          })
+        }
+
+      // -> HTML => Markdown
+      } else if (sourceContentType === 'html' && targetContentType === 'markdown') {
+        const td = new TurndownService({
+          bulletListMarker: '-',
+          codeBlockStyle: 'fenced',
+          emDelimiter: '*',
+          fence: '```',
+          headingStyle: 'atx',
+          hr: '---',
+          linkStyle: 'inlined',
+          preformattedCode: true,
+          strongDelimiter: '**'
+        })
+
+        td.use(turndownPluginGfm)
+
+        td.keep(['kbd'])
+
+        td.addRule('subscript', {
+          filter: ['sub'],
+          replacement: c => `~${c}~`
+        })
+
+        td.addRule('superscript', {
+          filter: ['sup'],
+          replacement: c => `^${c}^`
+        })
+
+        td.addRule('underline', {
+          filter: ['u'],
+          replacement: c => `_${c}_`
+        })
+
+        td.addRule('removeTocAnchors', {
+          filter: (n, o) => {
+            return n.nodeName === 'A' && n.classList.contains('toc-anchor')
+          },
+          replacement: c => ''
+        })
+
+        convertedContent = td.turndown(ogPage.content)
+      // -> Unsupported
+      } else {
+        throw new Error('Unsupported source / destination content types combination.')
+      }
+    }
+
+    // -> Create version snapshot
+    if (shouldConvert) {
+      await WIKI.models.pageHistory.addVersion({
+        ...ogPage,
+        isPublished: ogPage.isPublished === true || ogPage.isPublished === 1,
+        action: 'updated',
+        versionDate: ogPage.updatedAt
+      })
+    }
+
+    // -> Update page
+    await WIKI.models.pages.query().patch({
+      contentType: targetContentType,
+      editorKey: opts.editor,
+      ...(convertedContent ? { content: convertedContent } : {})
+    }).where('id', ogPage.id)
+    const page = await WIKI.models.pages.getPageFromDb(ogPage.id)
+
+    await WIKI.models.pages.deletePageFromCache(page.hash)
+    WIKI.events.outbound.emit('deletePageFromCache', page.hash)
+
+    // -> Update on Storage
+    await WIKI.models.storage.pageEvent({
+      event: 'updated',
+      page
+    })
+  }
+
   /**
    * Move a Page
    *
@@ -872,6 +1004,7 @@ module.exports = class Page extends Model {
       creatorId: page.creatorId,
       creatorName: page.creatorName,
       description: page.description,
+      editorKey: page.editorKey,
       extra: {
         css: _.get(page, 'extra.css', ''),
         js: _.get(page, 'extra.js', '')

+ 1 - 0
server/views/page.pug

@@ -20,6 +20,7 @@ block body
       updated-at=page.updatedAt
       author-name=page.authorName
       :author-id=page.authorId
+      editor=page.editorKey
       :is-published=page.isPublished.toString()
       toc=Buffer.from(page.toc).toString('base64')
       :page-id=page.id

+ 17 - 0
yarn.lock

@@ -3220,6 +3220,11 @@
     "@types/yargs" "^15.0.0"
     chalk "^4.0.0"
 
+"@joplin/turndown-plugin-gfm@1.0.27":
+  version "1.0.27"
+  resolved "https://registry.yarnpkg.com/@joplin/turndown-plugin-gfm/-/turndown-plugin-gfm-1.0.27.tgz#15ae15c169b88a355647065e7502f6619f0ace46"
+  integrity sha512-4BPgTSkhvxPI3tbjG4BPiBq0VuNZji1Y77DRWHb09GnzsrgwBI+gpo3EI6obkyIeRuN/03wzf98W5u1iau2vpQ==
+
 "@kwsites/file-exists@^1.1.1":
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99"
@@ -8277,6 +8282,11 @@ domhandler@^2.3.0:
   dependencies:
     domelementtype "1"
 
+domino@^2.1.6:
+  version "2.1.6"
+  resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.6.tgz#fe4ace4310526e5e7b9d12c7de01b7f485a57ffe"
+  integrity sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==
+
 dompurify@2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.0.tgz#51d34e76faa38b5d6b4e83a0678530f27fe3965c"
@@ -18405,6 +18415,13 @@ tunnel@0.0.6, tunnel@^0.0.6:
   resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
   integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
 
+turndown@7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.0.0.tgz#19b2a6a2d1d700387a1e07665414e4af4fec5225"
+  integrity sha512-G1FfxfR0mUNMeGjszLYl3kxtopC4O9DRRiMlMDDVHvU1jaBkGFg4qxIyjIk2aiKLHyDyZvZyu4qBO2guuYBy3Q==
+  dependencies:
+    domino "^2.1.6"
+
 tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   version "0.14.5"
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"