diff --git a/client/components/common/nav-header.vue b/client/components/common/nav-header.vue index f1b6e687..d8fc3c80 100644 --- a/client/components/common/nav-header.vue +++ b/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 }, diff --git a/client/components/common/page-convert.vue b/client/components/common/page-convert.vue new file mode 100644 index 00000000..94db317c --- /dev/null +++ b/client/components/common/page-convert.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/client/store/page.js b/client/store/page.js index e6b0992e..0a72798e 100644 --- a/client/store/page.js +++ b/client/store/page.js @@ -14,6 +14,7 @@ const state = { tags: [], title: '', updatedAt: '', + editor: '', mode: '', scriptJs: '', scriptCss: '', diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue index 8d5b222d..b02c40e8 100644 --- a/client/themes/default/components/page.vue +++ b/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') }, diff --git a/package.json b/package.json index add282ae..afa3bf1b 100644 --- a/package.json +++ b/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", diff --git a/server/graph/resolvers/page.js b/server/graph/resolvers/page.js index d1e60121..e356ac0f 100644 --- a/server/graph/resolvers/page.js +++ b/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 */ diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index a230185c..10eebc85 100644 --- a/server/graph/schemas/page.graphql +++ b/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! diff --git a/server/models/pages.js b/server/models/pages.js index 16f5b2f1..681bba08 100644 --- a/server/models/pages.js +++ b/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('', '').replace('', '').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', '') diff --git a/server/views/page.pug b/server/views/page.pug index 2096a7c8..cc19fbae 100644 --- a/server/views/page.pug +++ b/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 diff --git a/yarn.lock b/yarn.lock index 9f4edca1..5058d061 100644 --- a/yarn.lock +++ b/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"