feat: edit page properties + update dependencies

pull/6078/head
Nicolas Giard 2 years ago
parent 274f3f4a0a
commit f05a73dccb
No known key found for this signature in database
GPG Key ID: 85061B8F9D55B7C8

@ -295,28 +295,28 @@ export default {
$content: String!
$description: String!
$editor: String!
$isPublished: Boolean!
$publishState: PagePublishState!
$locale: String!
$path: String!
$publishEndDate: Date
$publishStartDate: Date
$scriptCss: String
$scriptJs: String
$scriptJsLoad: String
$siteId: UUID!
$tags: [String]!
$tags: [String!]
$title: String!
) {
createPage(
content: $content
description: $description
editor: $editor
isPublished: $isPublished
publishState: $publishState
locale: $locale
path: $path
publishEndDate: $publishEndDate
publishStartDate: $publishStartDate
scriptCss: $scriptCss
scriptJs: $scriptJs
scriptJsLoad: $scriptJsLoad
siteId: $siteId
tags: $tags
title: $title
@ -337,12 +337,12 @@ export default {
description: this.$store.get('page/description'),
editor: this.$store.get('editor/editorKey'),
locale: this.$store.get('page/locale'),
isPublished: this.$store.get('page/isPublished'),
publishState: this.$store.get('page/isPublished') ? 'published' : 'draft',
path: this.$store.get('page/path'),
publishEndDate: this.$store.get('page/publishEndDate') || '',
publishStartDate: this.$store.get('page/publishStartDate') || '',
scriptCss: this.$store.get('page/scriptCss'),
scriptJs: this.$store.get('page/scriptJs'),
scriptJsLoad: this.$store.get('page/scriptJs'),
siteId: this.$store.get('site/id'),
tags: this.$store.get('page/tags'),
title: this.$store.get('page/title')
@ -392,33 +392,11 @@ export default {
mutation: gql`
mutation (
$id: UUID!
$content: String
$description: String
$editor: String
$isPublished: Boolean
$locale: String
$path: String
$publishEndDate: Date
$publishStartDate: Date
$scriptCss: String
$scriptJs: String
$tags: [String]
$title: String
$patch: PageUpdateInput!
) {
updatePage(
id: $id
content: $content
description: $description
editor: $editor
isPublished: $isPublished
locale: $locale
path: $path
publishEndDate: $publishEndDate
publishStartDate: $publishStartDate
scriptCss: $scriptCss
scriptJs: $scriptJs
tags: $tags
title: $title
patch: $patch
) {
operation {
succeeded
@ -432,18 +410,19 @@ export default {
`,
variables: {
id: this.$store.get('page/id'),
content: this.$store.get('editor/content'),
description: this.$store.get('page/description'),
editor: this.$store.get('editor/editorKey'),
locale: this.$store.get('page/locale'),
isPublished: this.$store.get('page/isPublished'),
path: this.$store.get('page/path'),
publishEndDate: this.$store.get('page/publishEndDate') || '',
publishStartDate: this.$store.get('page/publishStartDate') || '',
scriptCss: this.$store.get('page/scriptCss'),
scriptJs: this.$store.get('page/scriptJs'),
tags: this.$store.get('page/tags'),
title: this.$store.get('page/title')
patch: {
content: this.$store.get('editor/content'),
description: this.$store.get('page/description'),
locale: this.$store.get('page/locale'),
publishState: this.$store.get('page/isPublished') ? 'published' : 'draft',
path: this.$store.get('page/path'),
publishEndDate: this.$store.get('page/publishEndDate') || '',
publishStartDate: this.$store.get('page/publishStartDate') || '',
scriptCss: this.$store.get('page/scriptCss'),
scriptJsLoad: this.$store.get('page/scriptJs'),
tags: this.$store.get('page/tags'),
title: this.$store.get('page/title')
}
}
})
resp = _.get(resp, 'data.updatePage', {})

@ -10,6 +10,7 @@ exports.up = async knex => {
// =====================================
// PG EXTENSIONS
// =====================================
await knex.raw('CREATE EXTENSION IF NOT EXISTS ltree;')
await knex.raw('CREATE EXTENSION IF NOT EXISTS pgcrypto;')
await knex.schema
@ -187,21 +188,27 @@ exports.up = async knex => {
.createTable('pageHistory', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.uuid('pageId').notNullable().index()
table.string('action').defaultTo('updated')
table.jsonb('affectedFields').notNullable().defaultTo('[]')
table.string('path').notNullable()
table.string('hash').notNullable()
table.string('alias')
table.string('title').notNullable()
table.string('description')
table.string('icon')
table.enu('publishState', ['draft', 'published', 'scheduled']).notNullable().defaultTo('draft')
table.timestamp('publishStartDate')
table.timestamp('publishEndDate')
table.string('action').defaultTo('updated')
table.jsonb('config').notNullable().defaultTo('{}')
table.jsonb('relations').notNullable().defaultTo('[]')
table.text('content')
table.text('render')
table.jsonb('toc')
table.string('editor').notNullable()
table.string('contentType').notNullable()
table.jsonb('extra').notNullable().defaultTo('{}')
table.jsonb('tags').defaultTo('[]')
table.timestamp('versionDate').notNullable().defaultTo(knex.fn.now())
table.jsonb('scripts').notNullable().defaultTo('{}')
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('versionDate').notNullable().defaultTo(knex.fn.now())
})
// PAGE LINKS --------------------------
.createTable('pageLinks', table => {
@ -212,32 +219,32 @@ exports.up = async knex => {
// PAGES -------------------------------
.createTable('pages', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.string('slug')
table.string('path').notNullable()
table.specificType('dotPath', 'ltree').notNullable().index()
table.string('hash').notNullable()
table.string('alias')
table.string('title').notNullable()
table.string('description')
table.string('icon')
table.enu('publishState', ['draft', 'published', 'scheduled']).notNullable().defaultTo('draft')
table.timestamp('publishStartDate')
table.timestamp('publishEndDate')
table.jsonb('config').notNullable().defaultTo('{}')
table.jsonb('relations').notNullable().defaultTo('[]')
table.text('content')
table.text('render')
table.jsonb('toc')
table.string('editor').notNullable()
table.string('contentType').notNullable()
table.jsonb('extra').notNullable().defaultTo('{}')
table.boolean('isBrowsable').notNullable().defaultTo(true)
table.string('password')
table.integer('ratingScore').notNullable().defaultTo(0)
table.integer('ratingCount').notNullable().defaultTo(0)
table.jsonb('scripts').notNullable().defaultTo('{}')
table.jsonb('historyData').notNullable().defaultTo('{}')
table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())
})
// PAGE TREE ---------------------------
.createTable('pageTree', table => {
table.integer('id').unsigned().primary()
table.string('path').notNullable()
table.integer('depth').unsigned().notNullable()
table.string('title').notNullable()
table.boolean('isFolder').notNullable().defaultTo(false)
table.jsonb('ancestors')
})
// RENDERERS ---------------------------
.createTable('renderers', table => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
@ -365,11 +372,6 @@ exports.up = async knex => {
table.uuid('creatorId').notNullable().references('id').inTable('users').index()
table.uuid('siteId').notNullable().references('id').inTable('sites').index()
})
.table('pageTree', table => {
table.integer('parent').unsigned().references('id').inTable('pageTree').onDelete('CASCADE')
table.uuid('pageId').notNullable().references('id').inTable('pages').onDelete('CASCADE')
table.string('localeCode', 5).references('code').inTable('locales')
})
.table('storage', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
@ -507,7 +509,11 @@ exports.up = async knex => {
defaults: {
timezone: 'America/New_York',
dateFormat: 'YYYY-MM-DD',
timeFormat: '12h'
timeFormat: '12h',
tocDepth: {
min: 1,
max: 2
}
},
features: {
ratings: false,

@ -143,7 +143,7 @@ module.exports = {
* FETCH SINGLE PAGE BY ID
*/
async pageById (obj, args, context, info) {
let page = await WIKI.db.pages.getPageFromDb(args.id)
const page = await WIKI.db.pages.getPageFromDb(args.id)
if (page) {
if (WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
path: page.path,
@ -151,30 +151,37 @@ module.exports = {
})) {
return {
...page,
locale: page.localeCode,
editor: page.editorKey
...page.config,
scriptCss: page.scripts?.css,
scriptJsLoad: page.scripts?.jsLoad,
scriptJsUnload: page.scripts?.jsUnload,
locale: page.localeCode
}
} else {
throw new WIKI.Error.PageViewForbidden()
throw new Error('ERR_FORBIDDEN')
}
} else {
throw new WIKI.Error.PageNotFound()
throw new Error('ERR_PAGE_NOT_FOUND')
}
},
/**
* FETCH SINGLE PAGE BY PATH
*/
async pageByPath (obj, args, context, info) {
// console.info(info)
const pageArgs = pageHelper.parsePath(args.path)
let page = await WIKI.db.pages.getPageFromDb({
const page = await WIKI.db.pages.getPageFromDb({
...pageArgs,
siteId: args.siteId
})
if (page) {
return {
...page,
locale: page.localeCode,
editor: page.editorKey
...page.config,
scriptCss: page.scripts?.css,
scriptJsLoad: page.scripts?.jsLoad,
scriptJsUnload: page.scripts?.jsUnload,
locale: page.localeCode
}
} else {
throw new Error('ERR_PAGE_NOT_FOUND')
@ -607,8 +614,20 @@ module.exports = {
}
},
Page: {
icon (obj) {
return obj.icon || 'las la-file-alt'
},
password (obj) {
return obj ? '********' : ''
},
async tags (obj) {
return WIKI.db.pages.relatedQuery('tags').for(obj.id)
},
tocDepth (obj) {
return {
min: obj.extra?.tocDepth?.min ?? 1,
max: obj.extra?.tocDepth?.max ?? 2
}
}
// comments(pg) {
// return pg.$relatedQuery('comments')

@ -32,11 +32,13 @@ extend type Query {
pageById(
id: UUID!
password: String
): Page
pageByPath(
siteId: UUID!
path: String!
password: String
): Page
tags: [PageTag]!
@ -69,35 +71,35 @@ extend type Query {
extend type Mutation {
createPage(
siteId: UUID!
allowComments: Boolean
allowContributions: Boolean
allowRatings: Boolean
content: String!
description: String!
editor: String!
isPublished: Boolean!
icon: String
isBrowsable: Boolean
locale: String!
path: String!
publishState: PagePublishState!
publishEndDate: Date
publishStartDate: Date
relations: [PageRelationInput!]
scriptCss: String
scriptJs: String
tags: [String]!
scriptJsLoad: String
scriptJsUnload: String
showSidebar: Boolean
showTags: Boolean
showToc: Boolean
siteId: UUID!
tags: [String!]
title: String!
tocDepth: PageTocDepthInput
): PageResponse
updatePage(
id: UUID!
content: String
description: String
editor: String
isPublished: Boolean
locale: String
path: String
publishEndDate: Date
publishStartDate: Date
scriptCss: String
scriptJs: String
tags: [String]
title: String
patch: PageUpdateInput!
): PageResponse
convertPage(
@ -163,31 +165,40 @@ type PageMigrationResponse {
}
type Page {
id: UUID
path: String
hash: String
title: String
description: String
isPublished: Boolean
publishStartDate: Date
publishEndDate: Date
tags: [PageTag]
allowComments: Boolean
allowContributions: Boolean
allowRatings: Boolean
author: User
content: String
render: String
toc: [JSON]
contentType: String
createdAt: Date
updatedAt: Date
creator: User
description: String
editor: String
hash: String
icon: String
id: UUID
isBrowsable: Boolean
locale: String
password: String
path: String
publishEndDate: Date
publishStartDate: Date
publishState: PagePublishState
relations: [PageRelation]
render: String
scriptJsLoad: String
scriptJsUnload: String
scriptCss: String
scriptJs: String
authorId: Int
authorName: String
authorEmail: String
creatorId: Int
creatorName: String
creatorEmail: String
showSidebar: Boolean
showTags: Boolean
showToc: Boolean
siteId: UUID
tags: [PageTag]
title: String
toc: [JSON]
tocDepth: PageTocDepth
updatedAt: Date
}
type PageTag {
@ -299,6 +310,59 @@ type PageConflictLatest {
updatedAt: Date
}
type PageRelation {
id: UUID
position: PageRelationPosition
label: String
caption: String
icon: String
target: String
}
input PageRelationInput {
id: UUID!
position: PageRelationPosition!
label: String!
caption: String
icon: String
target: String!
}
input PageUpdateInput {
allowComments: Boolean
allowContributions: Boolean
allowRatings: Boolean
content: String
description: String
icon: String
isBrowsable: Boolean
locale: String
password: String
path: String
publishEndDate: Date
publishStartDate: Date
publishState: PagePublishState
relations: [PageRelationInput!]
scriptJsLoad: String
scriptJsUnload: String
scriptCss: String
showSidebar: Boolean
showTags: Boolean
showToc: Boolean
tags: [String!]
title: String
tocDepth: PageTocDepthInput
}
type PageTocDepth {
min: Int
max: Int
}
input PageTocDepthInput {
min: Int!
max: Int!
}
enum PageOrderBy {
CREATED
ID
@ -317,3 +381,15 @@ enum PageTreeMode {
PAGES
ALL
}
enum PagePublishState {
draft
published
scheduled
}
enum PageRelationPosition {
left
center
right
}

@ -89,6 +89,7 @@ type SiteDefaults {
timezone: String
dateFormat: String
timeFormat: String
tocDepth: PageTocDepth
}
type SiteLocale {
@ -174,6 +175,7 @@ input SiteDefaultsInput {
timezone: String
dateFormat: String
timeFormat: String
tocDepth: PageTocDepthInput
}
input SiteThemeInput {

@ -13,6 +13,8 @@ const TurndownService = require('turndown')
const turndownPluginGfm = require('@joplin/turndown-plugin-gfm').gfm
const cheerio = require('cheerio')
const pageRegex = /^[a-zA0-90-9-_/]*$/
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,
@ -52,7 +54,7 @@ module.exports = class Page extends Model {
}
static get jsonAttributes() {
return ['extra']
return ['config', 'historyData', 'relations', 'scripts', 'toc']
}
static get relationMappings() {
@ -231,11 +233,6 @@ module.exports = class Page extends Model {
throw new WIKI.Error.Custom('InvalidSiteId', 'Site ID is invalid.')
}
// -> 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)
@ -246,6 +243,14 @@ module.exports = class Page extends Model {
opts.path = opts.path.slice(1)
}
// -> Validate path
if (!pageRegex.test(opts.path)) {
throw new Error('ERR_INVALID_PATH')
}
opts.path = opts.path.toLowerCase()
const dotPath = opts.path.replaceAll('/', '.').replaceAll('-', '_')
// -> Check for page access
if (!WIKI.auth.checkAccess(opts.user, ['write:pages'], {
locale: opts.locale,
@ -279,41 +284,52 @@ module.exports = class Page extends Model {
}
// -> Format JS Scripts
let scriptJs = ''
let scriptJsLoad = ''
let scriptJsUnload = ''
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
locale: opts.locale,
path: opts.path
})) {
scriptJs = opts.scriptJs || ''
scriptJsLoad = opts.scriptJsLoad || ''
scriptJsUnload = opts.scriptJsUnload || ''
}
// -> Create page
await WIKI.db.pages.query().insert({
const page = await WIKI.db.pages.query().insert({
authorId: opts.user.id,
content: opts.content,
creatorId: opts.user.id,
contentType: _.get(WIKI.data.editors[opts.editor], 'contentType', 'text'),
config: {
allowComments: opts.allowComments ?? true,
allowContributions: opts.allowContributions ?? true,
allowRatings: opts.allowRatings ?? true,
showSidebar: opts.showSidebar ?? true,
showTags: opts.showTags ?? true,
showToc: opts.showToc ?? true,
tocDepth: opts.tocDepth ?? WIKI.sites[opts.siteId].config?.defaults.tocDepth
},
contentType: WIKI.data.editors[opts.editor]?.contentType ?? 'text',
description: opts.description,
dotPath: dotPath,
editor: opts.editor,
hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
publishState: opts.publishState,
icon: opts.icon,
isBrowsable: opts.isBrowsable ?? true,
localeCode: opts.locale,
path: opts.path,
publishState: opts.publishState,
publishEndDate: opts.publishEndDate?.toISO(),
publishStartDate: opts.publishStartDate?.toISO(),
relations: opts.relations ?? [],
siteId: opts.siteId,
title: opts.title,
toc: '[]',
extra: JSON.stringify({
js: scriptJs,
scripts: JSON.stringify({
jsLoad: scriptJsLoad,
jsUnload: scriptJsUnload,
css: scriptCss
})
})
const page = await WIKI.db.pages.getPageFromDb({
path: opts.path,
locale: opts.locale,
userId: opts.user.id
})
}).returning('*')
// -> Save Tags
if (opts.tags && opts.tags.length > 0) {
@ -365,7 +381,7 @@ module.exports = class Page extends Model {
// -> Fetch original page
const ogPage = await WIKI.db.pages.query().findById(opts.id)
if (!ogPage) {
throw new Error('Invalid Page Id')
throw new Error('ERR_PAGE_NOT_FOUND')
}
// -> Check for page access
@ -373,70 +389,205 @@ module.exports = class Page extends Model {
locale: ogPage.localeCode,
path: ogPage.path
})) {
throw new WIKI.Error.PageUpdateForbidden()
throw new Error('ERR_PAGE_UPDATE_FORBIDDEN')
}
// -> Check for empty content
if (!opts.content || _.trim(opts.content).length < 1) {
throw new WIKI.Error.PageEmptyContent()
const patch = {}
const historyData = {
action: 'updated',
affectedFields: []
}
// -> Create version snapshot
await WIKI.db.pageHistory.addVersion({
...ogPage,
action: opts.action ? opts.action : 'updated',
versionDate: ogPage.updatedAt
})
await WIKI.db.pageHistory.addVersion(ogPage)
// -> Basic fields
if ('title' in opts.patch) {
patch.title = opts.patch.title.trim()
historyData.affectedFields.push('title')
// -> Format Extra Properties
if (!_.isPlainObject(ogPage.extra)) {
ogPage.extra = {}
if (patch.title.length < 1) {
throw new Error('ERR_PAGE_TITLE_MISSING')
}
}
if ('description' in opts.patch) {
patch.description = opts.patch.description.trim()
historyData.affectedFields.push('description')
}
if ('icon' in opts.patch) {
patch.icon = opts.patch.icon.trim()
historyData.affectedFields.push('icon')
}
if ('content' in opts.patch) {
patch.content = opts.patch.content
historyData.affectedFields.push('content')
}
// -> Publish State
if (opts.patch.publishState) {
patch.publishState = opts.patch.publishState
historyData.affectedFields.push('publishState')
if (patch.publishState === 'scheduled' && (!opts.patch.publishStartDate || !opts.patch.publishEndDate)) {
throw new Error('ERR_PAGE_MISSING_SCHEDULED_DATES')
}
}
if (opts.patch.publishStartDate) {
patch.publishStartDate = opts.patch.publishStartDate
historyData.affectedFields.push('publishStartDate')
}
if (opts.patch.publishEndDate) {
patch.publishEndDate = opts.patch.publishEndDate
historyData.affectedFields.push('publishEndDate')
}
// -> Page Config
if ('isBrowsable' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
isBrowsable: opts.patch.isBrowsable
}
historyData.affectedFields.push('isBrowsable')
}
if ('allowComments' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
allowComments: opts.patch.allowComments
}
historyData.affectedFields.push('allowComments')
}
if ('allowContributions' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
allowContributions: opts.patch.allowContributions
}
historyData.affectedFields.push('allowContributions')
}
if ('allowRatings' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
allowRatings: opts.patch.allowRatings
}
historyData.affectedFields.push('allowRatings')
}
if ('showSidebar' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
showSidebar: opts.patch.showSidebar
}
historyData.affectedFields.push('showSidebar')
}
if ('showTags' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
showTags: opts.patch.showTags
}
historyData.affectedFields.push('showTags')
}
if ('showToc' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
showToc: opts.patch.showToc
}
historyData.affectedFields.push('showToc')
}
if ('tocDepth' in opts.patch) {
patch.config = {
...patch.config ?? ogPage.config ?? {},
tocDepth: opts.patch.tocDepth
}
historyData.affectedFields.push('tocDepth')
if (patch.config.tocDepth?.min < 1 || patch.config.tocDepth?.min > 6) {
throw new Error('ERR_PAGE_INVALID_TOC_DEPTH')
}
if (patch.config.tocDepth?.max < 1 || patch.config.tocDepth?.max > 6) {
throw new Error('ERR_PAGE_INVALID_TOC_DEPTH')
}
}
// -> Relations
if ('relations' in opts.patch) {
patch.relations = opts.patch.relations.map(r => {
if (r.label.length < 1) {
throw new Error('ERR_PAGE_RELATION_LABEL_MISSING')
} else if (r.label.length > 255) {
throw new Error('ERR_PAGE_RELATION_LABEL_TOOLONG')
} else if (r.icon.length > 255) {
throw new Error('ERR_PAGE_RELATION_ICON_INVALID')
} else if (r.target.length > 1024) {
throw new Error('ERR_PAGE_RELATION_TARGET_INVALID')
}
return r
})
historyData.affectedFields.push('relations')
}
// -> 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 = ''
if (opts.patch.scriptCss) {
if (WIKI.auth.checkAccess(opts.user, ['write:styles'], {
locale: ogPage.localeCode,
path: ogPage.path
})) {
patch.scripts = {
...patch.scripts ?? ogPage.scripts ?? {},
css: !_.isEmpty(opts.patch.scriptCss) ? new CleanCSS({ inline: false }).minify(opts.patch.scriptCss).styles : ''
}
historyData.affectedFields.push('scripts.css')
}
}
// -> 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 || ''
if (opts.patch.scriptJsLoad) {
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
locale: ogPage.localeCode,
path: ogPage.path
})) {
patch.scripts = {
...patch.scripts ?? ogPage.scripts ?? {},
jsLoad: opts.patch.scriptJsLoad ?? ''
}
historyData.affectedFields.push('scripts.jsLoad')
}
}
if (opts.patch.scriptJsUnload) {
if (WIKI.auth.checkAccess(opts.user, ['write:scripts'], {
locale: ogPage.localeCode,
path: ogPage.path
})) {
patch.scripts = {
...patch.scripts ?? ogPage.scripts ?? {},
jsUnload: opts.patch.scriptJsUnload ?? ''
}
historyData.affectedFields.push('scripts.jsUnload')
}
}
// -> Tags
if ('tags' in opts.patch) {
historyData.affectedFields.push('tags')
}
// -> Update page
await WIKI.db.pages.query().patch({
...patch,
authorId: opts.user.id,
content: opts.content,
description: opts.description,
publishState: opts.publishState,
publishEndDate: opts.publishEndDate?.toISO(),
publishStartDate: opts.publishStartDate?.toISO(),
title: opts.title,
extra: JSON.stringify({
...ogPage.extra,
js: scriptJs,
css: scriptCss
})
historyData
}).where('id', ogPage.id)
let page = await WIKI.db.pages.getPageFromDb(ogPage.id)
// -> Save Tags
await WIKI.db.tags.associateTags({ tags: opts.tags, page })
if (opts.patch.tags) {
await WIKI.db.tags.associateTags({ tags: opts.patch.tags, page })
}
// -> Render page to HTML
await WIKI.db.pages.renderPage(page)
if (opts.patch.content) {
await WIKI.db.pages.renderPage(page)
}
WIKI.events.outbound.emit('deletePageFromCache', page.hash)
// // -> Update Search Index
@ -468,11 +619,6 @@ module.exports = class Page extends Model {
destinationPath: opts.path,
user: opts.user
})
} else {
// -> Update title of page tree entry
await WIKI.db.knex.table('pageTree').where({
pageId: page.id
}).update('title', page.title)
}
// -> Get latest updatedAt
@ -944,6 +1090,8 @@ module.exports = class Page extends Model {
* @returns {Promise} Promise of the Page Model Instance
*/
static async getPage(opts) {
return WIKI.db.pages.getPageFromDb(opts)
// -> Get from cache first
let page = await WIKI.db.pages.getPageFromCache(opts)
if (!page) {
@ -974,26 +1122,7 @@ module.exports = class Page extends Model {
try {
return WIKI.db.pages.query()
.column([
'pages.id',
'pages.path',
'pages.hash',
'pages.title',
'pages.description',
'pages.publishState',
'pages.publishStartDate',
'pages.publishEndDate',
'pages.content',
'pages.render',
'pages.toc',
'pages.contentType',
'pages.createdAt',
'pages.updatedAt',
'pages.editor',
'pages.localeCode',
'pages.authorId',
'pages.creatorId',
'pages.siteId',
'pages.extra',
'pages.*',
{
authorName: 'author.name',
authorEmail: 'author.email',

@ -11,7 +11,7 @@
"lint": "eslint --ext .js,.vue ./"
},
"dependencies": {
"@apollo/client": "3.6.9",
"@apollo/client": "3.7.1",
"@codemirror/autocomplete": "6.0.2",
"@codemirror/basic-setup": "0.20.0",
"@codemirror/closebrackets": "0.19.2",
@ -31,8 +31,8 @@
"@codemirror/state": "6.0.1",
"@codemirror/tooltip": "0.19.16",
"@codemirror/view": "6.0.2",
"@lezer/common": "1.0.0",
"@quasar/extras": "1.15.1",
"@lezer/common": "1.0.1",
"@quasar/extras": "1.15.5",
"@tiptap/core": "2.0.0-beta.176",
"@tiptap/extension-code-block": "2.0.0-beta.37",
"@tiptap/extension-code-block-lowlight": "2.0.0-beta.68",
@ -58,45 +58,45 @@
"@tiptap/starter-kit": "2.0.0-beta.185",
"@tiptap/vue-3": "2.0.0-beta.91",
"apollo-upload-client": "17.0.0",
"browser-fs-access": "0.31.0",
"browser-fs-access": "0.31.1",
"clipboard": "2.0.11",
"codemirror": "6.0.1",
"filesize": "9.0.11",
"filesize": "10.0.5",
"filesize-parser": "1.5.0",
"graphql": "16.6.0",
"graphql-tag": "2.12.6",
"js-cookie": "3.0.1",
"jwt-decode": "3.1.2",
"lodash-es": "4.17.21",
"luxon": "3.0.1",
"pinia": "2.0.20",
"luxon": "3.1.0",
"pinia": "2.0.23",
"pug": "3.0.2",
"quasar": "2.7.7",
"socket.io-client": "4.5.2",
"quasar": "2.10.1",
"socket.io-client": "4.5.3",
"tippy.js": "6.3.7",
"uuid": "8.3.2",
"v-network-graph": "0.6.6",
"vue": "3.2.37",
"vue-codemirror": "6.0.2",
"uuid": "9.0.0",
"v-network-graph": "0.6.10",
"vue": "3.2.41",
"vue-codemirror": "6.1.1",
"vue-i18n": "9.2.2",
"vue-router": "4.1.3",
"vue-router": "4.1.6",
"vue3-otp-input": "0.3.6",
"vuedraggable": "4.1.0",
"xterm": "4.19.0",
"xterm": "5.0.0",
"zxcvbn": "4.4.2"
},
"devDependencies": {
"@intlify/vite-plugin-vue-i18n": "6.0.1",
"@quasar/app-vite": "1.0.6",
"@types/lodash": "4.14.184",
"@volar/vue-language-plugin-pug": "1.0.1",
"@intlify/vite-plugin-vue-i18n": "6.0.3",
"@quasar/app-vite": "1.1.3",
"@types/lodash": "4.14.188",
"@volar/vue-language-plugin-pug": "1.0.9",
"browserlist": "latest",
"eslint": "8.22.0",
"eslint": "8.27.0",
"eslint-config-standard": "17.0.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-n": "15.2.4",
"eslint-plugin-promise": "6.0.0",
"eslint-plugin-vue": "9.3.0"
"eslint-plugin-n": "15.5.0",
"eslint-plugin-promise": "6.1.1",
"eslint-plugin-vue": "9.7.0"
},
"engines": {
"node": "^18 || ^16",

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M10.5 1.5H28.5V4.5H10.5z"/><path fill="#4788c7" d="M28,2v2H11V2H28 M29,1H10v4h19V1L29,1z"/><path fill="#b6dcfe" d="M1.5 34.5H37.5V37.5H1.5z"/><path fill="#4788c7" d="M37 35v2H2v-2H37M38 34H1v4h37V34L38 34zM37.5 32A.5.5 0 1 0 37.5 33 .5.5 0 1 0 37.5 32zM36.808 30A.5.5 0 1 0 36.808 31 .5.5 0 1 0 36.808 30zM36.115 28A.5.5 0 1 0 36.115 29 .5.5 0 1 0 36.115 28zM35.423 26A.5.5 0 1 0 35.423 27 .5.5 0 1 0 35.423 26zM34.731 24A.5.5 0 1 0 34.731 25 .5.5 0 1 0 34.731 24zM34.038 22A.5.5 0 1 0 34.038 23 .5.5 0 1 0 34.038 22zM33.346 20A.5.5 0 1 0 33.346 21 .5.5 0 1 0 33.346 20zM32.654 18A.5.5 0 1 0 32.654 19 .5.5 0 1 0 32.654 18zM31.962 16A.5.5 0 1 0 31.962 17 .5.5 0 1 0 31.962 16zM31.269 14A.5.5 0 1 0 31.269 15 .5.5 0 1 0 31.269 14zM30.577 12A.5.5 0 1 0 30.577 13 .5.5 0 1 0 30.577 12zM29.885 10A.5.5 0 1 0 29.885 11 .5.5 0 1 0 29.885 10zM29.192 8A.5.5 0 1 0 29.192 9 .5.5 0 1 0 29.192 8zM28.5 6A.5.5 0 1 0 28.5 7 .5.5 0 1 0 28.5 6z"/><g><path fill="#4788c7" d="M1.5 32A.5.5 0 1 0 1.5 33 .5.5 0 1 0 1.5 32zM2.192 30A.5.5 0 1 0 2.192 31 .5.5 0 1 0 2.192 30zM2.885 28A.5.5 0 1 0 2.885 29 .5.5 0 1 0 2.885 28zM3.577 26A.5.5 0 1 0 3.577 27 .5.5 0 1 0 3.577 26zM4.269 24A.5.5 0 1 0 4.269 25 .5.5 0 1 0 4.269 24zM4.962 22A.5.5 0 1 0 4.962 23 .5.5 0 1 0 4.962 22zM5.654 20A.5.5 0 1 0 5.654 21 .5.5 0 1 0 5.654 20zM6.346 18A.5.5 0 1 0 6.346 19 .5.5 0 1 0 6.346 18zM7.038 16A.5.5 0 1 0 7.038 17 .5.5 0 1 0 7.038 16zM7.731 14A.5.5 0 1 0 7.731 15 .5.5 0 1 0 7.731 14zM8.423 12A.5.5 0 1 0 8.423 13 .5.5 0 1 0 8.423 12zM9.115 10A.5.5 0 1 0 9.115 11 .5.5 0 1 0 9.115 10zM9.808 8A.5.5 0 1 0 9.808 9 .5.5 0 1 0 9.808 8zM10.5 6A.5.5 0 1 0 10.5 7 .5.5 0 1 0 10.5 6z"/></g><g><path fill="#4788c7" d="M21 27L21 19 18 19 18 27 13 27 19.5 34 26 27z"/></g><g><path fill="#4788c7" d="M18 12L18 20 21 20 21 12 26 12 19.5 5 13 12z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@ -81,7 +81,7 @@ q-header.bg-header.text-white.site-header(
round
dense
icon='las la-tools'
color='secondary'
color='positive'
to='/_admin'
aria-label='Administration'
)

@ -41,28 +41,39 @@ q-card.page-properties-dialog
outlined
dense
)
q-input(
v-model='pageStore.icon'
:label='t(`editor.props.icon`)'
outlined
dense
)
template(#append)
q-icon.cursor-pointer(
name='las la-icons'
color='primary'
)
q-card-section.alt-card(id='refCardPublishState')
.text-overline.q-pb-xs.items-center.flex #[q-icon.q-mr-sm(name='las la-power-off', size='xs')] {{t('editor.props.publishState')}}
q-form.q-gutter-md
div
q-btn-toggle(
v-model='pageStore.isPublished'
v-model='pageStore.publishState'
push
glossy
no-caps
toggle-color='primary'
:options=`[
{ label: t('editor.props.draft'), value: false },
{ label: t('editor.props.published'), value: true },
{ label: t('editor.props.dateRange'), value: null }
{ label: t('editor.props.draft'), value: 'draft' },
{ label: t('editor.props.published'), value: 'published' },
{ label: t('editor.props.dateRange'), value: 'scheduled' }
]`
)
.text-caption(v-if='pageStore.isPublished'): em {{t('editor.props.publishedHint')}}
.text-caption(v-else-if='pageStore.isPublished === false'): em {{t('editor.props.draftHint')}}
template(v-else-if='pageStore.isPublished === null')
.text-caption(v-if='pageStore.publishState === `published`'): em {{t('editor.props.publishedHint')}}
.text-caption(v-else-if='pageStore.publishState === `draft`'): em {{t('editor.props.draftHint')}}
template(v-else-if='pageStore.publishState === `scheduled`')
.text-caption: em {{t('editor.props.dateRangeHint')}}
q-date(
v-model='pageStore.publishingRange'
v-model='publishingRange'
range
flat
bordered
@ -230,7 +241,7 @@ q-card.page-properties-dialog
q-form.q-gutter-md.q-pt-sm
div
q-toggle(
v-model='pageStore.showInTree'
v-model='pageStore.isBrowsable'
dense
:label='$t(`editor.props.showInTree`)'
color='primary'
@ -240,6 +251,7 @@ q-card.page-properties-dialog
div
q-toggle(
v-model='state.requirePassword'
@update:model-value='toggleRequirePassword'
dense
:label='$t(`editor.props.requirePassword`)'
color='primary'
@ -252,7 +264,7 @@ q-card.page-properties-dialog
)
q-input(
ref='iptPagePassword'
v-model='state.password'
v-model='pageStore.password'
:label='t(`editor.props.password`)'
:hint='t(`editor.props.passwordHint`)'
outlined
@ -272,7 +284,7 @@ q-card.page-properties-dialog
<script setup>
import { useI18n } from 'vue-i18n'
import { useQuasar } from 'quasar'
import { nextTick, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { DateTime } from 'luxon'
import PageRelationDialog from './PageRelationDialog.vue'
@ -302,9 +314,7 @@ const { t } = useI18n()
const state = reactive({
showRelationDialog: false,
showScriptsDialog: false,
publishingRange: {},
requirePassword: false,
password: '',
editRelationId: null,
pageScriptsMode: 'jsLoad',
showQuickAccess: true
@ -325,19 +335,23 @@ const quickaccess = [
const iptPagePassword = ref(null)
// WATCHERS
// COMPUTED
watch(() => state.requirePassword, (newValue) => {
if (newValue) {
nextTick(() => {
iptPagePassword.value.focus()
iptPagePassword.value.$el.scrollIntoView({
behavior: 'smooth'
})
})
const publishingRange = computed({
get () {
return {
from: pageStore.publishStartDate,
to: pageStore.publishEndDate
}
},
set (newValue) {
pageStore.publishStartDate = newValue?.from
pageStore.publishEndDate = newValue?.to
}
})
// WATCHERS
pageStore.$subscribe(() => {
editorStore.$patch({
lastChangeTimestamp: DateTime.utc()
@ -366,10 +380,24 @@ function jumpToSection (id) {
behavior: 'smooth'
})
}
function toggleRequirePassword (newValue) {
if (newValue) {
nextTick(() => {
iptPagePassword.value.focus()
iptPagePassword.value.$el.scrollIntoView({
behavior: 'smooth'
})
})
} else {
pageStore.password = ''
}
}
// MOUNTED
onMounted(() => {
state.requirePassword = pageStore.password?.length > 0
setTimeout(() => {
state.showQuickAccess = true
}, 300)

@ -1552,5 +1552,8 @@
"profile.avatarUploadSuccess": "Profile picture uploaded successfully.",
"profile.avatarUploadFailed": "Failed to upload user profile picture.",
"profile.avatarClearSuccess": "Profile picture cleared successfully.",
"profile.avatarClearFailed": "Failed to clear profile picture."
"profile.avatarClearFailed": "Failed to clear profile picture.",
"admin.general.defaultTocDepth": "Default ToC Depth",
"admin.general.defaultTocDepthHint": "The default minimum and maximum header levels to show in the table of contents.",
"editor.props.icon": "Icon"
}

@ -371,6 +371,25 @@ q-page.admin-general
toggle-color='primary'
:options='timeFormats'
)
q-separator.q-my-sm(inset)
q-item
blueprint-icon(icon='depth')
q-item-section
q-item-label {{t(`admin.general.defaultTocDepth`)}}
q-item-label(caption) {{t(`admin.general.defaultTocDepthHint`)}}
q-item-section.col-auto.q-pl-sm(style='min-width: 180px;')
.text-caption {{t('editor.props.tocMinMaxDepth')}} #[strong (H{{state.config.defaults.tocDepth.min}} &rarr; H{{state.config.defaults.tocDepth.max}})]
q-range(
v-model='state.config.defaults.tocDepth'
:min='1'
:max='6'
color='primary'
:left-label-value='`H` + state.config.defaults.tocDepth.min'
:right-label-value='`H` + state.config.defaults.tocDepth.max'
snap
label
markers
)
//- -----------------------
//- SEO
@ -479,7 +498,11 @@ const state = reactive({
defaults: {
timezone: '',
dateFormat: '',
timeFormat: ''
timeFormat: '',
tocDepth: {
min: 1,
max: 2
}
},
robots: {
index: false,
@ -573,6 +596,10 @@ async function load () {
timezone
dateFormat
timeFormat
tocDepth {
min
max
}
}
}
}
@ -635,7 +662,11 @@ async function save () {
defaults: {
timezone: state.config.defaults?.timezone ?? 'America/New_York',
dateFormat: state.config.defaults?.dateFormat ?? 'YYYY-MM-DD',
timeFormat: state.config.defaults?.timeFormat ?? '12h'
timeFormat: state.config.defaults?.timeFormat ?? '12h',
tocDepth: {
min: state.config.defaults?.tocDepth?.min ?? 1,
max: state.config.defaults?.tocDepth?.max ?? 2
}
}
}
}

@ -325,7 +325,7 @@ q-page.admin-mail
<script setup>
import { cloneDeep } from 'lodash-es'
import gql from 'graphql-tag'
import filesize from 'filesize'
import { filesize } from 'filesize'
import filesizeParser from 'filesize-parser'
import { useI18n } from 'vue-i18n'

@ -21,7 +21,7 @@ q-page.column
:to='brd.path'
)
.col-auto.flex.items-center.justify-end
template(v-if='!pageStore.isPublished')
template(v-if='!pageStore.publishState === `draft`')
.text-caption.text-accent: strong Unpublished
q-separator.q-mx-sm(vertical)
.text-caption.text-grey-6 Last modified on #[strong {{lastModified}}]
@ -233,6 +233,7 @@ q-page.column
color='deep-orange-9'
aria-label='Page Data'
@click='togglePageData'
disable
)
q-tooltip(anchor='center left' self='center right') Page Data
q-separator.q-my-sm(inset)
@ -519,7 +520,20 @@ async function discardChanges () {
}
async function saveChanges () {
$q.loading.show()
try {
await pageStore.pageSave()
$q.notify({
type: 'positive',
message: 'Page saved successfully.'
})
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to save page changes.'
})
}
$q.loading.hide()
}
</script>

@ -1,11 +1,57 @@
import { defineStore } from 'pinia'
import gql from 'graphql-tag'
import { cloneDeep, last, transform } from 'lodash-es'
import { cloneDeep, last, pick, transform } from 'lodash-es'
import { DateTime } from 'luxon'
import { useSiteStore } from './site'
import { useEditorStore } from './editor'
const pagePropsFragment = gql`
fragment PageRead on Page {
allowComments
allowContributions
allowRatings
contentType
createdAt
description
editor
icon
id
isBrowsable
locale
password
path
publishEndDate
publishStartDate
publishState
relations {
id
position
label
caption
icon
target
}
render
scriptJsLoad
scriptJsUnload
scriptCss
showSidebar
showTags
showToc
tags {
tag
title
}
title
toc
tocDepth {
min
max
}
updatedAt
}
`
const gqlQueries = {
pageById: gql`
query loadPage (
@ -14,16 +60,10 @@ const gqlQueries = {
pageById(
id: $id
) {
id
title
description
path
locale
updatedAt
render
toc
...PageRead
}
}
${pagePropsFragment}
`,
pageByPath: gql`
query loadPage (
@ -34,58 +74,49 @@ const gqlQueries = {
siteId: $siteId
path: $path
) {
id
title
description
path
locale
updatedAt
render
toc
...PageRead
}
}
${pagePropsFragment}
`
}
export const usePageStore = defineStore('page', {
state: () => ({
isLoading: true,
mode: 'view',
editor: 'wysiwyg',
editorMode: 'edit',
id: 0,
allowComments: false,
allowContributions: true,
allowRatings: true,
authorId: 0,
authorName: '',
commentsCount: 0,
content: '',
createdAt: '',
description: '',
isPublished: true,
showInTree: true,
icon: 'las la-file-alt',
id: '',
isBrowsable: true,
locale: 'en',
password: '',
path: '',
publishEndDate: '',
publishStartDate: '',
tags: [],
title: '',
icon: 'las la-file-alt',
updatedAt: '',
publishState: '',
relations: [],
render: '',
scriptJsLoad: '',
scriptJsUnload: '',
scriptStyles: '',
allowComments: false,
allowContributions: true,
allowRatings: true,
scriptCss: '',
showSidebar: true,
showToc: true,
showTags: true,
showToc: true,
tags: [],
title: '',
toc: [],
tocDepth: {
min: 1,
max: 2
},
commentsCount: 0,
content: '',
render: '',
toc: []
updatedAt: ''
}),
getters: {
breadcrumbs: (state) => {
@ -120,7 +151,11 @@ export const usePageStore = defineStore('page', {
throw new Error('ERR_PAGE_NOT_FOUND')
}
// Update page store
this.$patch(pageData)
this.$patch({
...pageData,
relations: pageData.relations.map(r => pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
// Update editor state timestamps
const curDate = DateTime.utc()
editorStore.$patch({
@ -174,6 +209,73 @@ export const usePageStore = defineStore('page', {
// -> View Mode
this.mode = 'edit'
},
/**
* PAGE SAVE
*/
async pageSave () {
const editorStore = useEditorStore()
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation savePage (
$id: UUID!
$patch: PageUpdateInput!
) {
updatePage (
id: $id
patch: $patch
) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: this.id,
patch: pick(this, [
'allowComments',
'allowContributions',
'allowRatings',
// 'content',
'description',
'icon',
'isBrowsable',
'locale',
'password',
'path',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'scriptJsLoad',
'scriptJsUnload',
'scriptCss',
'showSidebar',
'showTags',
'showToc',
'tags',
'title',
'tocDepth'
])
}
})
const result = resp?.data?.updatePage?.operation ?? {}
if (!result.succeeded) {
throw new Error(result.message)
}
// Update editor state timestamps
const curDate = DateTime.utc()
editorStore.$patch({
lastChangeTimestamp: curDate,
lastSaveTimestamp: curDate
})
} catch (err) {
console.warn(err)
throw err
}
},
generateToc () {
}

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save