Merge pull request #33560 from overleaf/mj-conversion-cleanup

[clsi+web] Small cleanups and improvements to conversions / exports

GitOrigin-RevId: 300adfbb91e89f754ee7f835db792ccb50b27613
This commit is contained in:
Mathias Jakobsen
2026-05-11 12:30:04 +01:00
committed by Copybot
parent 62d92b70dd
commit 6b28a4ee5a
9 changed files with 381 additions and 433 deletions

View File

@@ -6,6 +6,79 @@ const MODULE_PATH = Path.join(
'../../../app/js/ConversionManager' '../../../app/js/ConversionManager'
) )
const CONVERT_TO_LATEX_CASES = [
{
type: 'docx',
inputFilename: 'input.docx',
pandocArgs: [
'pandoc',
'input.docx',
'--output',
'main.tex',
'--to',
'latex',
'--standalone',
'--extract-media=.',
'--from',
'docx+citations',
'--citeproc',
],
},
{
type: 'markdown',
inputFilename: 'input.md',
pandocArgs: [
'pandoc',
'input.md',
'--output',
'main.tex',
'--to',
'latex',
'--standalone',
'--from',
'markdown',
],
},
]
const LATEX_TO_DOCUMENT_CASES = [
{
type: 'docx',
extension: 'docx',
compressOutput: false,
pandocArgs: outputId => [
'pandoc',
'main.tex',
'--output',
`${outputId}.docx`,
'--from',
'latex',
'--to',
'docx',
'--citeproc',
'--number-sections',
'--resource-path=.',
],
},
{
type: 'markdown',
extension: 'md',
compressOutput: true,
pandocArgs: outputId => [
'pandoc',
'main.tex',
'--output',
Path.join(outputId, 'main.md'),
'--from',
'latex',
'--to',
'markdown',
'--resource-path=.',
`--extract-media=${outputId}`,
],
},
]
describe('ConversionManager', function () { describe('ConversionManager', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
ctx.CommandRunner = { ctx.CommandRunner = {
@@ -65,12 +138,41 @@ describe('ConversionManager', function () {
}) })
describe('convertToLaTeXWithLock', function () { describe('convertToLaTeXWithLock', function () {
describe('with conversionType=docx', function () { describe('per conversion type', function () {
CONVERT_TO_LATEX_CASES.forEach(({ type, inputFilename, pandocArgs }) => {
describe(`type=${type}`, function () {
beforeEach(async function (ctx) {
ctx.inputPath = `/path/to/${inputFilename}`
await ctx.ConversionManager.promises.convertToLaTeXWithLock(
ctx.conversionId,
ctx.inputPath,
type
)
})
it('should copy the input file to the conversion directory under the type-specific filename', function (ctx) {
sinon.assert.calledWith(
ctx.fs.copyFile,
ctx.inputPath,
Path.join(ctx.conversionDir, inputFilename)
)
})
it('should run pandoc with the type-specific args', function (ctx) {
expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual(
pandocArgs
)
})
})
})
})
describe('with conversionType=docx (representative)', function () {
beforeEach(function (ctx) { beforeEach(function (ctx) {
ctx.inputPath = '/path/to/input.docx' ctx.inputPath = '/path/to/input.docx'
}) })
describe('file setup and pandoc args', function () { describe('successful conversion', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
ctx.result = ctx.result =
await ctx.ConversionManager.promises.convertToLaTeXWithLock( await ctx.ConversionManager.promises.convertToLaTeXWithLock(
@@ -80,78 +182,29 @@ describe('ConversionManager', function () {
) )
}) })
it('should acquire a lock', async function (ctx) { it('should acquire a lock on the conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir) sinon.assert.calledWith(ctx.LockManager.acquire, ctx.conversionDir)
}) })
it('should copy the input file to the conversion directory with docx filename', async function (ctx) { it('should create the conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, { sinon.assert.calledWith(ctx.fs.mkdir, ctx.conversionDir, {
recursive: true, 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) { it('should run pandoc then zip with the conversion timeout in milliseconds', function (ctx) {
expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([
'zip',
'-r',
'output-uuid.zip',
'.',
])
expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000) expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
expect(ctx.CommandRunner.promises.run.secondCall.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) { it('should remove the source document after conversion', 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',
'--to',
'latex',
'--standalone',
'--extract-media=.',
'--from',
'docx+citations',
'--citeproc',
],
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 =
await ctx.ConversionManager.promises.convertToLaTeXWithLock(
ctx.conversionId,
ctx.inputPath,
'docx'
)
})
it('should remove the source document after conversion', async function (ctx) {
sinon.assert.calledWith( sinon.assert.calledWith(
ctx.fs.unlink, ctx.fs.unlink,
Path.join(ctx.conversionDir, 'input.docx') Path.join(ctx.conversionDir, 'input.docx')
@@ -167,14 +220,13 @@ describe('ConversionManager', function () {
}) })
}) })
describe('unsuccessful conversion (exitcode)', function () { describe('unsuccessful conversion (pandoc exit code)', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
ctx.CommandRunner.promises.run.resolves({ ctx.CommandRunner.promises.run.resolves({
stdout: 'mock-stdout', stdout: '',
stderr: 'mock-stderr', stderr: '',
exitCode: 63, exitCode: 63,
}) })
await expect( await expect(
ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.ConversionManager.promises.convertToLaTeXWithLock(
ctx.conversionId, ctx.conversionId,
@@ -184,7 +236,7 @@ describe('ConversionManager', function () {
).to.be.rejectedWith('pandoc conversion failed') ).to.be.rejectedWith('pandoc conversion failed')
}) })
it('should remove the entire conversion directory', async function (ctx) { it('should remove the entire conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
force: true, force: true,
recursive: true, recursive: true,
@@ -196,22 +248,13 @@ describe('ConversionManager', function () {
}) })
}) })
describe('unsuccessful compression (exitcode)', function () { describe('unsuccessful compression (zip exit code)', function () {
beforeEach(async function (ctx) { beforeEach(async function (ctx) {
ctx.CommandRunner.promises.run ctx.CommandRunner.promises.run
.onFirstCall() .onFirstCall()
.resolves({ .resolves({ stdout: '', stderr: '', exitCode: 0 })
stdout: 'mock-pandoc-stdout',
stderr: 'mock-pandoc-stderr',
exitCode: 0,
})
.onSecondCall() .onSecondCall()
.resolves({ .resolves({ stdout: '', stderr: '', exitCode: 12 })
stdout: 'mock-zip-stdout',
stderr: 'mock-zip-stderr',
exitCode: 12,
})
await expect( await expect(
ctx.ConversionManager.promises.convertToLaTeXWithLock( ctx.ConversionManager.promises.convertToLaTeXWithLock(
ctx.conversionId, ctx.conversionId,
@@ -221,7 +264,7 @@ describe('ConversionManager', function () {
).to.be.rejectedWith('pandoc conversion failed') ).to.be.rejectedWith('pandoc conversion failed')
}) })
it('should remove the entire conversion directory', async function (ctx) { it('should remove the entire conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
force: true, force: true,
recursive: true, recursive: true,
@@ -247,7 +290,7 @@ describe('ConversionManager', function () {
).to.be.rejectedWith('pandoc conversion failed') ).to.be.rejectedWith('pandoc conversion failed')
}) })
it('should remove the entire conversion directory', async function (ctx) { it('should remove the entire conversion directory', function (ctx) {
sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, { sinon.assert.calledWith(ctx.fs.rm, ctx.conversionDir, {
force: true, force: true,
recursive: true, recursive: true,
@@ -260,303 +303,153 @@ describe('ConversionManager', function () {
}) })
}) })
describe('with conversionType=markdown', function () { describe('with an unsupported conversion type', function () {
beforeEach(function (ctx) { it('should reject with an unsupported conversion type error', async function (ctx) {
ctx.inputPath = '/path/to/input.md' await expect(
}) ctx.ConversionManager.promises.convertToLaTeXWithLock(
describe('file setup and pandoc args', function () {
beforeEach(async function (ctx) {
ctx.result =
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, ctx.conversionId,
[ '/path/to/input.txt',
'pandoc', 'not-a-real-type'
'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',
])
})
})
describe('successful conversion', function () {
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')
) )
}) ).to.be.rejectedWith('unsupported conversion type')
it('should return the output zip path', function (ctx) {
expect(ctx.result).toBe(ctx.outputPath)
})
}) })
}) })
}) })
describe('convertLaTeXToDocumentInDirWithLock', function () { describe('convertLaTeXToDocumentInDirWithLock', function () {
describe('successfully', function () { beforeEach(function (ctx) {
beforeEach(async function (ctx) { ctx.compileDir = '/compiles/test-compile-dir'
ctx.compileDir = '/compiles/test-compile-dir' ctx.rootDocPath = 'main.tex'
ctx.rootDocPath = 'main.tex' })
ctx.type = 'docx'
ctx.extension = 'docx'
ctx.result = describe('pandoc args per conversion type', function () {
LATEX_TO_DOCUMENT_CASES.forEach(({ type, pandocArgs }) => {
it(`should run pandoc with the type-specific args for type=${type}`, async function (ctx) {
await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
ctx.conversionId, ctx.conversionId,
ctx.compileDir, ctx.compileDir,
ctx.rootDocPath, ctx.rootDocPath,
ctx.type type
) )
}) expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual(
pandocArgs('output-uuid')
it('should acquire a lock on the compile dir', function (ctx) { )
sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir) })
})
it('should release the lock', function (ctx) {
sinon.assert.called(ctx.lock.release)
})
it('should run pandoc with correct arguments', function (ctx) {
expect(ctx.CommandRunner.promises.run.callCount).toBe(1)
expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
ctx.conversionId,
[
'pandoc',
ctx.rootDocPath,
'--output',
`output-uuid.${ctx.extension}`,
'--from',
'latex',
'--to',
ctx.type,
'--citeproc',
'--number-sections',
'--resource-path=.',
],
ctx.compileDir,
ctx.Settings.pandocImage,
60_000,
{},
'conversions',
])
})
it('should convert conversion timeout to milliseconds', function (ctx) {
expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
})
it('should return path to the output document', function (ctx) {
expect(ctx.result).toBe(
Path.join(ctx.compileDir, `output-uuid.${ctx.extension}`)
)
}) })
}) })
describe('when pandoc fails (non-zero exit code)', function () { describe('with type=docx (representative non-compressing type)', function () {
it('should reject with an error and release the lock', async function (ctx) { describe('successful conversion', function () {
ctx.compileDir = '/compiles/test-compile-dir' beforeEach(async function (ctx) {
ctx.result =
ctx.CommandRunner.promises.run.resolves({ await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
stdout: 'mock-stdout', ctx.conversionId,
stderr: 'mock-stderr', ctx.compileDir,
exitCode: 1, ctx.rootDocPath,
'docx'
)
}) })
await expect( it('should acquire a lock on the compile dir', function (ctx) {
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir)
ctx.conversionId,
ctx.compileDir,
'main.tex',
'docx'
)
).to.be.rejectedWith('pandoc latex-to-document conversion failed')
sinon.assert.called(ctx.lock.release)
})
})
})
describe('convertLaTeXToDocumentInDirWithLock (type=markdown)', function () {
describe('successfully', function () {
beforeEach(async function (ctx) {
ctx.compileDir = '/compiles/test-compile-dir'
ctx.rootDocPath = 'main.tex'
ctx.result =
await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
ctx.conversionId,
ctx.compileDir,
ctx.rootDocPath,
'markdown'
)
})
it('should acquire a lock on the compile dir', function (ctx) {
sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir)
})
it('should release the lock', function (ctx) {
sinon.assert.called(ctx.lock.release)
})
it('should create a UUID-named subdirectory', function (ctx) {
sinon.assert.calledWith(
ctx.fs.mkdir,
Path.join(ctx.compileDir, 'output-uuid'),
{ recursive: true }
)
})
it('should run pandoc then zip (two commands total)', function (ctx) {
expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
})
it('should run pandoc outputting main.md into the UUID-named subdir', function (ctx) {
expect(ctx.CommandRunner.promises.run.firstCall.args).toEqual([
ctx.conversionId,
[
'pandoc',
ctx.rootDocPath,
'--output',
Path.join('output-uuid', 'main.md'),
'--from',
'latex',
'--to',
'markdown',
'--resource-path=.',
'--extract-media=output-uuid',
],
ctx.compileDir,
ctx.Settings.pandocImage,
60_000,
{},
'conversions',
])
})
it('should zip the project-named subdirectory', function (ctx) {
expect(ctx.CommandRunner.promises.run.secondCall.args).toEqual([
ctx.conversionId,
['sh', '-c', 'cd output-uuid && zip -r ../output-uuid.zip .'],
ctx.compileDir,
ctx.Settings.pandocImage,
60_000,
{},
'conversions',
])
})
it('should return the path to the zip file', function (ctx) {
expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.zip'))
})
it('should convert conversion timeout to milliseconds', function (ctx) {
expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000)
})
})
describe('when pandoc fails (non-zero exit code)', function () {
it('should reject with an error and release the lock', async function (ctx) {
ctx.compileDir = '/compiles/test-compile-dir'
ctx.CommandRunner.promises.run.resolves({
stdout: 'mock-stdout',
stderr: 'mock-stderr',
exitCode: 1,
}) })
await expect( it('should release the lock', function (ctx) {
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( sinon.assert.called(ctx.lock.release)
ctx.conversionId, })
ctx.compileDir,
'main.tex',
'markdown'
)
).to.be.rejectedWith('pandoc latex-to-document conversion failed')
sinon.assert.called(ctx.lock.release) it('should pass the conversion timeout in milliseconds', function (ctx) {
expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
})
it('should not create a subdirectory or run zip and should return the document path directly', function (ctx) {
sinon.assert.notCalled(ctx.fs.mkdir)
expect(ctx.CommandRunner.promises.run.callCount).toBe(1)
expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.docx'))
})
})
describe('when pandoc fails (non-zero exit code)', function () {
it('should reject with an error and release the lock', async function (ctx) {
ctx.CommandRunner.promises.run.resolves({
stdout: '',
stderr: '',
exitCode: 1,
})
await expect(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
ctx.conversionId,
ctx.compileDir,
ctx.rootDocPath,
'docx'
)
).to.be.rejectedWith('pandoc latex-to-document conversion failed')
sinon.assert.called(ctx.lock.release)
})
}) })
}) })
describe('when zip fails (non-zero exit code)', function () { describe('with type=markdown (representative compressing type)', function () {
it('should reject with an error and release the lock', async function (ctx) { describe('successful conversion', function () {
ctx.compileDir = '/compiles/test-compile-dir' beforeEach(async function (ctx) {
ctx.result =
await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
ctx.conversionId,
ctx.compileDir,
ctx.rootDocPath,
'markdown'
)
})
ctx.CommandRunner.promises.run it('should create a UUID-named subdirectory for the output', function (ctx) {
.onFirstCall() sinon.assert.calledWith(
.resolves({ stdout: '', stderr: '', exitCode: 0 }) ctx.fs.mkdir,
.onSecondCall() Path.join(ctx.compileDir, 'output-uuid'),
.resolves({ stdout: '', stderr: 'zip error', exitCode: 1 }) { recursive: true }
)
})
it('should run pandoc then zip the subdirectory and return the zip path', function (ctx) {
expect(ctx.CommandRunner.promises.run.callCount).toBe(2)
expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([
'sh',
'-c',
'cd output-uuid && zip -r ../output-uuid.zip .',
])
expect(ctx.result).toBe(Path.join(ctx.compileDir, 'output-uuid.zip'))
})
})
describe('when zip fails (non-zero exit code)', function () {
it('should reject with an error and release the lock', async function (ctx) {
ctx.CommandRunner.promises.run
.onFirstCall()
.resolves({ stdout: '', stderr: '', exitCode: 0 })
.onSecondCall()
.resolves({ stdout: '', stderr: 'zip error', exitCode: 1 })
await expect(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
ctx.conversionId,
ctx.compileDir,
ctx.rootDocPath,
'markdown'
)
).to.be.rejectedWith('zip compression of export failed')
sinon.assert.called(ctx.lock.release)
})
})
})
describe('with an unsupported conversion type', function () {
it('should reject with an unsupported conversion type error', async function (ctx) {
await expect( await expect(
ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock( ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
ctx.conversionId, ctx.conversionId,
ctx.compileDir, ctx.compileDir,
'main.tex', ctx.rootDocPath,
'markdown' 'not-a-real-type'
) )
).to.be.rejectedWith('zip compression of export failed') ).to.be.rejectedWith('unsupported conversion type')
sinon.assert.called(ctx.lock.release)
}) })
}) })
}) })

