mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #32857 from overleaf/ds-pandoc-import-md
[WEB + CLSI] Import markdown files using pandoc GitOrigin-RevId: adad7831ddb13a8fcb8063871166bde13cbbf1b6
This commit is contained in:
committed by
Copybot
parent
44efc9d745
commit
eddcc5a42e
@@ -125,10 +125,20 @@ app.get(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Conversion endpoints
|
// Conversion endpoints
|
||||||
|
// Keep old route for backwards compatibility during CLSI/web deploy transition
|
||||||
app.post(
|
app.post(
|
||||||
'/convert/docx-to-latex',
|
'/convert/docx-to-latex',
|
||||||
FileUploadMiddleware.multerMiddleware,
|
FileUploadMiddleware.multerMiddleware,
|
||||||
ConversionController.convertDocxToLaTeX
|
(req, res, next) => {
|
||||||
|
req.query.type = 'docx'
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
ConversionController.convertDocumentToLaTeX
|
||||||
|
)
|
||||||
|
app.post(
|
||||||
|
'/convert/document-to-latex',
|
||||||
|
FileUploadMiddleware.multerMiddleware,
|
||||||
|
ConversionController.convertDocumentToLaTeX
|
||||||
)
|
)
|
||||||
app.post(
|
app.post(
|
||||||
'/project/:project_id/user/:user_id/download/project-to-document',
|
'/project/:project_id/user/:user_id/download/project-to-document',
|
||||||
|
|||||||
@@ -11,19 +11,25 @@ import Path from 'node:path'
|
|||||||
|
|
||||||
const SUPPORTED_CONVERSION_TYPES = new Map([['docx', 'docx']])
|
const SUPPORTED_CONVERSION_TYPES = new Map([['docx', 'docx']])
|
||||||
|
|
||||||
async function convertDocxToLaTeX(req, res) {
|
async function convertDocumentToLaTeX(req, res) {
|
||||||
const { path } = req.file
|
const { path } = req.file
|
||||||
|
const conversionType = req.query.type
|
||||||
if (!Settings.enablePandocConversions) {
|
if (!Settings.enablePandocConversions) {
|
||||||
await fs.unlink(path).catch(() => {})
|
await fs.unlink(path).catch(() => {})
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
logger.debug({ path }, 'received file for conversion')
|
if (!conversionType || !['docx', 'markdown'].includes(conversionType)) {
|
||||||
|
await fs.unlink(path).catch(() => {})
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
logger.debug({ path, conversionType }, 'received file for conversion')
|
||||||
const conversionId = crypto.randomUUID()
|
const conversionId = crypto.randomUUID()
|
||||||
let zipPath
|
let zipPath
|
||||||
try {
|
try {
|
||||||
zipPath = await ConversionManager.promises.convertDocxToLaTeXWithLock(
|
zipPath = await ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
conversionId,
|
conversionId,
|
||||||
path
|
path,
|
||||||
|
conversionType
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await fs.unlink(path).catch(() => {})
|
await fs.unlink(path).catch(() => {})
|
||||||
@@ -98,6 +104,6 @@ async function convertProjectToDocument(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
convertDocxToLaTeX: expressify(convertDocxToLaTeX),
|
convertDocumentToLaTeX: expressify(convertDocumentToLaTeX),
|
||||||
convertProjectToDocument: expressify(convertProjectToDocument),
|
convertProjectToDocument: expressify(convertProjectToDocument),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,44 @@ import CommandRunner from './CommandRunner.js'
|
|||||||
import LockManager from './LockManager.js'
|
import LockManager from './LockManager.js'
|
||||||
import OError from '@overleaf/o-error'
|
import OError from '@overleaf/o-error'
|
||||||
|
|
||||||
async function convertDocxToLaTeXWithLock(conversionId, inputPath) {
|
const CONVERSION_CONFIGS = {
|
||||||
|
docx: {
|
||||||
|
inputFilename: 'input.docx',
|
||||||
|
pandocArgs: ['--extract-media=.', '--from', 'docx+citations', '--citeproc'],
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
inputFilename: 'input.md',
|
||||||
|
pandocArgs: ['--from', 'markdown'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertToLaTeXWithLock(conversionId, inputPath, conversionType) {
|
||||||
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
const conversionDir = Path.join(Settings.path.compilesDir, conversionId)
|
||||||
const lock = LockManager.acquire(conversionDir)
|
const lock = LockManager.acquire(conversionDir)
|
||||||
try {
|
try {
|
||||||
return await convertDocxToLaTeX(conversionId, conversionDir, inputPath)
|
return await convertToLaTeX(
|
||||||
|
conversionId,
|
||||||
|
conversionDir,
|
||||||
|
inputPath,
|
||||||
|
conversionType
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
lock.release()
|
lock.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertDocxToLaTeX(conversionId, conversionDir, inputPath) {
|
async function convertToLaTeX(
|
||||||
|
conversionId,
|
||||||
|
conversionDir,
|
||||||
|
inputPath,
|
||||||
|
conversionType
|
||||||
|
) {
|
||||||
|
const config = CONVERSION_CONFIGS[conversionType]
|
||||||
|
if (!config) {
|
||||||
|
throw new OError('unsupported conversion type', { conversionType })
|
||||||
|
}
|
||||||
await fs.mkdir(conversionDir, { recursive: true })
|
await fs.mkdir(conversionDir, { recursive: true })
|
||||||
const newSourcePath = Path.join(conversionDir, 'input.docx')
|
const newSourcePath = Path.join(conversionDir, config.inputFilename)
|
||||||
await fs.copyFile(inputPath, newSourcePath)
|
await fs.copyFile(inputPath, newSourcePath)
|
||||||
const outputName = crypto.randomUUID() + '.zip'
|
const outputName = crypto.randomUUID() + '.zip'
|
||||||
|
|
||||||
@@ -31,16 +56,13 @@ async function convertDocxToLaTeX(conversionId, conversionDir, inputPath) {
|
|||||||
conversionId,
|
conversionId,
|
||||||
[
|
[
|
||||||
'pandoc',
|
'pandoc',
|
||||||
'input.docx',
|
config.inputFilename,
|
||||||
'--output',
|
'--output',
|
||||||
'main.tex',
|
'main.tex',
|
||||||
'--extract-media=.',
|
|
||||||
'--from',
|
|
||||||
'docx+citations',
|
|
||||||
'--to',
|
'--to',
|
||||||
'latex',
|
'latex',
|
||||||
'--citeproc',
|
|
||||||
'--standalone',
|
'--standalone',
|
||||||
|
...config.pandocArgs,
|
||||||
],
|
],
|
||||||
conversionDir,
|
conversionDir,
|
||||||
Settings.pandocImage,
|
Settings.pandocImage,
|
||||||
@@ -164,7 +186,7 @@ async function convertLaTeXToDocumentInDir(
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
promises: {
|
promises: {
|
||||||
convertDocxToLaTeXWithLock,
|
convertToLaTeXWithLock,
|
||||||
convertLaTeXToDocumentInDirWithLock,
|
convertLaTeXToDocumentInDirWithLock,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ describe('Conversions', function () {
|
|||||||
const outputStream = fs.createWriteStream(
|
const outputStream = fs.createWriteStream(
|
||||||
'/tmp/clsi_acceptance_tests_' + crypto.randomUUID() + '.zip'
|
'/tmp/clsi_acceptance_tests_' + crypto.randomUUID() + '.zip'
|
||||||
)
|
)
|
||||||
const stream = await Client.convertDocx(sourcePath)
|
const stream = await Client.convertDocument(sourcePath, 'docx')
|
||||||
await pipeline(stream, outputStream)
|
await pipeline(stream, outputStream)
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -77,7 +77,8 @@ describe('Conversions', function () {
|
|||||||
import.meta.dirname,
|
import.meta.dirname,
|
||||||
'../fixtures/minimal.pdf'
|
'../fixtures/minimal.pdf'
|
||||||
)
|
)
|
||||||
await expect(Client.convertDocx(sourcePath)).to.eventually.be.rejected
|
await expect(Client.convertDocument(sourcePath, 'docx')).to.eventually.be
|
||||||
|
.rejected
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ function compile(projectId, data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function convertDocx(path) {
|
async function convertDocument(path, type) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('qqfile', fs.createReadStream(path))
|
formData.append('qqfile', fs.createReadStream(path))
|
||||||
return await fetchStream(`${host}/convert/docx-to-latex`, {
|
return await fetchStream(`${host}/convert/document-to-latex?type=${type}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
@@ -202,7 +202,7 @@ function smokeTest() {
|
|||||||
export default {
|
export default {
|
||||||
randomId,
|
randomId,
|
||||||
compile,
|
compile,
|
||||||
convertDocx,
|
convertDocument,
|
||||||
stopCompile,
|
stopCompile,
|
||||||
clearCache,
|
clearCache,
|
||||||
getOutputFile,
|
getOutputFile,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ describe('ConversionController', function () {
|
|||||||
ctx.parsedRequest = { rootResourcePath: 'main.tex' }
|
ctx.parsedRequest = { rootResourcePath: 'main.tex' }
|
||||||
ctx.ConversionManager = {
|
ctx.ConversionManager = {
|
||||||
promises: {
|
promises: {
|
||||||
convertDocxToLaTeXWithLock: sinon.stub().resolves(ctx.zipPath),
|
convertToLaTeXWithLock: sinon.stub().resolves(ctx.zipPath),
|
||||||
convertLaTeXToDocumentInDirWithLock: sinon
|
convertLaTeXToDocumentInDirWithLock: sinon
|
||||||
.stub()
|
.stub()
|
||||||
.resolves(ctx.documentPath),
|
.resolves(ctx.documentPath),
|
||||||
@@ -86,16 +86,17 @@ describe('ConversionController', function () {
|
|||||||
ctx.ConversionController = (await import(MODULE_PATH)).default
|
ctx.ConversionController = (await import(MODULE_PATH)).default
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('convertDocxToLaTeX', function () {
|
describe('convertDocumentToLaTeX', function () {
|
||||||
describe('when conversions are disabled', function () {
|
describe('when conversions are disabled', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.Settings.enablePandocConversions = false
|
ctx.Settings.enablePandocConversions = false
|
||||||
ctx.req = {
|
ctx.req = {
|
||||||
file: { path: '/path/to/uploaded/file.docx' },
|
file: { path: '/path/to/uploaded/file.docx' },
|
||||||
|
query: { type: 'docx' },
|
||||||
}
|
}
|
||||||
ctx.res.sendStatus = sinon.stub()
|
ctx.res.sendStatus = sinon.stub()
|
||||||
|
|
||||||
await ctx.ConversionController.convertDocxToLaTeX(ctx.req, ctx.res)
|
await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remove the uploaded file', function (ctx) {
|
it('should remove the uploaded file', function (ctx) {
|
||||||
@@ -108,7 +109,59 @@ describe('ConversionController', function () {
|
|||||||
|
|
||||||
it('should not call the conversion manager', function (ctx) {
|
it('should not call the conversion manager', function (ctx) {
|
||||||
sinon.assert.notCalled(
|
sinon.assert.notCalled(
|
||||||
ctx.ConversionManager.promises.convertDocxToLaTeXWithLock
|
ctx.ConversionManager.promises.convertToLaTeXWithLock
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when conversionType is missing', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.req = {
|
||||||
|
file: { path: '/path/to/uploaded/file.docx' },
|
||||||
|
query: {},
|
||||||
|
}
|
||||||
|
ctx.res.sendStatus = sinon.stub()
|
||||||
|
|
||||||
|
await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the uploaded file', function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400', function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.res.sendStatus, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call the conversion manager', function (ctx) {
|
||||||
|
sinon.assert.notCalled(
|
||||||
|
ctx.ConversionManager.promises.convertToLaTeXWithLock
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('when conversionType is unsupported', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.req = {
|
||||||
|
file: { path: '/path/to/uploaded/file.docx' },
|
||||||
|
query: { type: 'invalid' },
|
||||||
|
}
|
||||||
|
ctx.res.sendStatus = sinon.stub()
|
||||||
|
|
||||||
|
await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the uploaded file', function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.unlink, ctx.req.file.path)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 400', function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.res.sendStatus, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call the conversion manager', function (ctx) {
|
||||||
|
sinon.assert.notCalled(
|
||||||
|
ctx.ConversionManager.promises.convertToLaTeXWithLock
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -117,18 +170,20 @@ describe('ConversionController', function () {
|
|||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.req = {
|
ctx.req = {
|
||||||
file: { path: '/path/to/uploaded/file.docx' },
|
file: { path: '/path/to/uploaded/file.docx' },
|
||||||
|
query: { type: 'docx' },
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.ConversionController.convertDocxToLaTeX(ctx.req, ctx.res)
|
await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call the conversion manager with the uploaded file path', function (ctx) {
|
it('should call the conversion manager with the uploaded file path and type', function (ctx) {
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(
|
||||||
ctx.ConversionManager.promises.convertDocxToLaTeXWithLock,
|
ctx.ConversionManager.promises.convertToLaTeXWithLock,
|
||||||
sinon.match(
|
sinon.match(
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||||
),
|
),
|
||||||
ctx.req.file.path
|
ctx.req.file.path,
|
||||||
|
'docx'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -160,6 +215,28 @@ describe('ConversionController', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('with conversionType=markdown', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.req = {
|
||||||
|
file: { path: '/path/to/uploaded/file.md' },
|
||||||
|
query: { type: 'markdown' },
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.ConversionController.convertDocumentToLaTeX(ctx.req, ctx.res)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call the conversion manager with the uploaded file path and markdown type', function (ctx) {
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.ConversionManager.promises.convertToLaTeXWithLock,
|
||||||
|
sinon.match(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||||
|
),
|
||||||
|
ctx.req.file.path,
|
||||||
|
'markdown'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('unsuccessfully', function () {
|
describe('unsuccessfully', function () {
|
||||||
describe('on streaming error', function () {
|
describe('on streaming error', function () {
|
||||||
it('should propagate the error and still clean up', async function (ctx) {
|
it('should propagate the error and still clean up', async function (ctx) {
|
||||||
@@ -169,10 +246,13 @@ describe('ConversionController', function () {
|
|||||||
res.attachment = sinon.stub()
|
res.attachment = sinon.stub()
|
||||||
res.setHeader = sinon.stub()
|
res.setHeader = sinon.stub()
|
||||||
|
|
||||||
const req = { file: { path: '/path/to/uploaded/file.docx' } }
|
const req = {
|
||||||
|
file: { path: '/path/to/uploaded/file.docx' },
|
||||||
|
query: { type: 'docx' },
|
||||||
|
}
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ctx.ConversionController.convertDocxToLaTeX(req, res)
|
ctx.ConversionController.convertDocumentToLaTeX(req, res)
|
||||||
).to.be.rejectedWith('mock stream error')
|
).to.be.rejectedWith('mock stream error')
|
||||||
|
|
||||||
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
|
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ describe('ConversionManager', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.conversionId = 'test-conversion-id'
|
ctx.conversionId = 'test-conversion-id'
|
||||||
ctx.inputPath = '/path/to/input.docx'
|
|
||||||
ctx.conversionDir = '/compiles/test-conversion-id'
|
ctx.conversionDir = '/compiles/test-conversion-id'
|
||||||
ctx.outputPath = '/compiles/test-conversion-id/output-uuid.zip'
|
ctx.outputPath = '/compiles/test-conversion-id/output-uuid.zip'
|
||||||
|
|
||||||
@@ -65,188 +64,287 @@ describe('ConversionManager', function () {
|
|||||||
ctx.uuidStub.restore()
|
ctx.uuidStub.restore()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('convertDocxToLaTeXWithLock', function () {
|
describe('convertToLaTeXWithLock', function () {
|
||||||
describe('general behavior', function () {
|
describe('with conversionType=docx', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(function (ctx) {
|
||||||
ctx.result =
|
ctx.inputPath = '/path/to/input.docx'
|
||||||
await ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
|
})
|
||||||
ctx.conversionId,
|
|
||||||
ctx.inputPath
|
describe('file setup and pandoc args', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.result =
|
||||||
|
await ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
|
ctx.conversionId,
|
||||||
|
ctx.inputPath,
|
||||||
|
'docx'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should acquire a lock', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy the input file to the conversion directory with docx filename', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.fs.copyFile,
|
||||||
|
ctx.inputPath,
|
||||||
|
Path.join(ctx.conversionDir, 'input.docx')
|
||||||
)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it('should acquire a lock', async function (ctx) {
|
|
||||||
sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should copy the input file to the conversion directory', async function (ctx) {
|
|
||||||
sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, {
|
|
||||||
recursive: true,
|
|
||||||
})
|
|
||||||
sinon.assert.calledWith(
|
|
||||||
ctx.fs.copyFile,
|
|
||||||
ctx.inputPath,
|
|
||||||
Path.join(ctx.conversionDir, 'input.docx')
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should convert conversion timeout to milliseconds', async function (ctx) {
|
|
||||||
expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
|
|
||||||
expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should run pandoc followed by zip in the conversion directory', function (ctx) {
|
|
||||||
expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
|
|
||||||
expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
|
|
||||||
ctx.conversionId,
|
|
||||||
[
|
|
||||||
'pandoc',
|
|
||||||
'input.docx',
|
|
||||||
'--output',
|
|
||||||
'main.tex',
|
|
||||||
'--extract-media=.',
|
|
||||||
'--from',
|
|
||||||
'docx+citations',
|
|
||||||
'--to',
|
|
||||||
'latex',
|
|
||||||
'--citeproc',
|
|
||||||
'--standalone',
|
|
||||||
],
|
|
||||||
ctx.conversionDir,
|
|
||||||
ctx.Settings.pandocImage,
|
|
||||||
60_000,
|
|
||||||
{},
|
|
||||||
'conversions',
|
|
||||||
])
|
|
||||||
expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([
|
|
||||||
ctx.conversionId,
|
|
||||||
['zip', '-r', 'output-uuid.zip', '.'],
|
|
||||||
ctx.conversionDir,
|
|
||||||
ctx.Settings.pandocImage,
|
|
||||||
60_000,
|
|
||||||
{},
|
|
||||||
'conversions',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('successful conversion', function () {
|
|
||||||
beforeEach(async function (ctx) {
|
|
||||||
ctx.CommandRunner.promises.run.resolves({
|
|
||||||
stdout: 'mock-stdout',
|
|
||||||
stderr: 'mock-stderr',
|
|
||||||
exitCode: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.result =
|
it('should convert conversion timeout to milliseconds', async function (ctx) {
|
||||||
await ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
|
expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
|
||||||
|
expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run pandoc with docx args followed by zip', function (ctx) {
|
||||||
|
expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
|
||||||
|
expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
|
||||||
ctx.conversionId,
|
ctx.conversionId,
|
||||||
ctx.inputPath
|
[
|
||||||
)
|
'pandoc',
|
||||||
})
|
'input.docx',
|
||||||
|
'--output',
|
||||||
it('should remove the source document after conversion', async function (ctx) {
|
'main.tex',
|
||||||
sinon.assert.calledWith(
|
'--to',
|
||||||
ctx.fs.unlink,
|
'latex',
|
||||||
Path.join(ctx.conversionDir, 'input.docx')
|
'--standalone',
|
||||||
)
|
'--extract-media=.',
|
||||||
})
|
'--from',
|
||||||
|
'docx+citations',
|
||||||
it('should return the conversion directory', function (ctx) {
|
'--citeproc',
|
||||||
expect(ctx.result).toBe(ctx.outputPath)
|
],
|
||||||
})
|
ctx.conversionDir,
|
||||||
|
ctx.Settings.pandocImage,
|
||||||
it('should release the lock', function (ctx) {
|
60_000,
|
||||||
sinon.assert.called(ctx.lock.release)
|
{},
|
||||||
})
|
'conversions',
|
||||||
})
|
])
|
||||||
|
expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([
|
||||||
describe('unsuccessful conversion (exitcode)', function () {
|
|
||||||
beforeEach(async function (ctx) {
|
|
||||||
ctx.CommandRunner.promises.run.resolves({
|
|
||||||
stdout: 'mock-stdout',
|
|
||||||
stderr: 'mock-stderr',
|
|
||||||
exitCode: 63,
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
|
|
||||||
ctx.conversionId,
|
ctx.conversionId,
|
||||||
ctx.inputPath
|
['zip', '-r', 'output-uuid.zip', '.'],
|
||||||
)
|
ctx.conversionDir,
|
||||||
).to.be.rejectedWith('pandoc conversion failed')
|
ctx.Settings.pandocImage,
|
||||||
})
|
60_000,
|
||||||
|
{},
|
||||||
it('should remove the entire conversion directory', async function (ctx) {
|
'conversions',
|
||||||
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
|
])
|
||||||
force: true,
|
|
||||||
recursive: true,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should release the lock', function (ctx) {
|
describe('successful conversion', function () {
|
||||||
sinon.assert.called(ctx.lock.release)
|
beforeEach(async function (ctx) {
|
||||||
})
|
ctx.CommandRunner.promises.run.resolves({
|
||||||
})
|
stdout: 'mock-stdout',
|
||||||
|
stderr: 'mock-stderr',
|
||||||
describe('unsuccessful compression (exitcode)', function () {
|
|
||||||
beforeEach(async function (ctx) {
|
|
||||||
ctx.CommandRunner.promises.run
|
|
||||||
.onFirstCall()
|
|
||||||
.resolves({
|
|
||||||
stdout: 'mock-pandoc-stdout',
|
|
||||||
stderr: 'mock-pandoc-stderr',
|
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
})
|
})
|
||||||
.onSecondCall()
|
|
||||||
.resolves({
|
|
||||||
stdout: 'mock-zip-stdout',
|
|
||||||
stderr: 'mock-zip-stderr',
|
|
||||||
exitCode: 12,
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(
|
ctx.result =
|
||||||
ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
|
await ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
ctx.conversionId,
|
ctx.conversionId,
|
||||||
ctx.inputPath
|
ctx.inputPath,
|
||||||
|
'docx'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the source document after conversion', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.fs.unlink,
|
||||||
|
Path.join(ctx.conversionDir, 'input.docx')
|
||||||
)
|
)
|
||||||
).to.be.rejectedWith('pandoc conversion failed')
|
})
|
||||||
})
|
|
||||||
|
|
||||||
it('should remove the entire conversion directory', async function (ctx) {
|
it('should return the output zip path', function (ctx) {
|
||||||
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
|
expect(ctx.result).toBe(ctx.outputPath)
|
||||||
force: true,
|
})
|
||||||
recursive: true,
|
|
||||||
|
it('should release the lock', function (ctx) {
|
||||||
|
sinon.assert.called(ctx.lock.release)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should release the lock', function (ctx) {
|
describe('unsuccessful conversion (exitcode)', function () {
|
||||||
sinon.assert.called(ctx.lock.release)
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.CommandRunner.promises.run.resolves({
|
||||||
|
stdout: 'mock-stdout',
|
||||||
|
stderr: 'mock-stderr',
|
||||||
|
exitCode: 63,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
|
ctx.conversionId,
|
||||||
|
ctx.inputPath,
|
||||||
|
'docx'
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith('pandoc conversion failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the entire conversion directory', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
|
||||||
|
force: true,
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release the lock', function (ctx) {
|
||||||
|
sinon.assert.called(ctx.lock.release)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unsuccessful compression (exitcode)', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.CommandRunner.promises.run
|
||||||
|
.onFirstCall()
|
||||||
|
.resolves({
|
||||||
|
stdout: 'mock-pandoc-stdout',
|
||||||
|
stderr: 'mock-pandoc-stderr',
|
||||||
|
exitCode: 0,
|
||||||
|
})
|
||||||
|
.onSecondCall()
|
||||||
|
.resolves({
|
||||||
|
stdout: 'mock-zip-stdout',
|
||||||
|
stderr: 'mock-zip-stderr',
|
||||||
|
exitCode: 12,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
|
ctx.conversionId,
|
||||||
|
ctx.inputPath,
|
||||||
|
'docx'
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith('pandoc conversion failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the entire conversion directory', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
|
||||||
|
force: true,
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release the lock', function (ctx) {
|
||||||
|
sinon.assert.called(ctx.lock.release)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unsuccessful conversion (throws)', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.CommandRunner.promises.run.rejects(
|
||||||
|
new Error('mock conversion error')
|
||||||
|
)
|
||||||
|
await expect(
|
||||||
|
ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
|
ctx.conversionId,
|
||||||
|
ctx.inputPath,
|
||||||
|
'docx'
|
||||||
|
)
|
||||||
|
).to.be.rejectedWith('pandoc conversion failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the entire conversion directory', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
|
||||||
|
force: true,
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release the lock', function (ctx) {
|
||||||
|
sinon.assert.called(ctx.lock.release)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('unsuccessful conversion (throws)', function () {
|
describe('with conversionType=markdown', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(function (ctx) {
|
||||||
ctx.CommandRunner.promises.run.rejects(
|
ctx.inputPath = '/path/to/input.md'
|
||||||
new Error('mock conversion error')
|
|
||||||
)
|
|
||||||
await expect(
|
|
||||||
ctx.ConversionManager.promises.convertDocxToLaTeXWithLock(
|
|
||||||
ctx.conversionId,
|
|
||||||
ctx.inputPath
|
|
||||||
)
|
|
||||||
).to.be.rejectedWith('pandoc conversion failed')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remove the entire conversion directory', async function (ctx) {
|
describe('file setup and pandoc args', function () {
|
||||||
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
|
beforeEach(async function (ctx) {
|
||||||
force: true,
|
ctx.result =
|
||||||
recursive: true,
|
await ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
|
ctx.conversionId,
|
||||||
|
ctx.inputPath,
|
||||||
|
'markdown'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy the input file to the conversion directory with md filename', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, {
|
||||||
|
recursive: true,
|
||||||
|
})
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.fs.copyFile,
|
||||||
|
ctx.inputPath,
|
||||||
|
Path.join(ctx.conversionDir, 'input.md')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run pandoc with markdown args followed by zip', function (ctx) {
|
||||||
|
expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
|
||||||
|
expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
|
||||||
|
ctx.conversionId,
|
||||||
|
[
|
||||||
|
'pandoc',
|
||||||
|
'input.md',
|
||||||
|
'--output',
|
||||||
|
'main.tex',
|
||||||
|
'--to',
|
||||||
|
'latex',
|
||||||
|
'--standalone',
|
||||||
|
'--from',
|
||||||
|
'markdown',
|
||||||
|
],
|
||||||
|
ctx.conversionDir,
|
||||||
|
ctx.Settings.pandocImage,
|
||||||
|
60_000,
|
||||||
|
{},
|
||||||
|
'conversions',
|
||||||
|
])
|
||||||
|
expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([
|
||||||
|
ctx.conversionId,
|
||||||
|
['zip', '-r', 'output-uuid.zip', '.'],
|
||||||
|
ctx.conversionDir,
|
||||||
|
ctx.Settings.pandocImage,
|
||||||
|
60_000,
|
||||||
|
{},
|
||||||
|
'conversions',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should release the lock', function (ctx) {
|
describe('successful conversion', function () {
|
||||||
sinon.assert.called(ctx.lock.release)
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.CommandRunner.promises.run.resolves({
|
||||||
|
stdout: 'mock-stdout',
|
||||||
|
stderr: 'mock-stderr',
|
||||||
|
exitCode: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.result =
|
||||||
|
await ctx.ConversionManager.promises.convertToLaTeXWithLock(
|
||||||
|
ctx.conversionId,
|
||||||
|
ctx.inputPath,
|
||||||
|
'markdown'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove the source document after conversion', async function (ctx) {
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.fs.unlink,
|
||||||
|
Path.join(ctx.conversionDir, 'input.md')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the output zip path', function (ctx) {
|
||||||
|
expect(ctx.result).toBe(ctx.outputPath)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -531,6 +531,7 @@ async function projectListPage(req, res, next) {
|
|||||||
// Split tests that will be made available to the frontend
|
// Split tests that will be made available to the frontend
|
||||||
'import-docx',
|
'import-docx',
|
||||||
'overleaf-library',
|
'overleaf-library',
|
||||||
|
'import-markdown',
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
|
|||||||
@@ -11,20 +11,26 @@ import OError from '@overleaf/o-error'
|
|||||||
import FormData from 'form-data'
|
import FormData from 'form-data'
|
||||||
import { FileTooLargeError } from '../Errors/Errors.js'
|
import { FileTooLargeError } from '../Errors/Errors.js'
|
||||||
|
|
||||||
async function convertDocxToLaTeXZipArchive(path, userId) {
|
async function convertDocumentToLaTeXZipArchive(path, userId, conversionType) {
|
||||||
const clsiUrl = new URL(Settings.apis.clsi.url)
|
const clsiUrl = new URL(Settings.apis.clsi.url)
|
||||||
const limits = await CompileManager.promises._getUserCompileLimits(userId)
|
const limits = await CompileManager.promises._getUserCompileLimits(userId)
|
||||||
|
|
||||||
clsiUrl.pathname = '/convert/docx-to-latex'
|
// Uncomment this and remove the line below when the deploy is done.
|
||||||
|
// clsiUrl.pathname = '/convert/document-to-latex'
|
||||||
|
clsiUrl.pathname =
|
||||||
|
conversionType === 'docx'
|
||||||
|
? '/convert/docx-to-latex'
|
||||||
|
: '/convert/document-to-latex'
|
||||||
clsiUrl.searchParams.set('compileBackendClass', limits.compileBackendClass)
|
clsiUrl.searchParams.set('compileBackendClass', limits.compileBackendClass)
|
||||||
clsiUrl.searchParams.set('compileGroup', limits.compileGroup)
|
clsiUrl.searchParams.set('compileGroup', limits.compileGroup)
|
||||||
|
clsiUrl.searchParams.set('type', conversionType)
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('qqfile', fs.createReadStream(path))
|
formData.append('qqfile', fs.createReadStream(path))
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ clsiUrl: clsiUrl.toString() },
|
{ clsiUrl: clsiUrl.toString(), conversionType },
|
||||||
'sending docx to CLSI for conversion'
|
'sending document to CLSI for conversion'
|
||||||
)
|
)
|
||||||
|
|
||||||
const outputFileName = crypto.randomUUID() + '_document-conversion' + '.zip'
|
const outputFileName = crypto.randomUUID() + '_document-conversion' + '.zip'
|
||||||
@@ -99,7 +105,7 @@ async function convertProjectToDocument(projectId, userId, type) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
promises: {
|
promises: {
|
||||||
convertDocxToLaTeXZipArchive,
|
convertDocumentToLaTeXZipArchive,
|
||||||
convertProjectToDocument,
|
convertProjectToDocument,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,16 +178,21 @@ async function uploadFile(req, res, next) {
|
|||||||
* @param {any} res
|
* @param {any} res
|
||||||
* @param {any} next
|
* @param {any} next
|
||||||
*/
|
*/
|
||||||
async function importDocx(req, res, next) {
|
async function importDocument(req, res, next) {
|
||||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||||
logger.debug({ path: req.file?.path, userId }, 'importing docx file')
|
|
||||||
const { path } = req.file
|
const { path } = req.file
|
||||||
const name = Path.basename(req.body.name, '.docx')
|
const conversionType = req.query.type
|
||||||
|
if (!['docx', 'markdown'].includes(conversionType)) {
|
||||||
|
return res.status(400).json({ success: false, error: 'invalid_type' })
|
||||||
|
}
|
||||||
|
const name = Path.basename(req.body.name, Path.extname(req.body.name))
|
||||||
|
logger.debug({ path, userId, conversionType }, 'importing document file')
|
||||||
try {
|
try {
|
||||||
const archivePath =
|
const archivePath =
|
||||||
await DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
|
await DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive(
|
||||||
path,
|
path,
|
||||||
userId
|
userId,
|
||||||
|
conversionType
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const project =
|
const project =
|
||||||
@@ -207,7 +212,7 @@ async function importDocx(req, res, next) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error }, 'error importing docx file')
|
logger.error({ error }, 'error importing document file')
|
||||||
if (
|
if (
|
||||||
error instanceof FileTooLargeError ||
|
error instanceof FileTooLargeError ||
|
||||||
error?.name === 'FileTooLargeError'
|
error?.name === 'FileTooLargeError'
|
||||||
@@ -267,5 +272,5 @@ export default {
|
|||||||
uploadProject,
|
uploadProject,
|
||||||
uploadFile: expressify(uploadFile),
|
uploadFile: expressify(uploadFile),
|
||||||
multerMiddleware,
|
multerMiddleware,
|
||||||
importDocx: expressify(importDocx),
|
importDocument: expressify(importDocument),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,12 +28,24 @@ export default {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (Settings.enablePandocConversions) {
|
if (Settings.enablePandocConversions) {
|
||||||
|
webRouter.post(
|
||||||
|
'/project/new/import-document',
|
||||||
|
AuthenticationController.requireLogin(),
|
||||||
|
RateLimiterMiddleware.rateLimit(rateLimiters.projectUpload),
|
||||||
|
ProjectUploadController.multerMiddleware,
|
||||||
|
ProjectUploadController.importDocument
|
||||||
|
)
|
||||||
|
// Keep old route for backwards compatibility with old frontends that haven't reloaded
|
||||||
webRouter.post(
|
webRouter.post(
|
||||||
'/project/new/import-docx',
|
'/project/new/import-docx',
|
||||||
AuthenticationController.requireLogin(),
|
AuthenticationController.requireLogin(),
|
||||||
RateLimiterMiddleware.rateLimit(rateLimiters.projectUpload),
|
RateLimiterMiddleware.rateLimit(rateLimiters.projectUpload),
|
||||||
ProjectUploadController.multerMiddleware,
|
ProjectUploadController.multerMiddleware,
|
||||||
ProjectUploadController.importDocx
|
(req, res, next) => {
|
||||||
|
req.query.type = 'docx'
|
||||||
|
next()
|
||||||
|
},
|
||||||
|
ProjectUploadController.importDocument
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,7 @@
|
|||||||
"choose_a_custom_color": "",
|
"choose_a_custom_color": "",
|
||||||
"choose_from_group_members": "",
|
"choose_from_group_members": "",
|
||||||
"choose_how_you_search_your_references": "",
|
"choose_how_you_search_your_references": "",
|
||||||
|
"choose_markdown_file": "",
|
||||||
"choose_which_experiments": "",
|
"choose_which_experiments": "",
|
||||||
"choose_word_document": "",
|
"choose_word_document": "",
|
||||||
"citation": "",
|
"citation": "",
|
||||||
@@ -905,12 +906,13 @@
|
|||||||
"image_url": "",
|
"image_url": "",
|
||||||
"image_width": "",
|
"image_width": "",
|
||||||
"import_a_bibtex_file_from_your_provider_account": "",
|
"import_a_bibtex_file_from_your_provider_account": "",
|
||||||
|
"import_document_description": "",
|
||||||
"import_existing_projects_from_github": "",
|
"import_existing_projects_from_github": "",
|
||||||
"import_from_github": "",
|
"import_from_github": "",
|
||||||
"import_idp_metadata": "",
|
"import_idp_metadata": "",
|
||||||
|
"import_markdown_file": "",
|
||||||
"import_to_sharelatex": "",
|
"import_to_sharelatex": "",
|
||||||
"import_word_document": "",
|
"import_word_document": "",
|
||||||
"import_word_document_description": "",
|
|
||||||
"imported_from_another_project_at_date": "",
|
"imported_from_another_project_at_date": "",
|
||||||
"imported_from_external_provider_at_date": "",
|
"imported_from_external_provider_at_date": "",
|
||||||
"imported_from_mendeley_at_date": "",
|
"imported_from_mendeley_at_date": "",
|
||||||
@@ -1148,6 +1150,7 @@
|
|||||||
"manager": "",
|
"manager": "",
|
||||||
"managers_management": "",
|
"managers_management": "",
|
||||||
"managing_your_subscription": "",
|
"managing_your_subscription": "",
|
||||||
|
"markdown_import_feedback_message": "",
|
||||||
"marked_as_resolved": "",
|
"marked_as_resolved": "",
|
||||||
"math": "",
|
"math": "",
|
||||||
"math_display": "",
|
"math_display": "",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { debugConsole } from '@/utils/debugging'
|
|||||||
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
|
||||||
import { OLToastContainer } from '@/shared/components/ol/ol-toast-container'
|
import { OLToastContainer } from '@/shared/components/ol/ol-toast-container'
|
||||||
import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
|
import clipboardToastGenerators from '@/features/source-editor/components/clipboard-toasts'
|
||||||
import importDocxFeedbackToastGenerators from '@/features/project-list/components/new-project-button/import-docx-feedback-toast'
|
import importDocumentFeedbackToastGenerators from '@/features/project-list/components/new-project-button/import-document-feedback-toast'
|
||||||
import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts'
|
import exportDocumentToastGenerators from '@/features/ide-react/components/toolbar/export-document-toasts'
|
||||||
|
|
||||||
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
|
const moduleGeneratorsImport = importOverleafModules('toastGenerators') as {
|
||||||
@@ -29,7 +29,7 @@ type GlobalToastGenerator = (
|
|||||||
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
|
const GENERATOR_LIST: GlobalToastGeneratorEntry[] = [
|
||||||
...moduleGenerators.flat(),
|
...moduleGenerators.flat(),
|
||||||
...clipboardToastGenerators,
|
...clipboardToastGenerators,
|
||||||
...importDocxFeedbackToastGenerators,
|
...importDocumentFeedbackToastGenerators,
|
||||||
...exportDocumentToastGenerators,
|
...exportDocumentToastGenerators,
|
||||||
]
|
]
|
||||||
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
|
const GENERATOR_MAP: Map<string, GlobalToastGenerator> = new Map(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ForceDisconnected from '@/features/ide-react/components/modals/force-disc
|
|||||||
import { UnsavedDocs } from '@/features/ide-react/components/unsaved-docs/unsaved-docs'
|
import { UnsavedDocs } from '@/features/ide-react/components/unsaved-docs/unsaved-docs'
|
||||||
import SystemMessages from '@/shared/components/system-messages'
|
import SystemMessages from '@/shared/components/system-messages'
|
||||||
import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
|
import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
|
||||||
import ProjectConvertedFromDocxModal from '@/features/ide-react/components/modals/project-converted-from-docx-modal'
|
import ProjectConvertedFromDocumentModal from '@/features/ide-react/components/modals/project-converted-from-document-modal'
|
||||||
|
|
||||||
export const Modals = memo(() => {
|
export const Modals = memo(() => {
|
||||||
return (
|
return (
|
||||||
@@ -12,7 +12,7 @@ export const Modals = memo(() => {
|
|||||||
<UnsavedDocs />
|
<UnsavedDocs />
|
||||||
<SystemMessages />
|
<SystemMessages />
|
||||||
<ViewOnlyAccessModal />
|
<ViewOnlyAccessModal />
|
||||||
<ProjectConvertedFromDocxModal />
|
<ProjectConvertedFromDocumentModal />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,36 +8,35 @@ import {
|
|||||||
} from '@/shared/components/ol/ol-modal'
|
} from '@/shared/components/ol/ol-modal'
|
||||||
import OLButton from '@/shared/components/ol/ol-button'
|
import OLButton from '@/shared/components/ol/ol-button'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { showImportDocxFeedbackToast } from '@/features/project-list/components/new-project-button/import-docx-feedback-toast'
|
import { showImportDocumentFeedbackToast } from '@/features/project-list/components/new-project-button/import-document-feedback-toast'
|
||||||
|
|
||||||
function ProjectConvertedFromDocxModal() {
|
function ProjectConvertedFromDocumentModal() {
|
||||||
const [
|
const [convertedFrom, setConvertedFrom] = useState<string | null>(null)
|
||||||
showProjectConvertedFromDocxModal,
|
|
||||||
setShowProjectConvertedFromDocxModal,
|
|
||||||
] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const query = window.location.search
|
const queryString = new URLSearchParams(window.location.search)
|
||||||
const queryString = new URLSearchParams(query)
|
const from = queryString.get('converted-from')
|
||||||
|
|
||||||
if (queryString.get('converted-from-docx') === 'true') {
|
if (from) {
|
||||||
setShowProjectConvertedFromDocxModal(true)
|
setConvertedFrom(from)
|
||||||
|
|
||||||
// Clean the URL immediately so a refresh doesn't trigger the modal again,
|
// Clean the URL immediately so a refresh doesn't trigger the modal again,
|
||||||
// but preserve other search params and the hash.
|
// but preserve other search params and the hash.
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.delete('converted-from-docx')
|
url.searchParams.delete('converted-from')
|
||||||
window.history.replaceState(window.history.state, '', url.toString())
|
window.history.replaceState(window.history.state, '', url.toString())
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{showProjectConvertedFromDocxModal && (
|
{convertedFrom && (
|
||||||
<ProjectConvertedFromDocxModalContent
|
<ProjectConvertedFromImportModalContent
|
||||||
onHide={() => {
|
onHide={() => {
|
||||||
setShowProjectConvertedFromDocxModal(false)
|
setConvertedFrom(null)
|
||||||
showImportDocxFeedbackToast()
|
if (convertedFrom === 'docx' || convertedFrom === 'markdown') {
|
||||||
|
showImportDocumentFeedbackToast(convertedFrom)
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -45,7 +44,7 @@ function ProjectConvertedFromDocxModal() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectConvertedFromDocxModalContent({
|
function ProjectConvertedFromImportModalContent({
|
||||||
onHide,
|
onHide,
|
||||||
}: {
|
}: {
|
||||||
onHide: () => void
|
onHide: () => void
|
||||||
@@ -57,7 +56,7 @@ function ProjectConvertedFromDocxModalContent({
|
|||||||
show
|
show
|
||||||
animation
|
animation
|
||||||
onHide={onHide}
|
onHide={onHide}
|
||||||
id="converted-from-docx-modal"
|
id="converted-from-document-modal"
|
||||||
backdrop="static"
|
backdrop="static"
|
||||||
>
|
>
|
||||||
<OLModalHeader>
|
<OLModalHeader>
|
||||||
@@ -73,4 +72,4 @@ function ProjectConvertedFromDocxModalContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ProjectConvertedFromDocxModal
|
export default ProjectConvertedFromDocumentModal
|
||||||
@@ -62,6 +62,9 @@ function NewProjectButton({
|
|||||||
const docxImportEnabled =
|
const docxImportEnabled =
|
||||||
useFeatureFlag('import-docx') &&
|
useFeatureFlag('import-docx') &&
|
||||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||||
|
const markdownImportEnabled =
|
||||||
|
useFeatureFlag('import-markdown') &&
|
||||||
|
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||||
const sendTrackingEvent = useCallback(
|
const sendTrackingEvent = useCallback(
|
||||||
({
|
({
|
||||||
dropdownMenu,
|
dropdownMenu,
|
||||||
@@ -228,6 +231,21 @@ function NewProjectButton({
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{markdownImportEnabled && (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
onClick={e =>
|
||||||
|
handleModalMenuClick(e, {
|
||||||
|
modalVariant: 'import_markdown',
|
||||||
|
dropdownMenuEvent: 'import-markdown',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
trailingIcon={<MaterialIcon type="fiber_new" />}
|
||||||
|
>
|
||||||
|
{t('import_markdown_file')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
<li role="none">
|
<li role="none">
|
||||||
{ImportProjectFromGithubMenu && (
|
{ImportProjectFromGithubMenu && (
|
||||||
<ImportProjectFromGithubMenu
|
<ImportProjectFromGithubMenu
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
|
||||||
|
import { Trans } from 'react-i18next'
|
||||||
|
|
||||||
|
const DocxImportFeedbackToast = () => (
|
||||||
|
<div>
|
||||||
|
<Trans
|
||||||
|
i18nKey="docx_import_feedback_message"
|
||||||
|
components={[
|
||||||
|
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
|
||||||
|
<a
|
||||||
|
href="https://forms.gle/B1qrdiD983YcQCJA9"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const MarkdownImportFeedbackToast = () => (
|
||||||
|
<div>
|
||||||
|
<Trans
|
||||||
|
i18nKey="markdown_import_feedback_message"
|
||||||
|
components={[
|
||||||
|
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
|
||||||
|
<a
|
||||||
|
href="https://forms.gle/B1qrdiD983YcQCJA9"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const generators: GlobalToastGeneratorEntry[] = [
|
||||||
|
{
|
||||||
|
key: 'import:docx-feedback',
|
||||||
|
generator: () => ({
|
||||||
|
content: <DocxImportFeedbackToast />,
|
||||||
|
type: 'info',
|
||||||
|
autoHide: false,
|
||||||
|
isDismissible: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'import:markdown-feedback',
|
||||||
|
generator: () => ({
|
||||||
|
content: <MarkdownImportFeedbackToast />,
|
||||||
|
type: 'info',
|
||||||
|
autoHide: false,
|
||||||
|
isDismissible: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default generators
|
||||||
|
|
||||||
|
export const showImportDocumentFeedbackToast = (type: 'docx' | 'markdown') => {
|
||||||
|
const key =
|
||||||
|
type === 'markdown' ? 'import:markdown-feedback' : 'import:docx-feedback'
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('ide:show-toast', {
|
||||||
|
detail: { key },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Dashboard } from '@uppy/react'
|
import { Dashboard } from '@uppy/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useProjectUploader } from '../../hooks/use-project-uploader'
|
import { useProjectUploader } from '../../hooks/use-project-uploader'
|
||||||
@@ -13,19 +14,39 @@ import '@uppy/core/dist/style.css'
|
|||||||
import '@uppy/dashboard/dist/style.css'
|
import '@uppy/dashboard/dist/style.css'
|
||||||
import BetaBadgeIcon from '@/shared/components/beta-badge-icon'
|
import BetaBadgeIcon from '@/shared/components/beta-badge-icon'
|
||||||
|
|
||||||
function ImportDocxModal({
|
function ImportDocumentModal({
|
||||||
|
type,
|
||||||
onHide,
|
onHide,
|
||||||
openProject,
|
openProject,
|
||||||
}: {
|
}: {
|
||||||
|
type: 'docx' | 'markdown'
|
||||||
onHide: () => void
|
onHide: () => void
|
||||||
openProject: (id: string, isConvertedFromDocx?: boolean) => void
|
openProject: (id: string, convertedFrom?: string) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const IMPORT_CONFIGS = useMemo(
|
||||||
|
() => ({
|
||||||
|
docx: {
|
||||||
|
allowedFileTypes: ['.docx'],
|
||||||
|
title: t('choose_word_document'),
|
||||||
|
browseLabel: 'Select .docx file',
|
||||||
|
dragLabel: '%{browseFiles} or \n\n Drag .docx file',
|
||||||
|
},
|
||||||
|
markdown: {
|
||||||
|
allowedFileTypes: ['.md'],
|
||||||
|
title: t('choose_markdown_file'),
|
||||||
|
browseLabel: 'Select .md file',
|
||||||
|
dragLabel: '%{browseFiles} or \n\n Drag .md file',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
const config = IMPORT_CONFIGS[type]
|
||||||
|
|
||||||
const uppy = useProjectUploader({
|
const uppy = useProjectUploader({
|
||||||
endpoint: '/project/new/import-docx',
|
endpoint: `/project/new/import-document?type=${type}`,
|
||||||
allowedFileTypes: ['.docx'],
|
allowedFileTypes: config.allowedFileTypes,
|
||||||
onSuccess: (projectId: string) => openProject(projectId, true),
|
onSuccess: (projectId: string) => openProject(projectId, type),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,16 +57,17 @@ function ImportDocxModal({
|
|||||||
id="upload-project-modal"
|
id="upload-project-modal"
|
||||||
backdrop="static"
|
backdrop="static"
|
||||||
>
|
>
|
||||||
|
{/* TODO: make necessary changes here for import document modal */}
|
||||||
<OLModalHeader>
|
<OLModalHeader>
|
||||||
<OLModalTitle as="h3" className="import-docx-modal-title">
|
<OLModalTitle as="h3" className="import-document-modal-title">
|
||||||
{t('choose_word_document')}
|
{config.title}
|
||||||
<span className="beta-icon-wrapper">
|
<span className="beta-icon-wrapper">
|
||||||
<BetaBadgeIcon />
|
<BetaBadgeIcon />
|
||||||
</span>
|
</span>
|
||||||
</OLModalTitle>
|
</OLModalTitle>
|
||||||
</OLModalHeader>
|
</OLModalHeader>
|
||||||
<OLModalBody>
|
<OLModalBody>
|
||||||
<p>{t('import_word_document_description')}</p>
|
<p>{t('import_document_description')}</p>
|
||||||
<Dashboard
|
<Dashboard
|
||||||
uppy={uppy}
|
uppy={uppy}
|
||||||
proudlyDisplayPoweredByUppy={false}
|
proudlyDisplayPoweredByUppy={false}
|
||||||
@@ -55,8 +77,8 @@ function ImportDocxModal({
|
|||||||
height={300}
|
height={300}
|
||||||
locale={{
|
locale={{
|
||||||
strings: {
|
strings: {
|
||||||
browseFiles: 'Select .docx file',
|
browseFiles: config.browseLabel,
|
||||||
dropPasteFiles: '%{browseFiles} or \n\n Drag .docx file',
|
dropPasteFiles: config.dragLabel,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
className="project-list-upload-project-modal-uppy-dashboard"
|
className="project-list-upload-project-modal-uppy-dashboard"
|
||||||
@@ -71,4 +93,4 @@ function ImportDocxModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ImportDocxModal
|
export default ImportDocumentModal
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { GlobalToastGeneratorEntry } from '@/features/ide-react/components/global-toasts'
|
|
||||||
import { Trans } from 'react-i18next'
|
|
||||||
|
|
||||||
const ImportDocxFeedbackToast = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Trans
|
|
||||||
i18nKey="docx_import_feedback_message"
|
|
||||||
components={[
|
|
||||||
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
|
|
||||||
<a
|
|
||||||
href="https://forms.gle/B1qrdiD983YcQCJA9"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const generators: GlobalToastGeneratorEntry[] = [
|
|
||||||
{
|
|
||||||
key: 'import:docx-feedback',
|
|
||||||
generator: () => ({
|
|
||||||
content: <ImportDocxFeedbackToast />,
|
|
||||||
type: 'info',
|
|
||||||
autoHide: false,
|
|
||||||
isDismissible: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default generators
|
|
||||||
|
|
||||||
export const showImportDocxFeedbackToast = () => {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('ide:show-toast', {
|
|
||||||
detail: { key: 'import:docx-feedback' },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
|||||||
import { useLocation } from '@/shared/hooks/use-location'
|
import { useLocation } from '@/shared/hooks/use-location'
|
||||||
|
|
||||||
const UploadProjectModal = lazy(() => import('./upload-project-modal'))
|
const UploadProjectModal = lazy(() => import('./upload-project-modal'))
|
||||||
const ImportDocxModal = lazy(() => import('./import-docx-modal'))
|
const ImportDocumentModal = lazy(() => import('./import-document-modal'))
|
||||||
|
|
||||||
export type NewProjectButtonModalVariant =
|
export type NewProjectButtonModalVariant =
|
||||||
| 'blank_project'
|
| 'blank_project'
|
||||||
@@ -15,6 +15,7 @@ export type NewProjectButtonModalVariant =
|
|||||||
| 'upload_project'
|
| 'upload_project'
|
||||||
| 'import_from_github'
|
| 'import_from_github'
|
||||||
| 'import_docx'
|
| 'import_docx'
|
||||||
|
| 'import_markdown'
|
||||||
|
|
||||||
type NewProjectButtonModalProps = {
|
type NewProjectButtonModalProps = {
|
||||||
modal: Nullable<NewProjectButtonModalVariant>
|
modal: Nullable<NewProjectButtonModalVariant>
|
||||||
@@ -32,9 +33,9 @@ function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const openProject = useCallback(
|
const openProject = useCallback(
|
||||||
(projectId: string, isConvertedFromDocx: boolean = false) => {
|
(projectId: string, convertedFrom?: string) => {
|
||||||
const url = isConvertedFromDocx
|
const url = convertedFrom
|
||||||
? `/project/${projectId}?converted-from-docx=true`
|
? `/project/${projectId}?converted-from=${convertedFrom}`
|
||||||
: `/project/${projectId}`
|
: `/project/${projectId}`
|
||||||
|
|
||||||
location.assign(url)
|
location.assign(url)
|
||||||
@@ -56,7 +57,21 @@ function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
|
|||||||
case 'import_docx':
|
case 'import_docx':
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||||
<ImportDocxModal onHide={onHide} openProject={openProject} />
|
<ImportDocumentModal
|
||||||
|
type="docx"
|
||||||
|
onHide={onHide}
|
||||||
|
openProject={openProject}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
case 'import_markdown':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||||
|
<ImportDocumentModal
|
||||||
|
type="markdown"
|
||||||
|
onHide={onHide}
|
||||||
|
openProject={openProject}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
case 'import_from_github':
|
case 'import_from_github':
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ function WelcomeMessageCreateNewProjectDropdown({
|
|||||||
const docxImportEnabled =
|
const docxImportEnabled =
|
||||||
useFeatureFlag('import-docx') &&
|
useFeatureFlag('import-docx') &&
|
||||||
getMeta('ol-ExposedSettings').enablePandocConversions
|
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||||
|
const markdownImportEnabled =
|
||||||
|
useFeatureFlag('import-markdown') &&
|
||||||
|
getMeta('ol-ExposedSettings').enablePandocConversions
|
||||||
|
|
||||||
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
const { isOverleaf } = getMeta('ol-ExposedSettings')
|
||||||
|
|
||||||
@@ -153,6 +156,20 @@ function WelcomeMessageCreateNewProjectDropdown({
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
|
{markdownImportEnabled && (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
onClick={e =>
|
||||||
|
handleDropdownItemClick(e, 'import_markdown', 'import-markdown')
|
||||||
|
}
|
||||||
|
tabIndex={-1}
|
||||||
|
trailingIcon={<MaterialIcon type="fiber_new" />}
|
||||||
|
>
|
||||||
|
{t('import_markdown_file')}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
{isOverleaf && (
|
{isOverleaf && (
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
|
|||||||
@@ -585,7 +585,7 @@ ul.project-list-filters {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-docx-modal-title {
|
.import-document-modal-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,7 @@
|
|||||||
"choose_a_new_password": "Choose a new password",
|
"choose_a_new_password": "Choose a new password",
|
||||||
"choose_from_group_members": "Choose from group members",
|
"choose_from_group_members": "Choose from group members",
|
||||||
"choose_how_you_search_your_references": "Choose how you search your references",
|
"choose_how_you_search_your_references": "Choose how you search your references",
|
||||||
|
"choose_markdown_file": "Choose Markdown file",
|
||||||
"choose_which_experiments": "Choose which experiments you’d like to try.",
|
"choose_which_experiments": "Choose which experiments you’d like to try.",
|
||||||
"choose_word_document": "Choose Word document",
|
"choose_word_document": "Choose Word document",
|
||||||
"choose_your_plan": "Choose your plan",
|
"choose_your_plan": "Choose your plan",
|
||||||
@@ -1174,12 +1175,13 @@
|
|||||||
"image_url": "Image URL",
|
"image_url": "Image URL",
|
||||||
"image_width": "Image width",
|
"image_width": "Image width",
|
||||||
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account",
|
"import_a_bibtex_file_from_your_provider_account": "Import a BibTeX file from your __provider__ account",
|
||||||
|
"import_document_description": "Content will be imported from the selected document. Formatting may not be reproduced exactly.",
|
||||||
"import_existing_projects_from_github": "Import existing projects from GitHub",
|
"import_existing_projects_from_github": "Import existing projects from GitHub",
|
||||||
"import_from_github": "Import from GitHub",
|
"import_from_github": "Import from GitHub",
|
||||||
"import_idp_metadata": "Import IdP metadata",
|
"import_idp_metadata": "Import IdP metadata",
|
||||||
|
"import_markdown_file": "Import Markdown file",
|
||||||
"import_to_sharelatex": "Import to __appName__",
|
"import_to_sharelatex": "Import to __appName__",
|
||||||
"import_word_document": "Import Word document",
|
"import_word_document": "Import Word document",
|
||||||
"import_word_document_description": "Content will be imported from the selected document. Formatting may not be reproduced exactly.",
|
|
||||||
"important_message": "Important message",
|
"important_message": "Important message",
|
||||||
"imported_from_another_project_at_date": "Imported from <0>Another project</0>/__sourceEntityPathHTML__, at __formattedDate__ __relativeDate__",
|
"imported_from_another_project_at_date": "Imported from <0>Another project</0>/__sourceEntityPathHTML__, at __formattedDate__ __relativeDate__",
|
||||||
"imported_from_external_provider_at_date": "Imported from <0>__shortenedUrlHTML__</0> at __formattedDate__ __relativeDate__",
|
"imported_from_external_provider_at_date": "Imported from <0>__shortenedUrlHTML__</0> at __formattedDate__ __relativeDate__",
|
||||||
@@ -1517,6 +1519,7 @@
|
|||||||
"managers_management": "Managers management",
|
"managers_management": "Managers management",
|
||||||
"managing_your_subscription": "Managing your subscription",
|
"managing_your_subscription": "Managing your subscription",
|
||||||
"march": "March",
|
"march": "March",
|
||||||
|
"markdown_import_feedback_message": "Importing Markdown files is a new feature. <0>Let us know what you think</0>",
|
||||||
"marked_as_resolved": "Marked as resolved",
|
"marked_as_resolved": "Marked as resolved",
|
||||||
"math": "Math",
|
"math": "Math",
|
||||||
"math_display": "Math Display",
|
"math_display": "Math Display",
|
||||||
|
|||||||
@@ -88,12 +88,74 @@ describe('DocumentConversionManager', function () {
|
|||||||
ctx.DocumentConversionManager = (await import(MODULE_PATH)).default
|
ctx.DocumentConversionManager = (await import(MODULE_PATH)).default
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('convertDocxToLaTeXZipArchive', function () {
|
describe('convertDocumentToLaTeXZipArchive', function () {
|
||||||
describe('successfully', function () {
|
describe('with conversionType=docx', function () {
|
||||||
|
describe('successfully', function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.path = '/path/to/input.docx'
|
||||||
|
ctx.userId = 'test-user-id'
|
||||||
|
ctx.response = {
|
||||||
|
headers: {
|
||||||
|
get: sinon.stub().returns(null),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx.response.headers.get.withArgs('Content-Length').returns('50')
|
||||||
|
|
||||||
|
ctx.fetchUtils.fetchStreamWithResponse.resolves({
|
||||||
|
stream: 'mocked-fetch-stream',
|
||||||
|
response: ctx.response,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.result =
|
||||||
|
await ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive(
|
||||||
|
ctx.path,
|
||||||
|
ctx.userId,
|
||||||
|
'docx'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call fetchStreamWithResponse with the correct URL and form data', function (ctx) {
|
||||||
|
const expectedUrl = new URL(ctx.Settings.apis.clsi.url)
|
||||||
|
// TODO: revert this to '/convert/document-to-latex' once the deploy is done (PR #32857)
|
||||||
|
expectedUrl.pathname = '/convert/docx-to-latex'
|
||||||
|
expectedUrl.searchParams.set(
|
||||||
|
'compileBackendClass',
|
||||||
|
'test-backend-class'
|
||||||
|
)
|
||||||
|
expectedUrl.searchParams.set('compileGroup', 'test-compile-group')
|
||||||
|
expectedUrl.searchParams.set('type', 'docx')
|
||||||
|
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.fetchUtils.fetchStreamWithResponse,
|
||||||
|
sinon.match(url => url.toString() === expectedUrl.toString()),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: sinon.match.instanceOf(FormData),
|
||||||
|
signal: sinon.match.instanceOf(AbortSignal),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pipe result into the output file', function (ctx) {
|
||||||
|
sinon.assert.calledWith(
|
||||||
|
ctx.nodeStream.pipeline,
|
||||||
|
'mocked-fetch-stream',
|
||||||
|
'mocked-write-stream'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a path to the output file', function (ctx) {
|
||||||
|
expect(ctx.result).to.match(
|
||||||
|
/\/path\/to\/dump\/folder\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}_document-conversion\.zip/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with conversionType=markdown', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.path = '/path/to/input.docx'
|
ctx.path = '/path/to/input.md'
|
||||||
ctx.userId = 'test-user-id'
|
ctx.userId = 'test-user-id'
|
||||||
ctx.outputPath = '/path/to/output.zip'
|
|
||||||
ctx.response = {
|
ctx.response = {
|
||||||
headers: {
|
headers: {
|
||||||
get: sinon.stub().returns(null),
|
get: sinon.stub().returns(null),
|
||||||
@@ -107,20 +169,22 @@ describe('DocumentConversionManager', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ctx.result =
|
ctx.result =
|
||||||
await ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
|
await ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive(
|
||||||
ctx.path,
|
ctx.path,
|
||||||
ctx.userId
|
ctx.userId,
|
||||||
|
'markdown'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call fetchStreamWithResponse with the correct URL and form data', function (ctx) {
|
it('should call fetchStreamWithResponse with the correct URL including markdown type', function (ctx) {
|
||||||
const expectedUrl = new URL(ctx.Settings.apis.clsi.url)
|
const expectedUrl = new URL(ctx.Settings.apis.clsi.url)
|
||||||
expectedUrl.pathname = '/convert/docx-to-latex'
|
expectedUrl.pathname = '/convert/document-to-latex'
|
||||||
expectedUrl.searchParams.set(
|
expectedUrl.searchParams.set(
|
||||||
'compileBackendClass',
|
'compileBackendClass',
|
||||||
'test-backend-class'
|
'test-backend-class'
|
||||||
)
|
)
|
||||||
expectedUrl.searchParams.set('compileGroup', 'test-compile-group')
|
expectedUrl.searchParams.set('compileGroup', 'test-compile-group')
|
||||||
|
expectedUrl.searchParams.set('type', 'markdown')
|
||||||
|
|
||||||
sinon.assert.calledWith(
|
sinon.assert.calledWith(
|
||||||
ctx.fetchUtils.fetchStreamWithResponse,
|
ctx.fetchUtils.fetchStreamWithResponse,
|
||||||
@@ -158,9 +222,10 @@ describe('DocumentConversionManager', function () {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
|
ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive(
|
||||||
ctx.path,
|
ctx.path,
|
||||||
ctx.userId
|
ctx.userId,
|
||||||
|
'docx'
|
||||||
)
|
)
|
||||||
).to.be.rejectedWith('document conversion failed')
|
).to.be.rejectedWith('document conversion failed')
|
||||||
})
|
})
|
||||||
@@ -195,9 +260,10 @@ describe('DocumentConversionManager', function () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive(
|
ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive(
|
||||||
ctx.path,
|
ctx.path,
|
||||||
ctx.userId
|
ctx.userId,
|
||||||
|
'docx'
|
||||||
)
|
)
|
||||||
).to.be.rejectedWith(sinon.match.instanceOf(FileTooLargeError))
|
).to.be.rejectedWith(sinon.match.instanceOf(FileTooLargeError))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ describe('ProjectUploadController', function () {
|
|||||||
}
|
}
|
||||||
ctx.DocumentConversionManager = {
|
ctx.DocumentConversionManager = {
|
||||||
promises: {
|
promises: {
|
||||||
convertDocxToLaTeXZipArchive: sinon.stub(),
|
convertDocumentToLaTeXZipArchive: sinon.stub(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,7 +463,7 @@ describe('ProjectUploadController', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('importDocx', function () {
|
describe('importDocument', function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.req.file = {
|
ctx.req.file = {
|
||||||
path: '/path/to/uploaded/file.docx',
|
path: '/path/to/uploaded/file.docx',
|
||||||
@@ -471,13 +471,73 @@ describe('ProjectUploadController', function () {
|
|||||||
ctx.req.body = {
|
ctx.req.body = {
|
||||||
name: 'file.docx',
|
name: 'file.docx',
|
||||||
}
|
}
|
||||||
|
ctx.req.query = { type: 'docx' }
|
||||||
ctx.archivePath = '/path/to/archive.zip'
|
ctx.archivePath = '/path/to/archive.zip'
|
||||||
ctx.fsPromises.unlink = sinon.stub().resolves()
|
ctx.fsPromises.unlink = sinon.stub().resolves()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('successfully', async function () {
|
describe('with conversionType=docx', async function () {
|
||||||
|
describe('successfully', async function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
|
||||||
|
sinon.stub().resolves(ctx.archivePath)
|
||||||
|
ctx.ProjectUploadManager.promises.createProjectFromZipArchive = sinon
|
||||||
|
.stub()
|
||||||
|
.resolves({
|
||||||
|
_id: 'new-project-id',
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(resolve => {
|
||||||
|
ctx.res.json = data => {
|
||||||
|
expect(data.success).to.be.true
|
||||||
|
expect(data.project_id).to.equal('new-project-id')
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call the DocumentConversionManager with file path and type', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.DocumentConversionManager.promises
|
||||||
|
.convertDocumentToLaTeXZipArchive
|
||||||
|
).to.have.been.calledWith(ctx.req.file.path, ctx.user_id, 'docx')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use the resulting archive to create a new project', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.ProjectUploadManager.promises.createProjectFromZipArchive
|
||||||
|
).to.have.been.calledWith(ctx.user_id, 'file', ctx.archivePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set the compiler to lualatex', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.ProjectOptionsHandler.promises.setCompiler
|
||||||
|
).to.have.been.calledWith('new-project-id', 'lualatex')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should unlink the archive after creating the project', function (ctx) {
|
||||||
|
expect(ctx.fsPromises.unlink).to.have.been.calledWith(ctx.archivePath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should unlink the uploaded file', function (ctx) {
|
||||||
|
expect(ctx.fsPromises.unlink).to.have.been.calledWith(
|
||||||
|
ctx.req.file.path
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('with conversionType=markdown', async function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive =
|
ctx.req.file = {
|
||||||
|
path: '/path/to/uploaded/file.md',
|
||||||
|
}
|
||||||
|
ctx.req.body = {
|
||||||
|
name: 'file.md',
|
||||||
|
}
|
||||||
|
ctx.req.query = { type: 'markdown' }
|
||||||
|
ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
|
||||||
sinon.stub().resolves(ctx.archivePath)
|
sinon.stub().resolves(ctx.archivePath)
|
||||||
ctx.ProjectUploadManager.promises.createProjectFromZipArchive = sinon
|
ctx.ProjectUploadManager.promises.createProjectFromZipArchive = sinon
|
||||||
.stub()
|
.stub()
|
||||||
@@ -491,14 +551,15 @@ describe('ProjectUploadController', function () {
|
|||||||
expect(data.project_id).to.equal('new-project-id')
|
expect(data.project_id).to.equal('new-project-id')
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
ctx.ProjectUploadController.importDocx(ctx.req, ctx.res)
|
ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call the DocumentConversionManager to convert the file', function (ctx) {
|
it('should call the DocumentConversionManager with file path and markdown type', function (ctx) {
|
||||||
expect(
|
expect(
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive
|
ctx.DocumentConversionManager.promises
|
||||||
).to.have.been.calledWith(ctx.req.file.path, ctx.user_id)
|
.convertDocumentToLaTeXZipArchive
|
||||||
|
).to.have.been.calledWith(ctx.req.file.path, ctx.user_id, 'markdown')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use the resulting archive to create a new project', function (ctx) {
|
it('should use the resulting archive to create a new project', function (ctx) {
|
||||||
@@ -522,9 +583,37 @@ describe('ProjectUploadController', function () {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('with an invalid conversionType', async function () {
|
||||||
|
beforeEach(async function (ctx) {
|
||||||
|
ctx.req.query = { type: 'invalid' }
|
||||||
|
|
||||||
|
await new Promise(resolve => {
|
||||||
|
ctx.res.json = data => {
|
||||||
|
expect(data).to.deep.equal({
|
||||||
|
success: false,
|
||||||
|
error: 'invalid_type',
|
||||||
|
})
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return http 400', function (ctx) {
|
||||||
|
expect(ctx.res.statusCode).to.equal(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not call DocumentConversionManager', function (ctx) {
|
||||||
|
expect(
|
||||||
|
ctx.DocumentConversionManager.promises
|
||||||
|
.convertDocumentToLaTeXZipArchive
|
||||||
|
).not.to.have.been.called
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('unsuccessfully', async function () {
|
describe('unsuccessfully', async function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive =
|
ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
|
||||||
sinon.stub().rejects(new Error('Conversion failed'))
|
sinon.stub().rejects(new Error('Conversion failed'))
|
||||||
|
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
@@ -532,14 +621,15 @@ describe('ProjectUploadController', function () {
|
|||||||
expect(data.success).to.be.false
|
expect(data.success).to.be.false
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
ctx.ProjectUploadController.importDocx(ctx.req, ctx.res)
|
ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call the DocumentConversionManager to convert the file', function (ctx) {
|
it('should call the DocumentConversionManager to convert the file', function (ctx) {
|
||||||
expect(
|
expect(
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive
|
ctx.DocumentConversionManager.promises
|
||||||
).to.have.been.calledWith(ctx.req.file.path, ctx.user_id)
|
.convertDocumentToLaTeXZipArchive
|
||||||
|
).to.have.been.calledWith(ctx.req.file.path, ctx.user_id, 'docx')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should unlink the uploaded file', function (ctx) {
|
it('should unlink the uploaded file', function (ctx) {
|
||||||
@@ -553,7 +643,7 @@ describe('ProjectUploadController', function () {
|
|||||||
|
|
||||||
describe('when the converted archive is too large', async function () {
|
describe('when the converted archive is too large', async function () {
|
||||||
beforeEach(async function (ctx) {
|
beforeEach(async function (ctx) {
|
||||||
ctx.DocumentConversionManager.promises.convertDocxToLaTeXZipArchive =
|
ctx.DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive =
|
||||||
sinon.stub().rejects(new FileTooLargeError('file too large'))
|
sinon.stub().rejects(new FileTooLargeError('file too large'))
|
||||||
|
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
@@ -564,7 +654,7 @@ describe('ProjectUploadController', function () {
|
|||||||
})
|
})
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
ctx.ProjectUploadController.importDocx(ctx.req, ctx.res)
|
ctx.ProjectUploadController.importDocument(ctx.req, ctx.res)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
2
services/web/types/assets.d.ts
vendored
2
services/web/types/assets.d.ts
vendored
@@ -27,3 +27,5 @@ declare module '*.txt' {
|
|||||||
const src: string
|
const src: string
|
||||||
export default src
|
export default src
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '*.css' {}
|
||||||
|
|||||||
Reference in New Issue
Block a user