From e50dc8951994a68e89861b2ef8e53a5de136bac6 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sat, 29 Feb 2020 18:57:54 -0500 Subject: [PATCH] feat: view version of page source --- client/components/history.vue | 103 ++++++++++++++++++++++---- client/components/source.vue | 21 +++++- dev/build/Dockerfile | 4 +- server/controllers/common.js | 32 ++++++-- server/db/migrations-sqlite/2.2.17.js | 9 +++ server/db/migrations/2.2.17.js | 22 ++++++ server/graph/schemas/page.graphql | 3 +- server/models/pageHistory.js | 14 ++-- server/views/history.pug | 8 ++ server/views/source.pug | 2 + 10 files changed, 188 insertions(+), 30 deletions(-) create mode 100644 server/db/migrations-sqlite/2.2.17.js create mode 100644 server/db/migrations/2.2.17.js diff --git a/client/components/history.vue b/client/components/history.vue index 2e636bc5..b90fe13a 100644 --- a/client/components/history.vue +++ b/client/components/history.vue @@ -23,30 +23,30 @@ dense ) v-timeline-item.pb-2( - v-for='(ph, idx) in trail' + v-for='(ph, idx) in fullTrail' :key='ph.versionId' :small='ph.actionType === `edit`' :color='trailColor(ph.actionType)' :icon='trailIcon(ph.actionType)' - :class='idx >= trail.length - 1 ? `pb-4` : `pb-2`' ) v-card.radius-7(flat, :class='trailBgColor(ph.actionType)') v-toolbar(flat, :color='trailBgColor(ph.actionType)', height='40') - .caption(:title='$options.filters.moment(ph.createdAt, `LLL`)') {{ ph.createdAt | moment('ll') }} + .caption(:title='$options.filters.moment(ph.versionDate, `LLL`)') {{ ph.versionDate | moment('ll') }} v-divider.mx-3(vertical) .caption(v-if='ph.actionType === `edit`') Edited by #[strong {{ ph.authorName }}] .caption(v-else-if='ph.actionType === `move`') Moved from #[strong {{ph.valueBefore}}] to #[strong {{ph.valueAfter}}] by #[strong {{ ph.authorName }}] .caption(v-else-if='ph.actionType === `initial`') Created by #[strong {{ ph.authorName }}] + .caption(v-else-if='ph.actionType === `live`') Last Edited by #[strong {{ ph.authorName }}] .caption(v-else) Unknown Action by #[strong {{ ph.authorName }}] v-spacer v-menu(offset-x, left) template(v-slot:activator='{ on }') v-btn.mr-2.radius-4(icon, v-on='on', small, tile): v-icon mdi-dots-horizontal v-list(dense, nav).history-promptmenu - v-list-item(@click='setDiffSource(ph.versionId)', :disabled='ph.versionId >= diffTarget') + v-list-item(@click='setDiffSource(ph.versionId)', :disabled='(ph.versionId >= diffTarget && diffTarget !== 0) || ph.versionId === 0') v-list-item-avatar(size='24'): v-avatar A v-list-item-title Set as Differencing Source - v-list-item(@click='setDiffTarget(ph.versionId)', :disabled='ph.versionId <= diffSource') + v-list-item(@click='setDiffTarget(ph.versionId)', :disabled='ph.versionId <= diffSource && ph.versionId !== 0') v-list-item-avatar(size='24'): v-avatar B v-list-item-title Set as Differencing Target v-list-item(@click='viewSource(ph.versionId)') @@ -55,8 +55,8 @@ v-list-item(@click='download(ph.versionId)') v-list-item-avatar(size='24'): v-icon mdi-cloud-download-outline v-list-item-title Download Version - v-list-item(@click='restore(ph.versionId)') - v-list-item-avatar(size='24'): v-icon mdi-history + v-list-item(@click='restore(ph.versionId)', :disabled='ph.versionId === 0') + v-list-item-avatar(size='24'): v-icon(:disabled='ph.versionId === 0') mdi-history v-list-item-title Restore v-list-item(@click='branchOff(ph.versionId)') v-list-item-avatar(size='24'): v-icon mdi-source-branch @@ -68,7 +68,7 @@ depressed tile :class='diffSource === ph.versionId ? `pink white--text` : ($vuetify.theme.dark ? `grey darken-2` : `grey lighten-2`)' - :disabled='ph.versionId >= diffTarget' + :disabled='(ph.versionId >= diffTarget && diffTarget !== 0) || ph.versionId === 0' ): strong A v-btn.mr-0.radius-4( @click='setDiffTarget(ph.versionId)' @@ -77,7 +77,7 @@ depressed tile :class='diffTarget === ph.versionId ? `pink white--text` : ($vuetify.theme.dark ? `grey darken-2` : `grey lighten-2`)' - :disabled='ph.versionId <= diffSource' + :disabled='ph.versionId <= diffSource && ph.versionId !== 0' ): strong B v-btn.ma-0.radius-7( @@ -137,6 +137,38 @@ export default { type: String, default: 'home' }, + title: { + type: String, + default: 'Untitled Page' + }, + description: { + type: String, + default: '' + }, + createdAt: { + type: String, + default: '' + }, + updatedAt: { + type: String, + default: '' + }, + tags: { + type: Array, + default: () => ([]) + }, + authorName: { + type: String, + default: 'Unknown' + }, + authorId: { + type: Number, + default: 0 + }, + isPublished: { + type: Boolean, + default: false + }, liveContent: { type: String, default: '' @@ -167,6 +199,20 @@ export default { }, computed: { darkMode: get('site/dark'), + fullTrail () { + return [ + { + versionId: 0, + authorId: this.authorId, + authorName: this.authorName, + actionType: 'live', + valueBefore: null, + valueAfter: null, + versionDate: this.updatedAt + }, + ...this.trail + ] + }, diffs () { return createPatch(`/${this.path}`, this.source.content, this.target.content) }, @@ -182,8 +228,8 @@ export default { watch: { trail (newValue, oldValue) { if (newValue && newValue.length > 0) { - this.diffTarget = _.get(_.head(newValue), 'versionId', 0) - this.diffSource = _.get(_.nth(newValue, 1), 'versionId', 0) + this.diffTarget = 0 + this.diffSource = _.get(_.head(newValue), 'versionId', 0) } }, async diffSource (newValue, oldValue) { @@ -214,7 +260,29 @@ export default { this.$store.commit('page/SET_MODE', 'history') - this.target.content = this.liveContent + this.cache.push({ + action: 'live', + authorId: this.authorId, + authorName: this.authorName, + content: this.liveContent, + contentType: '', + createdAt: this.createdAt, + description: this.description, + editor: '', + isPrivate: false, + isPublished: this.isPublished, + locale: this.locale, + pageId: this.pageId, + path: this.path, + publishEndDate: '', + publishStartDate: '', + tags: this.tags, + title: this.title, + versionId: 0, + versionDate: this.updatedAt + }) + + this.target = this.cache[0] }, methods: { async loadVersion (versionId) { @@ -230,6 +298,7 @@ export default { content contentType createdAt + versionDate description editor isPrivate @@ -314,6 +383,8 @@ export default { return 'purple' case 'initial': return 'teal' + case 'live': + return 'orange' default: return 'grey' } @@ -326,8 +397,10 @@ export default { return 'forward' case 'initial': return 'mdi-plus' + case 'live': + return 'mdi-atom-variant' default: - return 'warning' + return 'mdi-alert' } }, trailBgColor (actionType) { @@ -336,6 +409,8 @@ export default { return this.darkMode ? 'purple' : 'purple lighten-5' case 'initial': return this.darkMode ? 'teal darken-3' : 'teal lighten-5' + case 'live': + return this.darkMode ? 'orange darken-3' : 'orange lighten-5' default: return this.darkMode ? 'grey darken-3' : 'grey lighten-4' } @@ -354,7 +429,7 @@ export default { actionType valueBefore valueAfter - createdAt + versionDate } total } diff --git a/client/components/source.vue b/client/components/source.vue index 31ac1822..05ad95cc 100644 --- a/client/components/source.vue +++ b/client/components/source.vue @@ -3,11 +3,17 @@ nav-header v-content v-toolbar(color='primary', dark) - i18next.subheading(path='common:page.viewingSource', tag='div') + i18next.subheading(v-if='versionId > 0', path='common:page.viewingSourceVersion', tag='div') + strong(place='date', :title='$options.filters.moment(versionDate, `LLL`)') {{versionDate | moment('lll')}} + strong(place='path') /{{path}} + i18next.subheading(v-else, path='common:page.viewingSource', tag='div') strong(place='path') /{{path}} template(v-if='$vuetify.breakpoint.mdAndUp') v-spacer .caption.blue--text.text--lighten-3 {{$t('common:page.id', { id: pageId })}} + .caption.blue--text.text--lighten-3.ml-4(v-if='versionId > 0') {{$t('common:page.versionId', { id: versionId })}} + v-btn.ml-4(v-if='versionId > 0', depressed, color='blue darken-1', @click='goHistory') + v-icon mdi-history v-btn.ml-4(depressed, color='blue darken-1', @click='goLive') {{$t('common:page.returnNormalView')}} v-card(tile) v-card-text @@ -38,6 +44,14 @@ export default { path: { type: String, default: 'home' + }, + versionId: { + type: Number, + default: 0 + }, + versionDate: { + type: String, + default: '' } }, data() { @@ -55,7 +69,10 @@ export default { }, methods: { goLive() { - window.location.assign(`/${this.path}`) + window.location.assign(`/${this.locale}/${this.path}`) + }, + goHistory () { + window.location.assign(`/h/${this.locale}/${this.path}`) } } } diff --git a/dev/build/Dockerfile b/dev/build/Dockerfile index 3ee6e77f..2dddf879 100644 --- a/dev/build/Dockerfile +++ b/dev/build/Dockerfile @@ -1,7 +1,7 @@ # ==================== # --- Build Assets --- # ==================== -FROM node:12.14-alpine AS assets +FROM node:12-alpine AS assets RUN apk add yarn g++ make python --no-cache @@ -23,7 +23,7 @@ RUN yarn --production --frozen-lockfile --non-interactive # =============== # --- Release --- # =============== -FROM node:12.14-alpine +FROM node:12-alpine LABEL maintainer="requarks.io" RUN apk add bash curl git openssh gnupg sqlite --no-cache && \ diff --git a/server/controllers/common.js b/server/controllers/common.js index 5db3f92f..4f822a75 100644 --- a/server/controllers/common.js +++ b/server/controllers/common.js @@ -226,6 +226,8 @@ router.get(['/p', '/p/*'], (req, res, next) => { */ router.get(['/s', '/s/*'], async (req, res, next) => { const pageArgs = pageHelper.parsePath(req.path, { stripExt: true }) + const versionId = (req.query.v) ? _.toSafeInteger(req.query.v) : 0 + const page = await WIKI.models.pages.getPageFromDb({ path: pageArgs.path, locale: pageArgs.locale, @@ -242,14 +244,34 @@ router.get(['/s', '/s/*'], async (req, res, next) => { _.set(res, 'locals.siteConfig.lang', pageArgs.locale) _.set(res, 'locals.siteConfig.rtl', req.i18n.dir() === 'rtl') - if (!WIKI.auth.checkAccess(req.user, ['read:source'], pageArgs)) { - return res.render('unauthorized', { action: 'source' }) + if (versionId > 0) { + if (!WIKI.auth.checkAccess(req.user, ['read:history'], pageArgs)) { + _.set(res.locals, 'pageMeta.title', 'Unauthorized') + return res.render('unauthorized', { action: 'sourceVersion' }) + } + } else { + if (!WIKI.auth.checkAccess(req.user, ['read:source'], pageArgs)) { + _.set(res.locals, 'pageMeta.title', 'Unauthorized') + return res.render('unauthorized', { action: 'source' }) + } } if (page) { - _.set(res.locals, 'pageMeta.title', page.title) - _.set(res.locals, 'pageMeta.description', page.description) - res.render('source', { page }) + if (versionId > 0) { + const pageVersion = await WIKI.models.pageHistory.getVersion({ pageId: page.id, versionId }) + _.set(res.locals, 'pageMeta.title', pageVersion.title) + _.set(res.locals, 'pageMeta.description', pageVersion.description) + res.render('source', { + page: { + ...page, + ...pageVersion + } + }) + } else { + _.set(res.locals, 'pageMeta.title', page.title) + _.set(res.locals, 'pageMeta.description', page.description) + res.render('source', { page }) + } } else { res.redirect(`/${pageArgs.path}`) } diff --git a/server/db/migrations-sqlite/2.2.17.js b/server/db/migrations-sqlite/2.2.17.js new file mode 100644 index 00000000..7ee13f4c --- /dev/null +++ b/server/db/migrations-sqlite/2.2.17.js @@ -0,0 +1,9 @@ +exports.up = knex => { + return knex.schema + .alterTable('pageHistory', table => { + table.string('versionDate').notNullable().defaultTo('') + }) + .raw(`UPDATE pageHistory AS h1 INNER JOIN pageHistory AS h2 ON h2.id = (SELECT prev.id FROM pageHistory AS prev WHERE prev.pageId = h1.pageId AND prev.id < h1.id ORDER BY prev.id DESC LIMIT 1) SET h1.versionDate = h2.createdAt`) +} + +exports.down = knex => { } diff --git a/server/db/migrations/2.2.17.js b/server/db/migrations/2.2.17.js new file mode 100644 index 00000000..d9702e8b --- /dev/null +++ b/server/db/migrations/2.2.17.js @@ -0,0 +1,22 @@ +/* global WIKI */ + +exports.up = knex => { + let sqlVersionDate = '' + switch (WIKI.config.db.type) { + case 'postgres': + case 'mssql': + sqlVersionDate = 'UPDATE "pageHistory" h1 SET "versionDate" = COALESCE((SELECT prev."createdAt" FROM "pageHistory" prev WHERE prev."pageId" = h1."pageId" AND prev.id < h1.id ORDER BY prev.id DESC LIMIT 1), h1.createdAt)' + break + case 'mysql': + case 'mariadb': + sqlVersionDate = `UPDATE pageHistory AS h1 INNER JOIN pageHistory AS h2 ON h2.id = (SELECT prev.id FROM pageHistory AS prev WHERE prev.pageId = h1.pageId AND prev.id < h1.id ORDER BY prev.id DESC LIMIT 1) SET h1.versionDate = h2.createdAt` + break + } + return knex.schema + .alterTable('pageHistory', table => { + table.string('versionDate').notNullable().defaultTo('') + }) + .raw(sqlVersionDate) +} + +exports.down = knex => { } diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index bebdbb41..f17daa01 100644 --- a/server/graph/schemas/page.graphql +++ b/server/graph/schemas/page.graphql @@ -183,12 +183,12 @@ type PageTag { type PageHistory { versionId: Int! + versionDate: Date! authorId: Int! authorName: String! actionType: String! valueBefore: String valueAfter: String - createdAt: Date! } type PageVersion { @@ -198,6 +198,7 @@ type PageVersion { content: String! contentType: String! createdAt: Date! + versionDate: Date! description: String! editor: String! isPrivate: Boolean! diff --git a/server/models/pageHistory.js b/server/models/pageHistory.js index c9c903d4..c9d2f3ae 100644 --- a/server/models/pageHistory.js +++ b/server/models/pageHistory.js @@ -100,7 +100,8 @@ module.exports = class PageHistory extends Model { publishEndDate: opts.publishEndDate || '', publishStartDate: opts.publishStartDate || '', title: opts.title, - action: opts.action || 'updated' + action: opts.action || 'updated', + versionDate: opts.versionDate }) } @@ -120,6 +121,7 @@ module.exports = class PageHistory extends Model { 'pageHistory.action', 'pageHistory.authorId', 'pageHistory.pageId', + 'pageHistory.versionDate', { versionId: 'pageHistory.id', editor: 'pageHistory.editorKey', @@ -146,7 +148,7 @@ module.exports = class PageHistory extends Model { 'pageHistory.path', 'pageHistory.authorId', 'pageHistory.action', - 'pageHistory.createdAt', + 'pageHistory.versionDate', { authorName: 'author.name' } @@ -155,7 +157,7 @@ module.exports = class PageHistory extends Model { .where({ 'pageHistory.pageId': pageId }) - .orderBy('pageHistory.createdAt', 'desc') + .orderBy('pageHistory.versionDate', 'desc') .page(offsetPage, offsetSize) let prevPh = null @@ -168,7 +170,7 @@ module.exports = class PageHistory extends Model { 'pageHistory.path', 'pageHistory.authorId', 'pageHistory.action', - 'pageHistory.createdAt', + 'pageHistory.versionDate', { authorName: 'author.name' } @@ -177,7 +179,7 @@ module.exports = class PageHistory extends Model { .where({ 'pageHistory.pageId': pageId }) - .orderBy('pageHistory.createdAt', 'desc') + .orderBy('pageHistory.versionDate', 'desc') .offset((offsetPage + 1) * offsetSize) .limit(1) .first() @@ -204,7 +206,7 @@ module.exports = class PageHistory extends Model { actionType, valueBefore, valueAfter, - createdAt: ph.createdAt + versionDate: ph.versionDate }) prevPh = ph diff --git a/server/views/history.pug b/server/views/history.pug index 2380f9f5..9da62934 100644 --- a/server/views/history.pug +++ b/server/views/history.pug @@ -8,5 +8,13 @@ block body :page-id=page.id locale=page.localeCode path=page.path + title=page.title + description=page.description + :tags=page.tags + created-at=page.createdAt + updated-at=page.updatedAt + author-name=page.authorName + :author-id=page.authorId + :is-published=page.isPublished.toString() live-content=page.content ) diff --git a/server/views/source.pug b/server/views/source.pug index f0380b81..657bfad2 100644 --- a/server/views/source.pug +++ b/server/views/source.pug @@ -8,4 +8,6 @@ block body :page-id=page.id locale=page.localeCode path=page.path + :version-id=page.versionId + version-date=page.versionDate )= page.content