View File

@@ -493,6 +493,7 @@
"document_updated_externally": "", "document_updated_externally": "",
"document_updated_externally_detail": "", "document_updated_externally_detail": "",
"documentation": "", "documentation": "",
"docx_export_feedback_message": "",
"docx_import_feedback_message": "", "docx_import_feedback_message": "",
"doesnt_match": "", "doesnt_match": "",
"doing_this_allow_log_in_through_institution": "", "doing_this_allow_log_in_through_institution": "",
@@ -720,6 +721,7 @@
"generate_latex_from_prompts_and_images": "", "generate_latex_from_prompts_and_images": "",
"generate_token": "", "generate_token": "",
"generating": "", "generating": "",
"generic_export_feedback_message": "",
"generic_if_problem_continues_contact_us": "", "generic_if_problem_continues_contact_us": "",
"generic_linked_file_compile_error": "", "generic_linked_file_compile_error": "",
"generic_something_went_wrong": "", "generic_something_went_wrong": "",
@@ -1151,6 +1153,7 @@
"manager": "", "manager": "",
"managers_management": "", "managers_management": "",
"managing_your_subscription": "", "managing_your_subscription": "",
"markdown_export_feedback_message": "",
"markdown_import_feedback_message": "", "markdown_import_feedback_message": "",
"marked_as_resolved": "", "marked_as_resolved": "",
"math": "", "math": "",

