Commit 99380466 authored by Yannis Barlas's avatar Yannis Barlas

feat(preview): export to HTML

parent 01cfdd3b
module.exports = {
collectCoverage: false,
collectCoverageFrom: [
'**/*.{js,jsx}',
'!**/*test.{js,jsx}',
'!**/test/**',
'!**/node_modules/**',
'!**/config/**',
'!**/coverage/**',
],
coverageDirectory: '<rootDir>/coverage',
projects: [
{
displayName: 'app',
// not needed?
moduleNameMapper: {
'\\.s?css$': 'identity-obj-proxy',
},
rootDir: '<rootDir>/app',
setupTestFrameworkScriptFile: '<rootDir>/test/setup.js',
snapshotSerializers: ['enzyme-to-json/serializer'],
transformIgnorePatterns: ['node_modules/(?!(@?pubsweet|xpub-edit))'],
},
{
displayName: 'server',
rootDir: '<rootDir>/server',
testEnvironment: 'node',
testRegex: 'server/*/.+test.jsx?$',
},
{
displayName: 'auth',
testRegex: '(?<!(app|server))/test/.+test.jsx?$',
},
],
}
......@@ -61,7 +61,7 @@ const Form = props => {
}
const Submit = props => {
const { article, loading, update, upload } = props
const { article, exportManuscript, loading, update, upload } = props
if (loading) return <Loading />
const { status } = article
......@@ -69,19 +69,19 @@ const Submit = props => {
const editableByAuthor = isEditableByAuthor(status)
/*
if not full:
!iseditable and isglobal, show form
editor will select datatype -> show form
!iseditable and !isglobal
author and you cannot edit -> show preview
iseditable and isglobal
initial -> cannot see
post-datatype -> show preview
iseditable and !isglobal
author and you can edit -> show form
if not full:
!iseditable and isglobal, show form
editor will select datatype -> show form
!iseditable and !isglobal
author and you cannot edit -> show preview
iseditable and isglobal
initial -> cannot see
post-datatype -> show preview
iseditable and !isglobal
author and you can edit -> show form
*/
return (
......@@ -96,7 +96,12 @@ const Submit = props => {
/>
)
const preview = <ArticlePreview article={article} />
const preview = (
<ArticlePreview
article={article}
exportManuscript={exportManuscript}
/>
)
let display = null
......
......@@ -5,6 +5,7 @@ import { adopt } from 'react-adopt'
import { withRouter } from 'react-router-dom'
import { getArticle, updateArticle, uploadFile } from './pieces'
import { exportManuscriptToHTML } from '../../fetch/exportManuscript'
const mapper = {
getArticle: props => getArticle(props),
......@@ -14,6 +15,7 @@ const mapper = {
const mapProps = args => ({
article: args.getArticle.data && args.getArticle.data.manuscript,
exportManuscript: exportManuscriptToHTML,
loading: args.getArticle.loading,
update: args.updateArticle.updateArticle,
upload: args.uploadFile.uploadFile,
......
......@@ -15,12 +15,16 @@ import {
} from 'lodash'
import config from 'config'
import { H2, H4, H6 } from '@pubsweet/ui'
import { Button, H2, H4, H6 } from '@pubsweet/ui'
import { th } from '@pubsweet/ui-toolkit'
import { AbstractEditor } from 'xpub-edit'
import { unCamelCase } from '../../helpers/generic'
import { isDatatypeSelected, isFullSubmissionReady } from '../../helpers/status'
import {
isAccepted,
isDatatypeSelected,
isFullSubmissionReady,
} from '../../helpers/status'
const stripHTML = html => {
const tmp = document.createElement('DIV')
......@@ -97,6 +101,19 @@ const PageHeader = styled(H2)`
${headingStyle};
`
const HeaderWrapper = styled.div`
display: flex;
justify-content: space-between;
`
const ExportWrapper = styled.div`
display: flex;
button {
align-self: center;
}
`
const SectionHeader = styled(H6)`
margin: 0;
${headingStyle};
......@@ -411,11 +428,25 @@ const Preview = props => {
}
const ArticlePreview = props => {
const { article, livePreview } = props
const { article, exportManuscript, livePreview } = props
const { id: articleId, status } = article
const accepted = isAccepted(status)
return (
<Wrapper>
{!livePreview && <PageHeader>Article Preview</PageHeader>}
{!livePreview && (
<HeaderWrapper>
<PageHeader>Article Preview</PageHeader>
{accepted && (
<ExportWrapper>
<Button onClick={() => exportManuscript(articleId)} primary>
Export to HTML
</Button>
</ExportWrapper>
)}
</HeaderWrapper>
)}
{article && <Preview article={article} livePreview={livePreview} />}
{!article && 'No data'}
......
import config from 'config'
const { baseUrl } = config['pubsweet-client']
const apiUrl = `${baseUrl}/api/export`
const exportManuscriptToHTML = articleId => {
window.location.assign(`${apiUrl}/${articleId}/html`)
}
/* eslint-disable import/prefer-default-export */
export { exportManuscriptToHTML }
......@@ -8,6 +8,7 @@
"./server/manuscript/src",
"./server/review/src",
"./server/api",
"./server/export",
"./server/models/externalTeam",
"./server/models/externalUser"
]
{
"name": "wormbase",
"name": "micropublication",
"version": "0.6.0",
"description": "Wormbase article submission and review system",
"description": "microPublication article submission and review system",
"repository": {
"type": "git",
"url": "https://gitlab.coko.foundation/micropubs/wormbase"
},
"keywords": [
"pubsweet",
"micropubs",
"micropublication",
"wormbase"
],
"author": "Yannis Barlas",
......@@ -20,6 +20,7 @@
"@pubsweet/logger": "^0.2.7",
"@pubsweet/ui": "^8.7.0",
"@pubsweet/ui-toolkit": "1.2.0",
"ajv": "^6.10.0",
"app-module-path": "^2.2.0",
"babel-preset-es2015-native-modules": "^6.9.4",
"babel-preset-minify": "^0.5.0-alpha.3cc09dcf",
......@@ -38,7 +39,9 @@
"html-webpack-plugin": "^3.2.0",
"joi": "^13.3.0",
"joi-browser": "^13.0.1",
"js-beautify": "^1.9.1",
"json-loader": "^0.5.7",
"jszip": "^3.2.1",
"lodash": "^4.17.10",
"longest": "^2.0.1",
"nodemailer-mailgun-transport": "^1.4.0",
......@@ -141,50 +144,12 @@
"server": "pubsweet server",
"server:production": "./scripts/runProductionServer.sh",
"setupdb": "pubsweet setupdb && npm run seed",
"test": "jest --maxWorkers=1",
"test": "jest --maxWorkers=1 --config=.jest.config.js",
"testcafe": "source ./config/test.env && testcafe chromium e2e/"
},
"config": {
"commitizen": {
"path": "cz-customizable"
}
},
"jest": {
"collectCoverageFrom": [
"**/*.{js,jsx}",
"!**/*test.{js,jsx}",
"!**/test/**",
"!**/node_modules/**",
"!**/config/**",
"!**/coverage/**"
],
"coverageDirectory": "<rootDir>/coverage",
"collectCoverage": false,
"projects": [
{
"rootDir": "<rootDir>/app",
"displayName": "app",
"setupTestFrameworkScriptFile": "<rootDir>/test/setup.js",
"transformIgnorePatterns": [
"node_modules/(?!(@?pubsweet|xpub-edit))"
],
"snapshotSerializers": [
"enzyme-to-json/serializer"
],
"moduleNameMapper": {
"\\.s?css$": "identity-obj-proxy"
}
},
{
"rootDir": "<rootDir>/server",
"displayName": "server",
"testRegex": "server/*/.+test.jsx?$",
"testEnvironment": "node"
},
{
"displayName": "auth",
"testRegex": "(?<!(app|server))/test/.+test.jsx?$"
}
]
}
}
{
ignore: ["*"]
}
const Ajv = require('ajv')
const beautify = require('js-beautify').html
const uniq = require('lodash/uniq')
const logger = require('@pubsweet/logger')
const errorMessage = 'HTML file creation:'
/* Helpers */
const contributionAuthor = name => /* html */ `
<span data-id="author-contributions-item-name">
${name}:
</span>
`
const contributionCredit = credit => {
const data = credit
.map(c => unCamelCase(c))
.map(item => contributionCreditItem(item))
.join(', ')
return /* html */ `
<span data-id="author-contributions-item-credit-section">
${data}
</span>
`
}
const contributionCreditItem = text => /* html */ `
<span data-id="credit-section-item">
${text}
</span>
`
const contributionItem = author => /* html */ `
<span data-id="author-contributions-item">
${contributionAuthor(author.name)}
${contributionCredit(author.credit)}
</span>
`
const getFieldValues = (list, field) => {
const values = list.map(item => item[field])
return values
}
const textSectionWithHeader = ({ data, label, name }) => /* html */ `
<div data-id="${name}">
<h2 data-id="${name}-header">
${label}
</h2>
<div data-id="${name}-content">
${data}
</div>
</div>
`
const unCamelCase = string =>
string.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())
/* End Helpers */
/* Parts */
const authorContributionsEl = authors => {
const data = authors.map(author => ({
credit: author.credit,
name: author.name,
}))
const items = data.map(item => contributionItem(item)).join('; ')
return /* html */ `
<div data-id="author-contributions-section">
${items}
</div>
`
}
const authorsEl = authors => {
const names = getFieldValues(authors, 'name')
const nameEls = names
.map(name => /* html */ `<span data-id="author-name">${name}</span>`)
.join(', ')
const affiliations = getFieldValues(authors, 'affiliations')
const affiliationsEls = uniq(affiliations)
.map(
affiliation =>
/* html */ `<div data-id="author-affiliation">${affiliation}</div>`,
)
.join('')
return /* html */ `
<div data-id="author-section">
<div data-id="author-names">
${nameEls}
</div>
<div data-id="author-affiliations">
${affiliationsEls}
</div>
</div>
`
}
const copyRight = /* html */ `
<div data-id="copyright-section">
<span data-id="copyright-header">
Copyright
</span>
<span data-id="copyright-content">
© 2019 by the authors. This is an open-access article distributed
under the terms of the Creative Commons Attribution 4.0 International
(CC BY 4.0) License, which permits unrestricted use, distribution, and
reproduction in any medium, provided the original author and source are
credited.
</span>
</div>
`
const descriptionEl = description =>
textSectionWithHeader({
data: description,
label: 'Description',
name: 'description',
})
const fundingEl = funding =>
textSectionWithHeader({
data: funding,
label: 'Funding',
name: 'funding',
})
const imageEl = (imageSrc, imageCaption) => /* html */ `
<div data-id="image-section">
<figure>
<img data-id="image" src="${imageSrc}" />
<figcaption>
Figure 1. ${imageCaption}
</figcaption>
</figure>
</div>
`
const methodsEl = methods =>
textSectionWithHeader({
data: methods,
label: 'Methods',
name: 'methods',
})
const referencesEl = references =>
textSectionWithHeader({
data: references,
label: 'References',
name: 'references',
})
const titleEl = title => /* html */ `
<h1 data-id="title">
${title}
</h1>
`
/* End Parts */
/*
Create complete HTML string
reviewed by -- don't have name
received / accepted / published online -- don't have dates!
citation -- don't have author first/last names
*/
const createHTML = (manuscript, imageSrc) => `
${titleEl(manuscript.title)}
${authorsEl(manuscript.authors)}
${imageEl(imageSrc, manuscript.imageCaption)}
${descriptionEl(manuscript.patternDescription)}
${methodsEl(manuscript.methods)}
${referencesEl(manuscript.references)}
${fundingEl(manuscript.funding)}
${authorContributionsEl(manuscript.authors)}
${copyRight}
`
/* Validation */
/* eslint-disable sort-keys */
const stringNotEmpty = {
type: 'string',
minLength: 1,
}
const arrayOfStringsNotEmpty = {
type: 'array',
items: stringNotEmpty,
minItems: 1,
}
const arrayNotEmpty = keys => ({
...keys,
type: 'array',
minItems: 1,
})
const author = {
type: 'object',
properties: {
affiliations: stringNotEmpty,
credit: arrayOfStringsNotEmpty,
name: stringNotEmpty,
},
required: ['affiliations', 'credit', 'name'],
}
const authors = arrayNotEmpty({ items: author })
const schema = {
type: 'object',
properties: {
authors,
funding: stringNotEmpty,
imageCaption: stringNotEmpty,
methods: stringNotEmpty,
patternDescription: stringNotEmpty,
references: stringNotEmpty,
title: stringNotEmpty,
},
required: [
'authors',
'funding',
'imageCaption',
'methods',
'patternDescription',
'references',
'title',
],
}
/* eslint-enable sort-keys */
const validate = (manuscript, imageSrc) => {
const errorText = `${errorMessage} Validate:`
if (!imageSrc) {
logger.error(`${errorText} No image src string provided`)
return false
}
const ajv = new Ajv()
const valid = ajv.validate(schema, manuscript)
// if (!valid) logger.error(`${errorText} ${ajv.errorsText()}`)
return valid
}
/* End Validation */
const output = (manuscript, imageSrc) => {
const valid = validate(manuscript, imageSrc)
if (!valid) return null
const html = createHTML(manuscript, imageSrc)
return beautify(html)
}
module.exports = output
const beautify = require('js-beautify').html
const omit = require('lodash/omit')
const HTMLExport = require('../HTML')
const manuscript = require('../../../test/helpers/manuscript')
const manuscriptHTML = require('../../../test/helpers/manuscriptHTMLOutput')
const imageSrc = manuscript.image.url.substring(1)
// Standardize format of HTML strings, so that they can be compared
const format = html =>
beautify(html, {
indent_size: 4,
preserve_newlines: false,
})
describe('HTML file creation from manuscript', () => {
test('Should handle missing image src', () => {
const output = HTMLExport(manuscript, null)
expect(output).toBeNull()
})
test('Should handle missing manuscript', () => {
const output = HTMLExport(null, imageSrc)
expect(output).toBeNull()
})
test('Should handle empty manuscript', () => {
const output = HTMLExport({}, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing title', () => {
const input = omit(manuscript, 'title')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.title = ''
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing or malformed authors', () => {
const input = omit(manuscript, 'authors')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.authors = []
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.authors = [{ key: 'value' }]
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.authors = [
{
affiliations: 'University of Chicago',
credit: ['Software'],
},
]
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing image caption', () => {
const input = omit(manuscript, 'imageCaption')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.imageCaption = ''
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing pattern description', () => {
const input = omit(manuscript, 'patternDescription')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.patternDescription = ''
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing methods', () => {
const input = omit(manuscript, 'methods')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.methods = ''
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing references', () => {
const input = omit(manuscript, 'references')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.references = ''
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should handle missing funding', () => {
const input = omit(manuscript, 'funding')
let output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
input.funding = ''
output = HTMLExport(input, imageSrc)
expect(output).toBeNull()
})
test('Should return HTML string', () => {
const output = HTMLExport(manuscript, imageSrc)
expect(output).not.toBeNull()
const result = format(output)
const expected = format(manuscriptHTML)
expect(result).toEqual(expected)
})
})
// import HTMLExport from '../services/HTMLExport'
const fs = require('fs')
const path = require('path')
const { promisify } = require('util')
const JSZip = require('jszip')
const logger = require('@pubsweet/logger')
const Manuscript = require('../manuscript/src/manuscript')
const HTMLExport = require('./HTML')
const readFile = promisify(fs.readFile)
const writeFile = promisify(fs.writeFile)
const exportEndpoints = app => {
app.get('/api/export/:manuscriptId/html', async (req, res) => {
const { manuscriptId } = req.params
// Get manuscript
let manuscript
try {
manuscript = await Manuscript.query().findById(manuscriptId)
} catch (e) {
logger.error(e)
throw new Error(e)
}
// Define names and paths for files
const htmlFileName = `${manuscriptId}.html`
const htmlFilePath = path.join(__dirname, '_tempFileStorage', htmlFileName)
const imageFileName = manuscript.image.url.substring(1)
const imageFilePath = path.join(
__dirname,
'..',
'..',
'uploads',
imageFileName,
)
const zip = new JSZip()
const zipFileName = `${manuscript.id}.zip`
const zipFilePath = path.join(__dirname, '_tempFileStorage', zipFileName)
const deleteFiles = () => {
const filesToDelete = [htmlFilePath, zipFilePath]
filesToDelete.forEach(file => {
if (fs.existsSync(file)) {
fs.unlink(file, err => {
if (err) {
logger.error(err)
return
}
logger.info(`${file} deleted from temp folder`)
<