feat: rendering + new page view

pull/6078/head
Nicolas Giard 2 years ago
parent 055fcc6b72
commit 7128b160dd
No known key found for this signature in database
GPG Key ID: 85061B8F9D55B7C8

@ -33,7 +33,7 @@ defaults:
workers: 3
pollingCheck: 5
scheduledCheck: 300
maxRetries: 5
maxRetries: 2
retryBackoff: 60
historyExpiration: 90000
# DB defaults
@ -83,6 +83,13 @@ defaults:
search:
maxHits: 100
maintainerEmail: security@requarks.io
editors:
code:
contentType: html
markdown:
contentType: markdown
wysiwyg:
contentType: html
groups:
defaultPermissions:
- 'read:pages'

@ -528,9 +528,9 @@ router.get('/*', async (req, res, next) => {
// -> Build theme code injection
const injectCode = {
css: WIKI.config.theming.injectCSS,
head: WIKI.config.theming.injectHead,
body: WIKI.config.theming.injectBody
css: '', // WIKI.config.theming.injectCSS,
head: '', // WIKI.config.theming.injectHead,
body: '' // WIKI.config.theming.injectBody
}
// Handle missing extra field
@ -551,12 +551,12 @@ router.get('/*', async (req, res, next) => {
// -> Inject comments variables
const commentTmpl = {
codeTemplate: WIKI.data.commentProvider.codeTemplate,
head: WIKI.data.commentProvider.head,
body: WIKI.data.commentProvider.body,
main: WIKI.data.commentProvider.main
codeTemplate: '', // WIKI.data.commentProvider.codeTemplate,
head: '', // WIKI.data.commentProvider.head,
body: '', // WIKI.data.commentProvider.body,
main: '' // WIKI.data.commentProvider.main
}
if (WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
if (false && WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
[
{ key: 'pageUrl', value: `${WIKI.config.host}/i/${page.id}` },
{ key: 'pageId', value: page.id }
@ -568,13 +568,14 @@ router.get('/*', async (req, res, next) => {
}
// -> Render view
res.render('page', {
page,
sidebar,
injectCode,
comments: commentTmpl,
effectivePermissions
})
res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
// res.render('page', {
// page,
// sidebar,
// injectCode,
// comments: commentTmpl,
// effectivePermissions
// })
} else if (pageArgs.path === 'home') {
res.redirect('/_welcome')
} else {

@ -21,7 +21,7 @@ module.exports = {
/**
* Initialize DB
*/
init() {
init(workerMode = false) {
let self = this
WIKI.logger.info('Checking DB configuration...')
@ -85,10 +85,14 @@ module.exports = {
connection: this.config,
searchPath: [WIKI.config.db.schemas.wiki],
pool: {
...WIKI.config.pool,
...workerMode ? { min: 0, max: 1 } : WIKI.config.pool,
async afterCreate(conn, done) {
// -> Set Connection App Name
await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}:MAIN'`)
if (workerMode) {
await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}'`)
} else {
await conn.query(`set application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}:MAIN'`)
}
done()
}
},
@ -145,7 +149,7 @@ module.exports = {
// Perform init tasks
this.onReady = (async () => {
this.onReady = workerMode ? Promise.resolve() : (async () => {
await initTasks.connect()
await initTasks.migrateFromLegacy()
await initTasks.syncSchemas()

@ -4,6 +4,9 @@ const autoload = require('auto-load')
const path = require('node:path')
const cronparser = require('cron-parser')
const { DateTime } = require('luxon')
const { v4: uuid } = require('uuid')
const { createDeferred } = require('../helpers/common')
const _ = require('lodash')
module.exports = {
workerPool: null,
@ -12,6 +15,7 @@ module.exports = {
pollingRef: null,
scheduledRef: null,
tasks: null,
completionPromises: [],
async init () {
this.maxWorkers = WIKI.config.scheduler.workers === 'auto' ? (os.cpus().length - 1) : WIKI.config.scheduler.workers
if (this.maxWorkers < 1) { this.maxWorkers = 1 }
@ -38,6 +42,20 @@ module.exports = {
}
break
}
case 'jobCompleted': {
const jobPromise = _.find(this.completionPromises, ['id', payload.id])
if (jobPromise) {
if (payload.state === 'success') {
jobPromise.resolve()
} else {
jobPromise.reject(new Error(payload.errorMessage))
}
setTimeout(() => {
_.remove(this.completionPromises, ['id', payload.id])
})
}
break
}
}
})
@ -56,23 +74,52 @@ module.exports = {
WIKI.logger.info('Scheduler: [ STARTED ]')
},
async addJob ({ task, payload, waitUntil, maxRetries, isScheduled = false, notify = true }) {
/**
* Add a job to the scheduler
* @param {Object} opts - Job options
* @param {string} opts.task - The task name to execute.
* @param {Object} [opts.payload={}] - An optional data object to pass to the job.
* @param {Date} [opts.waitUntil] - An optional datetime after which the task is allowed to run.
* @param {Number} [opts.maxRetries] - The number of times this job can be restarted upon failure. Uses server defaults if not provided.
* @param {Boolean} [opts.isScheduled=false] - Whether this is a scheduled job.
* @param {Boolean} [opts.notify=true] - Whether to notify all instances that a new job is available.
* @param {Boolean} [opts.promise=false] - Whether to return a promise property that resolves when the job completes.
* @returns {Promise}
*/
async addJob ({ task, payload = {}, waitUntil, maxRetries, isScheduled = false, notify = true, promise = false }) {
try {
await WIKI.db.knex('jobs').insert({
task,
useWorker: !(typeof this.tasks[task] === 'function'),
payload,
maxRetries: maxRetries ?? WIKI.config.scheduler.maxRetries,
isScheduled,
waitUntil,
createdBy: WIKI.INSTANCE_ID
})
const jobId = uuid()
const jobDefer = createDeferred()
if (promise) {
this.completionPromises.push({
id: jobId,
added: DateTime.utc(),
resolve: jobDefer.resolve,
reject: jobDefer.reject
})
}
await WIKI.db.knex('jobs')
.insert({
id: jobId,
task,
useWorker: !(typeof this.tasks[task] === 'function'),
payload,
maxRetries: maxRetries ?? WIKI.config.scheduler.maxRetries,
isScheduled,
waitUntil,
createdBy: WIKI.INSTANCE_ID
})
if (notify) {
WIKI.db.listener.publish('scheduler', {
source: WIKI.INSTANCE_ID,
event: 'newJob'
event: 'newJob',
id: jobId
})
}
return {
id: jobId,
...promise && { promise: jobDefer.promise }
}
} catch (err) {
WIKI.logger.warn(`Failed to add job to scheduler: ${err.message}`)
}
@ -130,6 +177,12 @@ module.exports = {
completedAt: new Date()
})
WIKI.logger.info(`Completed job ${job.id}: ${job.task}`)
WIKI.db.listener.publish('scheduler', {
source: WIKI.INSTANCE_ID,
event: 'jobCompleted',
state: 'success',
id: job.id
})
} catch (err) {
WIKI.logger.warn(`Failed to complete job ${job.id}: ${job.task} [ FAILED ]`)
WIKI.logger.warn(err)
@ -137,9 +190,17 @@ module.exports = {
await WIKI.db.knex('jobHistory').where({
id: job.id
}).update({
attempt: job.retries + 1,
state: 'failed',
lastErrorMessage: err.message
})
WIKI.db.listener.publish('scheduler', {
source: WIKI.INSTANCE_ID,
event: 'jobCompleted',
state: 'failed',
id: job.id,
errorMessage: err.message
})
// -> Reschedule for retry
if (job.retries < job.maxRetries) {
const backoffDelay = (2 ** job.retries) * WIKI.config.scheduler.retryBackoff

@ -243,7 +243,7 @@ exports.up = async knex => {
table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
table.string('module').notNullable()
table.boolean('isEnabled').notNullable().defaultTo(false)
table.jsonb('config')
table.jsonb('config').notNullable().defaultTo('{}')
})
// SETTINGS ----------------------------
.createTable('settings', table => {
@ -370,9 +370,6 @@ exports.up = async knex => {
table.uuid('pageId').notNullable().references('id').inTable('pages').onDelete('CASCADE')
table.string('localeCode', 5).references('code').inTable('locales')
})
.table('renderers', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})
.table('storage', table => {
table.uuid('siteId').notNullable().references('id').inTable('sites')
})

@ -79,6 +79,25 @@ module.exports = {
}
},
Mutation: {
async cancelJob (obj, args, context) {
WIKI.logger.info(`Admin requested cancelling job ${args.id}...`)
try {
const result = await WIKI.db.knex('jobs')
.where('id', args.id)
.del()
if (result === 1) {
WIKI.logger.info(`Cancelled job ${args.id} [ OK ]`)
} else {
throw new Error('Job has already entered active state or does not exist.')
}
return {
operation: graphHelper.generateSuccess('Cancelled job successfully.')
}
} catch (err) {
WIKI.logger.warn(err)
return graphHelper.generateError(err)
}
},
async disconnectWS (obj, args, context) {
WIKI.servers.ws.disconnectSockets(true)
WIKI.logger.info('All active websocket connections have been terminated.')
@ -97,6 +116,39 @@ module.exports = {
return graphHelper.generateError(err)
}
},
async retryJob (obj, args, context) {
WIKI.logger.info(`Admin requested rescheduling of job ${args.id}...`)
try {
const job = await WIKI.db.knex('jobHistory')
.where('id', args.id)
.first()
if (!job) {
throw new Error('No such job found.')
} else if (job.state === 'interrupted') {
throw new Error('Cannot reschedule a task that has been interrupted. It will automatically be retried shortly.')
} else if (job.state === 'failed' && job.attempt < job.maxRetries) {
throw new Error('Cannot reschedule a task that has not reached its maximum retry attempts.')
}
await WIKI.db.knex('jobs')
.insert({
id: job.id,
task: job.task,
useWorker: job.useWorker,
payload: job.payload,
retries: job.attempt,
maxRetries: job.maxRetries,
isScheduled: job.wasScheduled,
createdBy: WIKI.INSTANCE_ID
})
WIKI.logger.info(`Job ${args.id} has been rescheduled [ OK ]`)
return {
operation: graphHelper.generateSuccess('Job rescheduled successfully.')
}
} catch (err) {
WIKI.logger.warn(err)
return graphHelper.generateError(err)
}
},
async updateSystemFlags (obj, args, context) {
WIKI.config.flags = _.transform(args.flags, (result, row) => {
_.set(result, row.key, row.value)

@ -16,12 +16,20 @@ extend type Query {
}
extend type Mutation {
cancelJob(
id: UUID!
): DefaultResponse
disconnectWS: DefaultResponse
installExtension(
key: String!
): DefaultResponse
retryJob(
id: UUID!
): DefaultResponse
updateSystemFlags(
flags: [SystemFlagInput]!
): DefaultResponse

@ -1,6 +1,34 @@
const _ = require('lodash')
module.exports = {
/* eslint-disable promise/param-names */
createDeferred () {
let result, resolve, reject
return {
resolve: function (value) {
if (resolve) {
resolve(value)
} else {
result = result || new Promise(function (r) { r(value) })
}
},
reject: function (reason) {
if (reject) {
reject(reason)
} else {
result = result || new Promise(function (x, j) { j(reason) })
}
},
promise: new Promise(function (r, j) {
if (result) {
r(result)
} else {
resolve = r
reject = j
}
})
}
},
/**
* Get default value of type
*

@ -1,41 +0,0 @@
const Model = require('objection').Model
/**
* Editor model
*/
module.exports = class Editor extends Model {
static get tableName() { return 'editors' }
static get idColumn() { return 'key' }
static get jsonSchema () {
return {
type: 'object',
required: ['key', 'isEnabled'],
properties: {
key: {type: 'string'},
isEnabled: {type: 'boolean'}
}
}
}
static get jsonAttributes() {
return ['config']
}
static async getEditors() {
return WIKI.db.editors.query()
}
static async getDefaultEditor(contentType) {
// TODO - hardcoded for now
switch (contentType) {
case 'markdown':
return 'markdown'
case 'html':
return 'ckeditor'
default:
return 'code'
}
}
}

@ -34,7 +34,7 @@ module.exports = class Page extends Model {
required: ['path', 'title'],
properties: {
id: {type: 'integer'},
id: {type: 'string'},
path: {type: 'string'},
hash: {type: 'string'},
title: {type: 'string'},
@ -44,7 +44,7 @@ module.exports = class Page extends Model {
publishEndDate: {type: 'string'},
content: {type: 'string'},
contentType: {type: 'string'},
siteId: {type: 'string'},
createdAt: {type: 'string'},
updatedAt: {type: 'string'}
}
@ -125,11 +125,11 @@ module.exports = class Page extends Model {
*/
static get cacheSchema() {
return new JSBinType({
id: 'uint',
authorId: 'uint',
id: 'string',
authorId: 'string',
authorName: 'string',
createdAt: 'string',
creatorId: 'uint',
creatorId: 'string',
creatorName: 'string',
description: 'string',
editor: 'string',
@ -137,6 +137,7 @@ module.exports = class Page extends Model {
publishEndDate: 'string',
publishStartDate: 'string',
render: 'string',
siteId: 'string',
tags: [
{
tag: 'string'
@ -291,7 +292,7 @@ module.exports = class Page extends Model {
authorId: opts.user.id,
content: opts.content,
creatorId: opts.user.id,
contentType: _.get(_.find(WIKI.data.editors, ['key', opts.editor]), `contentType`, 'text'),
contentType: _.get(WIKI.data.editors[opts.editor], 'contentType', 'text'),
description: opts.description,
editor: opts.editor,
hash: pageHelper.generateHash({ path: opts.path, locale: opts.locale }),
@ -322,6 +323,9 @@ module.exports = class Page extends Model {
// -> Render page to HTML
await WIKI.db.pages.renderPage(page)
return page
// TODO: Handle remaining flow
// -> Rebuild page tree
await WIKI.db.pages.rebuildTree()
@ -922,12 +926,15 @@ module.exports = class Page extends Model {
* @returns {Promise} Promise with no value
*/
static async renderPage(page) {
const renderJob = await WIKI.scheduler.registerJob({
name: 'render-page',
immediate: true,
worker: true
}, page.id)
return renderJob.finished
const renderJob = await WIKI.scheduler.addJob({
task: 'render-page',
payload: {
id: page.id
},
maxRetries: 0,
promise: true
})
return renderJob.promise
}
/**
@ -963,7 +970,7 @@ module.exports = class Page extends Model {
* @returns {Promise} Promise of the Page Model Instance
*/
static async getPageFromDb(opts) {
const queryModeID = _.isNumber(opts)
const queryModeID = typeof opts === 'string'
try {
return WIKI.db.pages.query()
.column([
@ -985,6 +992,7 @@ module.exports = class Page extends Model {
'pages.localeCode',
'pages.authorId',
'pages.creatorId',
'pages.siteId',
'pages.extra',
{
authorName: 'author.name',
@ -1033,7 +1041,7 @@ module.exports = class Page extends Model {
id: page.id,
authorId: page.authorId,
authorName: page.authorName,
createdAt: page.createdAt,
createdAt: page.createdAt.toISOString(),
creatorId: page.creatorId,
creatorName: page.creatorName,
description: page.description,
@ -1042,14 +1050,15 @@ module.exports = class Page extends Model {
css: _.get(page, 'extra.css', ''),
js: _.get(page, 'extra.js', '')
},
publishState: page.publishState,
publishEndDate: page.publishEndDate,
publishStartDate: page.publishStartDate,
publishState: page.publishState ?? '',
publishEndDate: page.publishEndDate ?? '',
publishStartDate: page.publishStartDate ?? '',
render: page.render,
siteId: page.siteId,
tags: page.tags.map(t => _.pick(t, ['tag'])),
title: page.title,
toc: _.isString(page.toc) ? page.toc : JSON.stringify(page.toc),
updatedAt: page.updatedAt
updatedAt: page.updatedAt.toISOString()
}))
}

@ -55,19 +55,65 @@ module.exports = class Renderer extends Model {
}
static async refreshRenderersFromDisk() {
// const dbRenderers = await WIKI.db.renderers.query()
try {
const dbRenderers = await WIKI.db.renderers.query()
// -> Fetch definitions from disk
await WIKI.db.renderers.fetchDefinitions()
// -> Insert new Renderers
const newRenderers = []
let updatedRenderers = 0
for (const renderer of WIKI.data.renderers) {
if (!_.some(dbRenderers, ['module', renderer.key])) {
newRenderers.push({
module: renderer.key,
isEnabled: renderer.enabledDefault ?? true,
config: _.transform(renderer.props, (result, value, key) => {
result[key] = value.default
return result
}, {})
})
} else {
const rendererConfig = _.get(_.find(dbRenderers, ['module', renderer.key]), 'config', {})
await WIKI.db.renderers.query().patch({
config: _.transform(renderer.props, (result, value, key) => {
if (!_.has(result, key)) {
result[key] = value.default
}
return result
}, rendererConfig)
}).where('module', renderer.key)
updatedRenderers++
}
}
if (newRenderers.length > 0) {
await WIKI.db.renderers.query().insert(newRenderers)
WIKI.logger.info(`Loaded ${newRenderers.length} new renderers: [ OK ]`)
}
// -> Fetch definitions from disk
await WIKI.db.renderers.fetchDefinitions()
if (updatedRenderers > 0) {
WIKI.logger.info(`Updated ${updatedRenderers} existing renderers: [ OK ]`)
}
// TODO: Merge existing configs with updated modules
// -> Delete removed Renderers
for (const renderer of dbRenderers) {
if (!_.some(WIKI.data.renderers, ['key', renderer.module])) {
await WIKI.db.renderers.query().where('module', renderer.key).del()
WIKI.logger.info(`Removed renderer ${renderer.key} because it is no longer present in the modules folder: [ OK ]`)
}
}
} catch (err) {
WIKI.logger.error('Failed to import renderers: [ FAILED ]')
WIKI.logger.error(err)
}
}
static async getRenderingPipeline(contentType) {
const renderersDb = await WIKI.db.renderers.query().where('isEnabled', true)
if (renderersDb && renderersDb.length > 0) {
const renderers = renderersDb.map(rdr => {
const renderer = _.find(WIKI.data.renderers, ['key', rdr.key])
const renderer = _.find(WIKI.data.renderers, ['key', rdr.module])
return {
...renderer,
config: rdr.config

@ -4,5 +4,5 @@ description: Embed asciinema players from compatible links
author: requarks.io
icon: mdi-theater
enabledDefault: false
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,5 +4,5 @@ description: Parse blockquotes box styling
author: requarks.io
icon: mdi-alpha-t-box-outline
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,6 +4,6 @@ description: Syntax detector for programming code
author: requarks.io
icon: mdi-code-braces
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
step: pre
props: {}

@ -1,4 +1,4 @@
key: htmlCore
key: html-core
title: Core
description: Basic HTML Parser
author: requarks.io

@ -21,7 +21,7 @@ module.exports = {
// --------------------------------
for (let child of _.reject(this.children, ['step', 'post'])) {
const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
const renderer = require(`../${child.key}/renderer.js`)
await renderer.init($, child.config)
}
@ -33,10 +33,7 @@ module.exports = {
const reservedPrefixes = /^\/[a-z]\//i
const exactReservedPaths = /^\/[a-z]$/i
const isHostSet = WIKI.config.host.length > 7 && WIKI.config.host !== 'http://'
if (!isHostSet) {
WIKI.logger.warn('Host is not set. You must set the Site Host under General in the Administration Area!')
}
const hasHostname = this.site.hostname !== '*'
$('a').each((i, elm) => {
let href = $(elm).attr('href')
@ -48,8 +45,8 @@ module.exports = {
}
// -> Strip host from local links
if (isHostSet && href.indexOf(`${WIKI.config.host}/`) === 0) {
href = href.replace(WIKI.config.host, '')
if (hasHostname && href.indexOf(`${this.site.hostname}/`) === 0) {
href = href.replace(this.site.hostname, '')
}
// -> Assign local / external tag
@ -68,7 +65,7 @@ module.exports = {
let pagePath = null
// -> Add locale prefix if using namespacing
if (WIKI.config.lang.namespacing) {
if (this.site.config.localeNamespacing) {
// -> Reformat paths
if (href.indexOf('/') !== 0) {
if (this.config.absoluteLinks) {

@ -4,5 +4,5 @@ description: HTML Processing for diagrams (draw.io)
author: requarks.io
icon: mdi-chart-multiline
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,5 +4,5 @@ description: Prefetch remotely rendered images (korki/plantuml)
author: requarks.io
icon: mdi-cloud-download-outline
enabledDefault: false
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,5 +4,5 @@ description: Embed players such as Youtube, Vimeo, Soundcloud, etc.
author: requarks.io
icon: mdi-video
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,5 +4,5 @@ description: Generate flowcharts from Mermaid syntax
author: requarks.io
icon: mdi-arrow-decision-outline
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,7 +4,7 @@ description: Filter and strips potentially dangerous content
author: requarks.io
icon: mdi-fire
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
step: post
order: 99999
props:

@ -4,5 +4,5 @@ description: Transform headers into tabs
author: requarks.io
icon: mdi-tab
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
props: {}

@ -4,7 +4,7 @@ description: Apply Twitter Emojis to all Unicode emojis
author: requarks.io
icon: mdi-emoticon-happy-outline
enabledDefault: true
dependsOn: htmlCore
dependsOn: html-core
step: post
order: 10
props: {}

@ -4,5 +4,5 @@ description: Parse abbreviations into abbr tags
author: requarks.io
icon: mdi-contain-start
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props: {}

@ -1,4 +1,4 @@
key: markdownCore
key: markdown-core
title: Core
description: Basic Markdown Parser
author: requarks.io

@ -44,7 +44,7 @@ module.exports = {
})
for (let child of this.children) {
const renderer = require(`../${_.kebabCase(child.key)}/renderer.js`)
const renderer = require(`../${child.key}/renderer.js`)
await renderer.init(mkdown, child.config)
}

@ -4,5 +4,5 @@ description: Convert tags to emojis
author: requarks.io
icon: mdi-sticker-emoji
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props: {}

@ -4,7 +4,7 @@ description: Replace tabs with spaces in code blocks
author: requarks.io
icon: mdi-arrow-expand-horizontal
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props:
tabWidth:
type: Number

@ -4,5 +4,5 @@ description: Parse footnotes references
author: requarks.io
icon: mdi-page-layout-footer
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props: {}

@ -4,5 +4,5 @@ description: Adds dimensions attributes to images
author: requarks.io
icon: mdi-image-size-select-large
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props: {}

@ -4,7 +4,7 @@ description: LaTeX Math + Chemical Expression Typesetting Renderer
author: requarks.io
icon: mdi-math-integral
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props:
useInline:
type: Boolean

@ -4,7 +4,7 @@ description: Kroki Diagrams Parser
author: rlanyi (based on PlantUML renderer)
icon: mdi-sitemap
enabledDefault: false
dependsOn: markdownCore
dependsOn: markdown-core
props:
server:
type: String

@ -4,7 +4,7 @@ description: LaTeX Math + Chemical Expression Typesetting Renderer
author: requarks.io
icon: mdi-math-integral
enabledDefault: false
dependsOn: markdownCore
dependsOn: markdown-core
props:
useInline:
type: Boolean

@ -4,7 +4,7 @@ description: Add MultiMarkdown table support
author: requarks.io
icon: mdi-table
enabledDefault: false
dependsOn: markdownCore
dependsOn: markdown-core
props:
multilineEnabled:
type: Boolean

@ -4,7 +4,7 @@ description: PlantUML Markdown Parser
author: ethanmdavidson
icon: mdi-sitemap
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props:
server:
type: String

@ -4,7 +4,7 @@ description: Parse subscript and superscript tags
author: requarks.io
icon: mdi-format-superscript
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props:
subEnabled:
type: Boolean

@ -4,5 +4,5 @@ description: Parse task lists to checkboxes
author: requarks.io
icon: mdi-format-list-checks
enabledDefault: true
dependsOn: markdownCore
dependsOn: markdown-core
props: {}

@ -0,0 +1,92 @@
const _ = require('lodash')
const cheerio = require('cheerio')
module.exports = async ({ payload }) => {
WIKI.logger.info(`Rendering page ${payload.id}...`)
try {
await WIKI.ensureDb()
const page = await WIKI.db.pages.getPageFromDb(payload.id)
if (!page) {
throw new Error('Invalid Page Id')
}
const site = await WIKI.db.sites.query().findById(page.siteId)
await WIKI.db.renderers.fetchDefinitions()
const pipeline = await WIKI.db.renderers.getRenderingPipeline(page.contentType)
let output = page.content
if (_.isEmpty(page.content)) {
WIKI.logger.warn(`Failed to render page ID ${payload.id} because content was empty: [ FAILED ]`)
}
for (let core of pipeline) {
const renderer = require(`../../modules/rendering/${core.key}/renderer.js`)
output = await renderer.render.call({
config: core.config,
children: core.children,
page,
site,
input: output
})
}
// Parse TOC
const $ = cheerio.load(output)
let isStrict = $('h1').length > 0 // <- Allows for documents using H2 as top level
let toc = { root: [] }
$('h1,h2,h3,h4,h5,h6').each((idx, el) => {
const depth = _.toSafeInteger(el.name.substring(1)) - (isStrict ? 1 : 2)
let leafPathError = false
const leafPath = _.reduce(_.times(depth), (curPath, curIdx) => {
if (_.has(toc, curPath)) {
const lastLeafIdx = _.get(toc, curPath).length - 1
if (lastLeafIdx >= 0) {
curPath = `${curPath}[${lastLeafIdx}].children`
} else {
leafPathError = true
}
}
return curPath
}, 'root')
if (leafPathError) { return }
const leafSlug = $('.toc-anchor', el).first().attr('href')
$('.toc-anchor', el).remove()
_.get(toc, leafPath).push({
title: _.trim($(el).text()),
anchor: leafSlug,
children: []
})
})
// Save to DB
await WIKI.db.pages.query()
.patch({
render: output,
toc: JSON.stringify(toc.root)
})
.where('id', payload.id)
// Save to cache
// await WIKI.db.pages.savePageToCache({
// ...page,
// render: output,
// toc: JSON.stringify(toc.root)
// })
WIKI.logger.info(`Rendered page ${payload.id}: [ COMPLETED ]`)
} catch (err) {
WIKI.logger.error(`Rendering page ${payload.id}: [ FAILED ]`)
WIKI.logger.error(err.message)
throw err
}
}

@ -5,8 +5,6 @@ block head
style(type='text/css')!= injectCode.css
if injectCode.head
!= injectCode.head
if config.features.featurePageComments
!= comments.head
block body
#root
@ -21,20 +19,9 @@ block body
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
sidebar=Buffer.from(JSON.stringify(sidebar)).toString('base64')
nav-mode=config.nav.mode
comments-enabled=config.features.featurePageComments
effective-permissions=Buffer.from(JSON.stringify(effectivePermissions)).toString('base64')
comments-external=comments.codeTemplate
)
template(slot='contents')
div!= page.render
template(slot='comments')
div!= comments.main
if injectCode.body
!= injectCode.body
if config.features.featurePageComments
!= comments.body

@ -12,7 +12,23 @@ let WIKI = {
INSTANCE_ID: 'worker',
SERVERPATH: path.join(process.cwd(), 'server'),
Error: require('./helpers/error'),
configSvc: require('./core/config')
configSvc: require('./core/config'),
ensureDb: async () => {
if (WIKI.db) { return true }
WIKI.db = require('./core/db').init(true)
try {
await WIKI.configSvc.loadFromDb()
await WIKI.configSvc.applyFlags()
} catch (err) {
WIKI.logger.error('Database Initialization Error: ' + err.message)
if (WIKI.IS_DEBUG) {
WIKI.logger.error(err)
}
process.exit(1)
}
}
}
global.WIKI = WIKI

@ -1,21 +1,21 @@
<template lang="pug">
.q-gutter-xs
template(v-if='tags && tags.length > 0')
template(v-if='pageStore.tags && pageStore.tags.length > 0')
q-chip(
square
color='secondary'
text-color='white'
dense
clickable
:removable='edit'
:removable='props.edit'
@remove='removeTag(tag)'
v-for='tag of tags'
v-for='tag of pageStore.tags'
:key='`tag-` + tag'
)
q-icon.q-mr-xs(name='las la-tag', size='14px')
span.text-caption {{tag}}
q-chip(
v-if='!edit && tags.length > 1'
v-if='!props.edit && pageStore.tags.length > 1'
square
color='secondary'
text-color='white'
@ -24,36 +24,51 @@
)
q-icon(name='las la-tags', size='14px')
q-input.q-mt-md(
v-if='edit'
v-if='props.edit'
outlined
v-model='newTag'
v-model='state.newTag'
dense
placeholder='Add new tag...'
)
</template>
<script>
import { sync } from 'vuex-pathify'
export default {
props: {
edit: {
type: Boolean,
default: false
}
},
data () {
return {
newTag: ''
}
},
computed: {
tags: sync('page/tags', false)
},
methods: {
removeTag (tag) {
this.tags = this.tags.filter(t => t !== tag)
}
<script setup>
import { useQuasar } from 'quasar'
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePageStore } from 'src/stores/page'
// PROPS
const props = defineProps({
edit: {
type: Boolean,
default: false
}
})
// QUASAR
const $q = useQuasar()
// STORES
const pageStore = usePageStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
newTag: ''
})
// METHODS
function removeTag (tag) {
pageStore.tags = pageStore.tags.filter(t => t !== tag)
}
</script>

@ -11,125 +11,146 @@ q-menu(
q-item-section.items-center(avatar)
q-icon(color='grey', name='las la-clipboard', size='sm')
q-item-section.q-pr-md Copy URL
q-item(clickable, tag='a', :href='`mailto:?subject=` + encodeURIComponent(title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(description)', target='_blank')
q-item(clickable, tag='a', :href='`mailto:?subject=` + encodeURIComponent(props.title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(props.description)', target='_blank')
q-item-section.items-center(avatar)
q-icon(color='grey', name='las la-envelope', size='sm')
q-item-section.q-pr-md Email
q-item(clickable, @click='openSocialPop(`https://www.facebook.com/sharer/sharer.php?u=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title) + `&description=` + encodeURIComponent(description))')
q-item(clickable, @click='openSocialPop(`https://www.facebook.com/sharer/sharer.php?u=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title) + `&description=` + encodeURIComponent(props.description))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-facebook', size='sm')
q-item-section.q-pr-md Facebook
q-item(clickable, @click='openSocialPop(`https://www.linkedin.com/shareArticle?mini=true&url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title) + `&summary=` + encodeURIComponent(description))')
q-item(clickable, @click='openSocialPop(`https://www.linkedin.com/shareArticle?mini=true&url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title) + `&summary=` + encodeURIComponent(props.description))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-linkedin', size='sm')
q-item-section.q-pr-md LinkedIn
q-item(clickable, @click='openSocialPop(`https://www.reddit.com/submit?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title))')
q-item(clickable, @click='openSocialPop(`https://www.reddit.com/submit?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-reddit', size='sm')
q-item-section.q-pr-md Reddit
q-item(clickable, @click='openSocialPop(`https://t.me/share/url?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(title))')
q-item(clickable, @click='openSocialPop(`https://t.me/share/url?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(props.title))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-telegram', size='sm')
q-item-section.q-pr-md Telegram
q-item(clickable, @click='openSocialPop(`https://twitter.com/intent/tweet?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(title))')
q-item(clickable, @click='openSocialPop(`https://twitter.com/intent/tweet?url=` + encodeURIComponent(urlFormatted) + `&text=` + encodeURIComponent(props.title))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-twitter', size='sm')
q-item-section.q-pr-md Twitter
q-item(clickable, :href='`viber://forward?text=` + encodeURIComponent(urlFormatted) + ` ` + encodeURIComponent(description)')
q-item(clickable, :href='`viber://forward?text=` + encodeURIComponent(urlFormatted) + ` ` + encodeURIComponent(props.description)')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-viber', size='sm')
q-item-section.q-pr-md Viber
q-item(clickable, @click='openSocialPop(`http://service.weibo.com/share/share.php?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(title))')
q-item(clickable, @click='openSocialPop(`http://service.weibo.com/share/share.php?url=` + encodeURIComponent(urlFormatted) + `&title=` + encodeURIComponent(props.title))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-weibo', size='sm')
q-item-section.q-pr-md Weibo
q-item(clickable, @click='openSocialPop(`https://api.whatsapp.com/send?text=` + encodeURIComponent(title) + `%0D%0A` + encodeURIComponent(urlFormatted))')
q-item(clickable, @click='openSocialPop(`https://api.whatsapp.com/send?text=` + encodeURIComponent(props.title) + `%0D%0A` + encodeURIComponent(urlFormatted))')
q-item-section.items-center(avatar)
q-icon(color='grey', name='lab la-whatsapp', size='sm')
q-item-section.q-pr-md Whatsapp
</template>
<script>
<script setup>
import ClipboardJS from 'clipboard'
import { useQuasar } from 'quasar'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
export default {
props: {
url: {
type: String,
default: null
},
title: {
type: String,
default: 'Untitled Page'
},
description: {
type: String,
default: ''
}
},
data () {
return {
width: 626,
height: 436,
left: 0,
top: 0,
clip: null
}
},
computed: {
urlFormatted () {
if (!import.meta.env.SSR) {
return this.url ? this.url : window.location.href
} else {
return ''
}
}
// PROPS
const props = defineProps({
url: {
type: String,
default: null
},
methods: {
openSocialPop (url) {
const popupWindow = window.open(
url,
'sharer',
`status=no,height=${this.height},width=${this.width},resizable=yes,left=${this.left},top=${this.top},screenX=${this.left},screenY=${this.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
)
popupWindow.focus()
},
menuShown (ev) {
this.clip = new ClipboardJS(this.$refs.copyUrlButton.$el, {
text: () => { return this.urlFormatted }
})
this.clip.on('success', () => {
this.$q.notify({
message: 'URL copied successfully',
icon: 'las la-clipboard'
})
})
this.clip.on('error', () => {
this.$q.notify({
type: 'negative',
message: 'Failed to copy to clipboard'
})
})
},
menuHidden (ev) {
this.clip.destroy()
}
title: {
type: String,
default: 'Untitled Page'
},
mounted () {
/**
* Center the popup on dual screens
* http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
*/
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
const width = window.innerWidth ? window.innerWidth : (document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width)
const height = window.innerHeight ? window.innerHeight : (document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height)
this.left = ((width / 2) - (this.width / 2)) + dualScreenLeft
this.top = ((height / 2) - (this.height / 2)) + dualScreenTop
description: {
type: String,
default: ''
}
})
// QUASAR
const $q = useQuasar()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
width: 626,
height: 436,
left: 0,
top: 0,
clip: null
})
let clip = null
const copyUrlButton = ref(null)
// COMPUTED
const urlFormatted = computed(() => {
if (!import.meta.env.SSR) {
return props.url ? props.url : window.location.href
} else {
return ''
}
})
// METHODS
function openSocialPop (url) {
const popupWindow = window.open(
url,
'sharer',
`status=no,height=${state.height},width=${state.width},resizable=yes,left=${state.left},top=${state.top},screenX=${state.left},screenY=${state.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
)
popupWindow.focus()
}
function menuShown (ev) {
clip = new ClipboardJS(copyUrlButton.value.$el, {
text: () => { return urlFormatted.value }
})
clip.on('success', () => {
$q.notify({
message: 'URL copied successfully',
icon: 'las la-clipboard'
})
})
clip.on('error', () => {
$q.notify({
type: 'negative',
message: 'Failed to copy to clipboard'
})
})
}
function menuHidden (ev) {
clip.destroy()
}
// MOUNTED
onMounted(() => {
/**
* Center the popup on dual screens
* http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
*/
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
const width = window.innerWidth ? window.innerWidth : (document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width)
const height = window.innerHeight ? window.innerHeight : (document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height)
state.left = ((width / 2) - (state.width / 2)) + dualScreenLeft
state.top = ((height / 2) - (state.height / 2)) + dualScreenTop
})
</script>

@ -1540,5 +1540,9 @@
"admin.instances.lastSeen": "Last Seen",
"admin.instances.firstSeen": "First Seen",
"admin.instances.activeListeners": "Active Listeners",
"admin.instances.activeConnections": "Active Connections"
"admin.instances.activeConnections": "Active Connections",
"admin.scheduler.cancelJob": "Cancel Job",
"admin.scheduler.cancelJobSuccess": "Job cancelled successfully.",
"admin.scheduler.retryJob": "Retry Job",
"admin.scheduler.retryJobSuccess": "Job has been rescheduled and will execute shortly."
}

@ -3,7 +3,7 @@ q-layout.admin(view='hHh Lpr lff')
q-header.bg-black.text-white
.row.no-wrap
q-toolbar(style='height: 64px;', dark)
q-btn(dense, flat, href='/')
q-btn(dense, flat, to='/')
q-avatar(size='34px', square)
img(src='/_assets/logo-wikijs.svg')
q-toolbar-title.text-h6 Wiki.js
@ -102,10 +102,6 @@ q-layout.admin(view='hHh Lpr lff')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
q-item-section {{ t('admin.navigation.title') }}
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/rendering`', v-ripple, active-class='bg-primary text-white', disabled)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
q-item-section {{ t('admin.rendering.title') }}
q-item(:to='`/_admin/` + adminStore.currentSiteId + `/storage`', v-ripple, active-class='bg-primary text-white')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-ssd.svg')
@ -156,6 +152,10 @@ q-layout.admin(view='hHh Lpr lff')
q-item-section {{ t('admin.mail.title') }}
q-item-section(side)
status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`')
q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white', disabled)
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
q-item-section {{ t('admin.rendering.title') }}
q-item(to='/_admin/scheduler', v-ripple, active-class='bg-primary text-white')
q-item-section(avatar)
q-icon(name='img:/_assets/icons/fluent-bot.svg')

@ -2,7 +2,7 @@
q-layout(view='hHh Lpr lff')
header-nav
q-drawer.bg-sidebar(
v-model='showSideNav'
v-model='siteStore.showSideNav'
show-if-above
:width='255'
)
@ -81,46 +81,64 @@ q-layout(view='hHh Lpr lff')
span(style='font-size: 11px;') &copy; Cyberdyne Systems Corp. 2020 | Powered by #[strong Wiki.js]
</template>
<script>
import { get, sync } from 'vuex-pathify'
import { setCssVar } from 'quasar'
<script setup>
import { useMeta, useQuasar, setCssVar } from 'quasar'
import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useSiteStore } from '../stores/site'
// COMPONENTS
import AccountMenu from '../components/AccountMenu.vue'
import HeaderNav from '../components/HeaderNav.vue'
export default {
name: 'MainLayout',
components: {
HeaderNav
},
data () {
return {
leftDrawerOpen: true,
search: '',
thumbStyle: {
right: '2px',
borderRadius: '5px',
backgroundColor: '#FFF',
width: '5px',
opacity: 0.5
},
barStyle: {
backgroundColor: '#000',
width: '9px',
opacity: 0.1
}
}
},
computed: {
showSideNav: sync('site/showSideNav', false),
isSyncing: get('isLoading', false)
},
created () {
setCssVar('primary', this.$store.get('site/theme@colorPrimary'))
setCssVar('secondary', this.$store.get('site/theme@colorSecondary'))
setCssVar('accent', this.$store.get('site/theme@colorAccent'))
setCssVar('header', this.$store.get('site/theme@colorHeader'))
setCssVar('sidebar', this.$store.get('site/theme@colorSidebar'))
}
// QUASAR
const $q = useQuasar()
// STORES
const siteStore = useSiteStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// META
useMeta({
titleTemplate: title => `${title} - ${siteStore.title}`
})
// DATA
const leftDrawerOpen = ref(true)
const search = ref('')
const user = reactive({
name: 'John Doe',
email: 'test@example.com',
picture: null
})
const thumbStyle = {
right: '2px',
borderRadius: '5px',
backgroundColor: '#FFF',
width: '5px',
opacity: 0.5
}
const barStyle = {
backgroundColor: '#000',
width: '9px',
opacity: 0.1
}
</script>
<style lang="scss">

@ -132,7 +132,7 @@ q-page.admin-terminal
div: small.text-grey {{humanizeDate(props.row.waitUntil)}}
template(v-slot:body-cell-retries='props')
q-td(:props='props')
span #[strong {{props.value + 1}}] #[span.text-grey / {{props.row.maxRetries}}]
span #[strong {{props.value + 1}}] #[span.text-grey / {{props.row.maxRetries + 1}}]
template(v-slot:body-cell-useworker='props')
q-td(:props='props')
template(v-if='props.value')
@ -148,6 +148,15 @@ q-page.admin-terminal
i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
template(#instance)
strong {{props.row.createdBy}}
template(v-slot:body-cell-cancel='props')
q-td(:props='props')
q-btn.acrylic-btn.q-px-sm(
flat
icon='las la-window-close'
color='negative'
@click='cancelJob(props.row.id)'
)
q-tooltip(anchor='center left', self='center right') {{ t('admin.scheduler.cancelJob') }}
template(v-else)
q-card.rounded-borders(
v-if='state.jobs.length < 1'
@ -221,7 +230,7 @@ q-page.admin-terminal
div: small {{ props.row.lastErrorMessage }}
template(v-slot:body-cell-attempt='props')
q-td(:props='props')
span #[strong {{props.value}}] #[span.text-grey / {{props.row.maxRetries}}]
span #[strong {{props.value}}] #[span.text-grey / {{props.row.maxRetries + 1}}]
template(v-slot:body-cell-useworker='props')
q-td(:props='props')
template(v-if='props.value')
@ -238,6 +247,17 @@ q-page.admin-terminal
i18n-t.text-grey(keypath='admin.scheduler.createdBy', tag='small')
template(#instance)
strong {{props.row.executedBy}}
template(v-slot:body-cell-actions='props')
q-td(:props='props')
q-btn.acrylic-btn.q-px-sm(
v-if='props.row.state !== `active`'
flat
icon='las la-undo-alt'
color='orange'
@click='retryJob(props.row.id)'
:disable='props.row.state === `interrupted` || props.row.state === `failed` && props.row.attempt < props.row.maxRetries'
)
q-tooltip(anchor='center left', self='center right') {{ t('admin.scheduler.retryJob') }}
</template>
@ -271,7 +291,7 @@ useMeta({
// DATA
const state = reactive({
displayMode: 'completed',
displayMode: 'upcoming',
scheduledJobs: [],
upcomingJobs: [],
jobs: [],
@ -369,6 +389,13 @@ const upcomingJobsHeaders = [
name: 'date',
sortable: true,
format: v => DateTime.fromISO(v).toRelative()
},
{
align: 'center',
field: 'id',
name: 'cancel',
sortable: false,
style: 'width: 15px;'
}
]
@ -415,6 +442,13 @@ const jobsHeaders = [
name: 'date',
sortable: true,
format: v => DateTime.fromISO(v).toRelative()
},
{
align: 'center',
field: 'id',
name: 'actions',
sortable: false,
style: 'width: 15px;'
}
]
@ -524,6 +558,80 @@ async function load () {
state.loading--
}
async function cancelJob (jobId) {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation cancelJob ($id: UUID!) {
cancelJob(id: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: jobId
}
})
if (resp?.data?.cancelJob?.operation?.succeeded) {
this.load()
$q.notify({
type: 'positive',
message: t('admin.scheduler.cancelJobSuccess')
})
} else {
throw new Error(resp?.data?.cancelJob?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to cancel job.',
caption: err.message
})
}
state.loading--
}
async function retryJob (jobId) {
state.loading++
try {
const resp = await APOLLO_CLIENT.mutate({
mutation: gql`
mutation retryJob ($id: UUID!) {
retryJob(id: $id) {
operation {
succeeded
message
}
}
}
`,
variables: {
id: jobId
}
})
if (resp?.data?.retryJob?.operation?.succeeded) {
this.load()
$q.notify({
type: 'positive',
message: t('admin.scheduler.retryJobSuccess')
})
} else {
throw new Error(resp?.data?.retryJob?.operation?.message || 'An unexpected error occured.')
}
} catch (err) {
$q.notify({
type: 'negative',
message: 'Failed to retry the job.',
caption: err.message
})
}
state.loading--
}
// MOUNTED
onMounted(() => {

@ -13,97 +13,33 @@ q-page.column
q-breadcrumbs-el(icon='las la-home', to='/', aria-label='Home')
q-tooltip Home
q-breadcrumbs-el(
v-for='brd of breadcrumbs'
v-for='brd of pageStore.breadcrumbs'
:key='brd.id'
:icon='brd.icon'
:label='brd.title'
:aria-label='brd.title'
:to='$pageHelpers.getFullPath(brd)'
)
q-breadcrumbs-el(
v-if='editCreateMode'
:icon='pageIcon'
:label='title || `Untitled Page`'
:aria-label='title || `Untitled Page`'
:to='getFullPath(brd)'
)
.col-auto.flex.items-center.justify-end
template(v-if='!isPublished')
template(v-if='!pageStore.isPublished')
.text-caption.text-accent: strong Unpublished
q-separator.q-mx-sm(vertical)
.text-caption.text-grey-6(v-if='editCreateMode') New Page
.text-caption.text-grey-6(v-if='!editCreateMode') Last modified on #[strong September 5th, 2020]
.text-caption.text-grey-6 Last modified on #[strong September 5th, 2020]
.page-header.row
//- PAGE ICON
.col-auto.q-pl-md.flex.items-center(v-if='editMode')
q-btn.rounded-borders(
padding='none'
size='37px'
:icon='pageIcon'
color='primary'
flat
)
q-menu(content-class='shadow-7')
icon-picker-dialog(v-model='pageIcon')
.col-auto.q-pl-md.flex.items-center(v-else)
.col-auto.q-pl-md.flex.items-center
q-icon.rounded-borders(
:name='pageIcon'
:name='pageStore.icon'
size='64px'
color='primary'
)
//- PAGE HEADER
.col.q-pa-md(v-if='editMode')
q-input.no-height(
borderless
v-model='title'
input-class='text-h4 text-grey-9'
input-style='padding: 0;'
placeholder='Untitled Page'
hide-hint
)
q-input.no-height(
borderless
v-model='description'
input-class='text-subtitle2 text-grey-7'
input-style='padding: 0;'
placeholder='Enter a short description'
hide-hint
)
.col.q-pa-md(v-else)
.text-h4.page-header-title {{title}}
.text-subtitle2.page-header-subtitle {{description}}
.col.q-pa-md
.text-h4.page-header-title {{pageStore.title}}
.text-subtitle2.page-header-subtitle {{pageStore.description}}
//- PAGE ACTIONS
.col-auto.q-pa-md.flex.items-center.justify-end(v-if='editMode')
q-btn.q-mr-sm.acrylic-btn(
flat
icon='las la-times'
color='grey-7'
label='Discard'
aria-label='Discard'
no-caps
@click='mode = `view`'
)
q-btn(
v-if='editorMode === `edit`'
unelevated
icon='las la-check'
color='secondary'
label='Save'
aria-label='Save'
no-caps
@click='mode = `view`'
)
q-btn(
v-else
unelevated
icon='las la-check'
color='secondary'
label='Create'
aria-label='Create'
no-caps
@click='mode = `view`'
)
.col-auto.q-pa-md.flex.items-center.justify-end(v-else)
.col-auto.q-pa-md.flex.items-center.justify-end
q-btn.q-mr-md(
flat
dense
@ -144,23 +80,18 @@ q-page.column
label='Edit'
aria-label='Edit'
no-caps
@click='mode = `edit`'
:href='editUrl'
)
.page-container.row.no-wrap.items-stretch(style='flex: 1 1 100%;')
.col(style='order: 1;')
q-no-ssr(v-if='editMode')
component(:is='editorComponent')
//- editor-wysiwyg
//- editor-markdown
q-scroll-area(
:thumb-style='thumbStyle'
:bar-style='barStyle'
style='height: 100%;'
v-else
)
.q-pa-md
div(v-html='render')
template(v-if='relations && relations.length > 0')
div(v-html='pageStore.render')
template(v-if='pageStore.relations && pageStore.relations.length > 0')
q-separator.q-my-lg
.row.align-center
.col.text-left(v-if='relationsLeft.length > 0')
@ -204,24 +135,24 @@ q-page.column
v-if='showSidebar'
style='order: 2;'
)
template(v-if='showToc')
template(v-if='pageStore.showToc')
//- TOC
.q-pa-md.flex.items-center
q-icon.q-mr-sm(name='las la-stream', color='grey')
.text-caption.text-grey-7 Contents
.q-px-md.q-pb-sm
q-tree(
:nodes='toc'
:nodes='state.toc'
node-key='key'
v-model:expanded='tocExpanded'
v-model:selected='tocSelected'
v-model:expanded='state.tocExpanded'
v-model:selected='state.tocSelected'
)
//- Tags
template(v-if='showTags')
q-separator(v-if='showToc')
template(v-if='pageStore.showTags')
q-separator(v-if='pageStore.showToc')
.q-pa-md(
@mouseover='showTagsEditBtn = true'
@mouseleave='showTagsEditBtn = false'
@mouseover='state.showTagsEditBtn = true'
@mouseleave='state.showTagsEditBtn = false'
)
.flex.items-center
q-icon.q-mr-sm(name='las la-tags', color='grey')
@ -229,7 +160,7 @@ q-page.column
q-space
transition(name='fade')
q-btn(
v-show='showTagsEditBtn'
v-show='state.showTagsEditBtn'
size='sm'
padding='none xs'
icon='las la-pen'
@ -237,24 +168,24 @@ q-page.column
flat
label='Edit'
no-caps
@click='tagEditMode = !tagEditMode'
@click='state.tagEditMode = !state.tagEditMode'
)
page-tags.q-mt-sm(:edit='tagEditMode')
template(v-if='allowRatings && ratingsMode !== `off`')
q-separator(v-if='showToc || showTags')
page-tags.q-mt-sm(:edit='state.tagEditMode')
template(v-if='pageStore.allowRatings && pageStore.ratingsMode !== `off`')
q-separator(v-if='pageStore.showToc || pageStore.showTags')
//- Rating
.q-pa-md.flex.items-center
q-icon.q-mr-sm(name='las la-star-half-alt', color='grey')
.text-caption.text-grey-7 Rate this page
.q-px-md
q-rating(
v-if='ratingsMode === `stars`'
v-model='currentRating'
v-if='pageStore.ratingsMode === `stars`'
v-model='state.currentRating'
icon='las la-star'
color='secondary'
size='sm'
)
.flex.items-center(v-else-if='ratingsMode === `thumbs`')
.flex.items-center(v-else-if='pageStore.ratingsMode === `thumbs`')
q-btn.acrylic-btn(
flat
icon='las la-thumbs-down'
@ -350,206 +281,226 @@ q-page.column
q-tooltip(anchor='center left' self='center right') Delete Page
q-dialog(
v-model='showSideDialog'
v-model='state.showSideDialog'
position='right'
full-height
transition-show='jump-left'
transition-hide='jump-right'
class='floating-sidepanel'
)
component(:is='sideDialogComponent')
component(:is='state.sideDialogComponent')
q-dialog(
v-model='showGlobalDialog'
v-model='state.showGlobalDialog'
transition-show='jump-up'
transition-hide='jump-down'
)
component(:is='globalDialogComponent')
component(:is='state.globalDialogComponent')
</template>
<script>
import { get, sync } from 'vuex-pathify'
import IconPickerDialog from '../components/IconPickerDialog.vue'
<script setup>
import { useMeta, useQuasar, setCssVar } from 'quasar'
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { usePageStore } from 'src/stores/page'
import { useSiteStore } from '../stores/site'
// COMPONENTS
import SocialSharingMenu from '../components/SocialSharingMenu.vue'
import PageDataDialog from '../components/PageDataDialog.vue'
import PageTags from '../components/PageTags.vue'
import PagePropertiesDialog from '../components/PagePropertiesDialog.vue'
import PageSaveDialog from '../components/PageSaveDialog.vue'
import EditorWysiwyg from '../components/EditorWysiwyg.vue'
export default {
name: 'PageIndex',
components: {
EditorWysiwyg,
IconPickerDialog,
PageDataDialog,
PagePropertiesDialog,
PageSaveDialog,
PageTags,
SocialSharingMenu
},
data () {
return {
showSideDialog: false,
sideDialogComponent: null,
showGlobalDialog: false,
globalDialogComponent: null,
showTagsEditBtn: false,
tagEditMode: false,
toc: [
{
key: 'h1-0',
label: 'Introduction'
},
// QUASAR
const $q = useQuasar()
// STORES
const pageStore = usePageStore()
const siteStore = useSiteStore()
// ROUTER
const router = useRouter()
const route = useRoute()
// I18N
const { t } = useI18n()
// META
useMeta({
title: pageStore.title
})
// DATA
const state = reactive({
showSideDialog: false,
sideDialogComponent: null,
showGlobalDialog: false,
globalDialogComponent: null,
showTagsEditBtn: false,
tagEditMode: false,
toc: [
{
key: 'h1-0',
label: 'Introduction'
},
{
key: 'h1-1',
label: 'Planets',
children: [
{
key: 'h1-1',
label: 'Planets',
key: 'h2-0',
label: 'Earth',
children: [
{
key: 'h2-0',
label: 'Earth',
key: 'h3-0',
label: 'Countries',
children: [
{
key: 'h3-0',
label: 'Countries',
key: 'h4-0',
label: 'Cities',
children: [
{
key: 'h4-0',
label: 'Cities',
key: 'h5-0',
label: 'Montreal',
children: [
{
key: 'h5-0',
label: 'Montreal',
children: [
{
key: 'h6-0',
label: 'Districts'
}
]
key: 'h6-0',
label: 'Districts'
}
]
}
]
}
]
},
{
key: 'h2-1',
label: 'Mars'
},
{
key: 'h2-2',
label: 'Jupiter'
}
]
},
{
key: 'h2-1',
label: 'Mars'
},
{
key: 'h2-2',
label: 'Jupiter'
}
],
tocExpanded: ['h1-0', 'h1-1'],
tocSelected: [],
currentRating: 3,
thumbStyle: {
right: '2px',
borderRadius: '5px',
backgroundColor: '#000',
width: '5px',
opacity: 0.15
},
barStyle: {
backgroundColor: '#FAFAFA',
width: '9px',
opacity: 1
}
}
},
computed: {
mode: sync('page/mode', false),
editorMode: get('page/editorMode', false),
breadcrumbs: get('page/breadcrumbs', false),
title: sync('page/title', false),
description: sync('page/description', false),
relations: get('page/relations', false),
tags: sync('page/tags', false),
ratingsMode: get('site/ratingsMode', false),
allowComments: get('page/allowComments', false),
allowContributions: get('page/allowContributions', false),
allowRatings: get('page/allowRatings', false),
showSidebar () {
return this.$store.get('page/showSidebar') && this.$store.get('site/showSidebar')
},
showTags: get('page/showTags', false),
showToc: get('page/showToc', false),
tocDepth: get('page/tocDepth', false),
isPublished: get('page/isPublished', false),
pageIcon: sync('page/icon', false),
render: get('page/render', false),
editorComponent () {
return this.$store.get('page/editor') ? `editor-${this.$store.get('page/editor')}` : null
},
relationsLeft () {
return this.relations ? this.relations.filter(r => r.position === 'left') : []
},
relationsCenter () {
return this.relations ? this.relations.filter(r => r.position === 'center') : []
},
relationsRight () {
return this.relations ? this.relations.filter(r => r.position === 'right') : []
},
editMode () {
return this.mode === 'edit'
},
editCreateMode () {
return this.mode === 'edit' && this.editorMode === 'create'
]
}
},
watch: {
toc () {
this.refreshTocExpanded()
},
tocDepth () {
this.refreshTocExpanded()
}
},
mounted () {
this.refreshTocExpanded()
},
methods: {
togglePageProperties () {
this.sideDialogComponent = 'PagePropertiesDialog'
this.showSideDialog = true
},
togglePageData () {
this.sideDialogComponent = 'PageDataDialog'
this.showSideDialog = true
},
savePage () {
this.globalDialogComponent = 'PageSaveDialog'
this.showGlobalDialog = true
},
refreshTocExpanded (baseToc) {
const toExpand = []
let isRootNode = false
if (!baseToc) {
baseToc = this.toc
isRootNode = true
}
if (baseToc.length > 0) {
for (const node of baseToc) {
if (node.key >= `h${this.tocDepth.min}` && node.key <= `h${this.tocDepth.max}`) {
toExpand.push(node.key)
}
if (node.children?.length && node.key < `h${this.tocDepth.max}`) {
toExpand.push(...this.refreshTocExpanded(node.children))
}
}
],
tocExpanded: ['h1-0', 'h1-1'],
tocSelected: [],
currentRating: 3
})
const thumbStyle = {
right: '2px',
borderRadius: '5px',
backgroundColor: '#000',
width: '5px',
opacity: 0.15
}
const barStyle = {
backgroundColor: '#FAFAFA',
width: '9px',
opacity: 1
}
// COMPUTED
const showSidebar = computed(() => {
return pageStore.showSidebar && siteStore.showSidebar
})
const editorComponent = computed(() => {
return pageStore.editor ? `editor-${pageStore.editor}` : null
})
const relationsLeft = computed(() => {
return pageStore.relations ? pageStore.relations.filter(r => r.position === 'left') : []
})
const relationsCenter = computed(() => {
return pageStore.relations ? pageStore.relations.filter(r => r.position === 'center') : []
})
const relationsRight = computed(() => {
return pageStore.relations ? pageStore.relations.filter(r => r.position === 'right') : []
})
const editMode = computed(() => {
return pageStore.mode === 'edit'
})
const editCreateMode = computed(() => {
return pageStore.mode === 'edit' && pageStore.mode === 'create'
})
const editUrl = computed(() => {
let pagePath = siteStore.useLocales ? `${pageStore.locale}/` : ''
pagePath += !pageStore.path ? 'home' : pageStore.path
return `/_edit/${pagePath}`
})
// WATCHERS
watch(() => state.toc, refreshTocExpanded)
watch(() => pageStore.tocDepth, refreshTocExpanded)
// METHODS
function getFullPath ({ locale, path }) {
if (siteStore.useLocales) {
return `/${locale}/${path}`
} else {
return `/${path}`
}
}
function togglePageProperties () {
state.sideDialogComponent = 'PagePropertiesDialog'
state.showSideDialog = true
}
function togglePageData () {
state.sideDialogComponent = 'PageDataDialog'
state.showSideDialog = true
}
function savePage () {
state.globalDialogComponent = 'PageSaveDialog'
state.showGlobalDialog = true
}
function refreshTocExpanded (baseToc) {
const toExpand = []
let isRootNode = false
if (!baseToc) {
baseToc = state.toc
isRootNode = true
}
if (baseToc.length > 0) {
for (const node of baseToc) {
if (node.key >= `h${pageStore.tocDepth.min}` && node.key <= `h${pageStore.tocDepth.max}`) {
toExpand.push(node.key)
}
if (isRootNode) {
this.tocExpanded = toExpand
} else {
return toExpand
if (node.children?.length && node.key < `h${pageStore.tocDepth.max}`) {
toExpand.push(...refreshTocExpanded(node.children))
}
}
}
if (isRootNode) {
state.tocExpanded = toExpand
} else {
return toExpand
}
}
// MOUNTED
onMounted(() => {
refreshTocExpanded()
})
</script>
<style lang="scss">

@ -1,13 +1,13 @@
const routes = [
// {
// path: '/',
// component: () => import('../layouts/MainLayout.vue'),
// children: [
// { path: '', component: () => import('../pages/Index.vue') },
// { path: 'n/:editor?', component: () => import('../pages/Index.vue') }
// ]
// },
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('../pages/Index.vue') }
// { path: 'n/:editor?', component: () => import('../pages/Index.vue') }
]
},
{
path: '/login',
component: () => import('layouts/AuthLayout.vue'),
@ -36,7 +36,6 @@ const routes = [
{ path: ':siteid/locale', component: () => import('pages/AdminLocale.vue') },
{ path: ':siteid/login', component: () => import('pages/AdminLogin.vue') },
{ path: ':siteid/navigation', component: () => import('pages/AdminNavigation.vue') },
// { path: ':siteid/rendering', component: () => import('pages/AdminRendering.vue') },
{ path: ':siteid/storage/:id?', component: () => import('pages/AdminStorage.vue') },
{ path: ':siteid/theme', component: () => import('pages/AdminTheme.vue') },
// -> Users
@ -48,6 +47,7 @@ const routes = [
{ path: 'extensions', component: () => import('pages/AdminExtensions.vue') },
{ path: 'instances', component: () => import('pages/AdminInstances.vue') },
{ path: 'mail', component: () => import('pages/AdminMail.vue') },
// { path: 'rendering', component: () => import('pages/AdminRendering.vue') },
{ path: 'scheduler', component: () => import('pages/AdminScheduler.vue') },
{ path: 'security', component: () => import('pages/AdminSecurity.vue') },
{ path: 'system', component: () => import('pages/AdminSystem.vue') },
@ -74,7 +74,10 @@ const routes = [
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue')
component: () => import('../layouts/MainLayout.vue'),
children: [
{ path: '', component: () => import('../pages/Index.vue') }
]
}
]

Loading…
Cancel
Save