View File

@@ -7,9 +7,6 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useCallback } from 'react' import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics' import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
import getMeta from '@/utils/meta'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import useConvertProject from '../../hooks/use-convert-project'
export const DownloadProjectZip = () => { export const DownloadProjectZip = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -103,75 +100,3 @@ export const DownloadProjectPDF = () => {
return button return button
} }
} }
export const ExportProjectDocx = () => {
const { t } = useTranslation()
const exportDocxEnabled = useFeatureFlag('export-docx')
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const downloadConversion = useConvertProject('docx')
const showExportDocx =
exportDocxEnabled && enablePandocConversions && !anonymous
useCommandProvider(
() =>
showExportDocx
? [
{
id: 'export-as-docx',
handler: downloadConversion,
label: t('export_as_docx'),
},
]
: [],
[t, showExportDocx, downloadConversion]
)
if (!showExportDocx) {
return null
}
return (
<OLDropdownMenuItem onClick={downloadConversion}>
{t('export_as_docx')}
</OLDropdownMenuItem>
)
}
export const ExportProjectMarkdown = () => {
const { t } = useTranslation()
const exportMarkdownEnabled = useFeatureFlag('export-markdown')
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const downloadConversion = useConvertProject('markdown')
const showExportMarkdown =
exportMarkdownEnabled && enablePandocConversions && !anonymous
useCommandProvider(
() =>
showExportMarkdown
? [
{
id: 'export-as-markdown',
handler: downloadConversion,
label: t('export_as_markdown'),
},
]
: [],
[t, showExportMarkdown, downloadConversion]
)
if (!showExportMarkdown) {
return null
}
return (
<OLDropdownMenuItem onClick={downloadConversion}>
{t('export_as_markdown')}
</OLDropdownMenuItem>
)
}

View File

@@ -24,6 +24,49 @@ const ExportDocumentErrorToast = () => {
) )
} }
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
const type = data?.type
if (type === 'docx') {
return (
<Trans
i18nKey="docx_export_feedback_message"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://forms.gle/Fg4BUXV2yv61hStX8"
target="_BLANK"
rel="noopener noreferrer"
/>,
]}
/>
)
} else if (type === 'markdown') {
return (
<Trans
i18nKey="markdown_export_feedback_message"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a
href="https://forms.gle/wc43zEukeqpec9mAA"
target="_BLANK"
rel="noopener noreferrer"
/>,
]}
/>
)
} else {
return (
<Trans
i18nKey="generic_export_feedback_message"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
<a href="/contact" target="_BLANK" rel="noopener noreferrer" />,
]}
/>
)
}
}
const generators: GlobalToastGeneratorEntry[] = [ const generators: GlobalToastGeneratorEntry[] = [
{ {
key: 'export-document:error', key: 'export-document:error',
@@ -44,6 +87,16 @@ const generators: GlobalToastGeneratorEntry[] = [
isDismissible: true, isDismissible: true,
}), }),
}, },
{
key: 'export-document:success',
generator: (data: any) => ({
content: <ExportDocumentSuccessToast data={data} />,
type: 'success',
autoHide: true,
delay: 45000,
isDismissible: true,
}),
},
] ]
export default generators export default generators
@@ -73,3 +126,11 @@ export const hidePreparingExportToast = (handle: string) => {
}) })
) )
} }
export const showExportDocumentSuccess = (type: 'docx' | 'markdown') => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
detail: { key: 'export-document:success', type },
})
)
}

View File

@@ -0,0 +1,51 @@
import getMeta from '@/utils/meta'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { FC } from 'react'
import useConvertProject from '../../hooks/use-convert-project'
import { useCommandProvider } from '../../hooks/use-command-provider'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
type ExportProjectWithConversionProps = {
featureFlag?: string
conversionType: 'docx' | 'markdown'
label: string
menuBarId: string
}
export const ExportProjectWithConversionButton: FC<
ExportProjectWithConversionProps
> = ({ featureFlag, conversionType, label, menuBarId }) => {
const splitTestEnabledIfNeeded = featureFlag
? isSplitTestEnabled(featureFlag)
: true
const enablePandocConversions =
getMeta('ol-ExposedSettings')?.enablePandocConversions
const anonymous = getMeta('ol-anonymous')
const downloadConversion = useConvertProject(conversionType)
const showExportButton =
splitTestEnabledIfNeeded && enablePandocConversions && !anonymous
useCommandProvider(
() =>
showExportButton
? [
{
id: menuBarId,
handler: downloadConversion,
label,
},
]
: [],
[showExportButton, downloadConversion, label, menuBarId]
)
if (!showExportButton) {
return null
}
return (
<OLDropdownMenuItem onClick={downloadConversion}>
{label}
</OLDropdownMenuItem>
)
}

View File

@@ -10,16 +10,12 @@ import { useTranslation } from 'react-i18next'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { useEditorContext } from '@/shared/context/editor-context' import { useEditorContext } from '@/shared/context/editor-context'
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context' import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
import { import { DownloadProjectPDF, DownloadProjectZip } from './download-project'
DownloadProjectPDF,
DownloadProjectZip,
ExportProjectDocx,
ExportProjectMarkdown,
} from './download-project'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item' import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
import EditableLabel from './editable-label' import EditableLabel from './editable-label'
import { DuplicateProject } from './duplicate-project' import { DuplicateProject } from './duplicate-project'
import { ExportProjectWithConversionButton } from './export-project-with-conversion-button'
const [publishModalModules] = importOverleafModules('publishModal') const [publishModalModules] = importOverleafModules('publishModal')
const SubmitProjectButton = publishModalModules?.import.NewPublishDropdownButton const SubmitProjectButton = publishModalModules?.import.NewPublishDropdownButton
@@ -81,8 +77,18 @@ export const ToolbarProjectTitle = () => {
)} )}
<DownloadProjectPDF /> <DownloadProjectPDF />
<DownloadProjectZip /> <DownloadProjectZip />
<ExportProjectDocx /> <ExportProjectWithConversionButton
<ExportProjectMarkdown /> featureFlag="export-docx"
conversionType="docx"
label={t('export_as_docx')}
menuBarId="export-as-docx"
/>
<ExportProjectWithConversionButton
featureFlag="export-markdown"
conversionType="markdown"
label={t('export_as_markdown')}
menuBarId="export-as-markdown"
/>
<DropdownDivider /> <DropdownDivider />
<DuplicateProject /> <DuplicateProject />
<OLDropdownMenuItem <OLDropdownMenuItem

View File

@@ -6,6 +6,7 @@ import { useCallback } from 'react'
import { import {
hidePreparingExportToast, hidePreparingExportToast,
showExportDocumentError, showExportDocumentError,
showExportDocumentSuccess,
showPreparingExportToast, showPreparingExportToast,
} from '../components/toolbar/export-document-toasts' } from '../components/toolbar/export-document-toasts'
@@ -32,6 +33,9 @@ export default function useConvertProject(type: 'docx' | 'markdown') {
if (downloadUrl) { if (downloadUrl) {
const url = new URL(downloadUrl, window.location.origin) const url = new URL(downloadUrl, window.location.origin)
location.assign(url.toString()) location.assign(url.toString())
showExportDocumentSuccess(type)
} else {
showExportDocumentError()
} }
} catch (error) { } catch (error) {
hidePreparingToast() hidePreparingToast()

View File

@@ -24,7 +24,7 @@ const MarkdownImportFeedbackToast = () => (
components={[ components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */ /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
<a <a
href="https://forms.gle/B1qrdiD983YcQCJA9" href="https://forms.gle/BQnQ57wB9ddS1FdKA"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
/>, />,
@@ -39,7 +39,8 @@ const generators: GlobalToastGeneratorEntry[] = [
generator: () => ({ generator: () => ({
content: <DocxImportFeedbackToast />, content: <DocxImportFeedbackToast />,
type: 'info', type: 'info',
autoHide: false, autoHide: true,
delay: 45000,
isDismissible: true, isDismissible: true,
}), }),
}, },
@@ -48,7 +49,8 @@ const generators: GlobalToastGeneratorEntry[] = [
generator: () => ({ generator: () => ({
content: <MarkdownImportFeedbackToast />, content: <MarkdownImportFeedbackToast />,
type: 'info', type: 'info',
autoHide: false, autoHide: true,
delay: 45000,
isDismissible: true, isDismissible: true,
}), }),
}, },

View File

@@ -653,6 +653,7 @@
"document_updated_externally_detail": "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions, please look in the history.", "document_updated_externally_detail": "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions, please look in the history.",
"documentation": "Documentation", "documentation": "Documentation",
"documentation_articles": "Documentation and articles", "documentation_articles": "Documentation and articles",
"docx_export_feedback_message": "Exporting Word docs is a new feature. <0>Let us know what you think</0>",
"docx_import_feedback_message": "Importing Word docs is a new feature. <0>Let us know what you think</0>", "docx_import_feedback_message": "Importing Word docs is a new feature. <0>Let us know what you think</0>",
"does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email", "does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email",
"doesnt_match": "Doesnt match", "doesnt_match": "Doesnt match",
@@ -959,6 +960,7 @@
"generate_latex_from_prompts_and_images": "Generate LaTeX from prompts and images", "generate_latex_from_prompts_and_images": "Generate LaTeX from prompts and images",
"generate_token": "Generate token", "generate_token": "Generate token",
"generating": "Generating", "generating": "Generating",
"generic_export_feedback_message": "This is a new feature. <0>Let us know what you think</0>",
"generic_if_problem_continues_contact_us": "If the problem continues please contact us", "generic_if_problem_continues_contact_us": "If the problem continues please contact us",
"generic_linked_file_compile_error": "This projects output files are not available because it failed to compile. Please open the project to see the compilation error details.", "generic_linked_file_compile_error": "This projects output files are not available because it failed to compile. Please open the project to see the compilation error details.",
"generic_something_went_wrong": "Sorry, something went wrong", "generic_something_went_wrong": "Sorry, something went wrong",
@@ -1520,6 +1522,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_export_feedback_message": "Exporting as Markdown is a new feature. <0>Let us know what you think</0>",
"markdown_import_feedback_message": "Importing Markdown files is a new feature. <0>Let us know what you think</0>", "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",