Просмотр исходного кода

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

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

GitOrigin-RevId: 300adfbb91e89f754ee7f835db792ccb50b27613
Mathias Jakobsen 5 месяцев назад
Родитель
Сommit
6b28a4ee5a

+ 232 - 339
services/clsi/test/unit/js/ConversionManager.test.js

@@ -6,6 +6,79 @@ const MODULE_PATH = Path.join(
   '../../../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 () {
   beforeEach(async function (ctx) {
     ctx.CommandRunner = {
@@ -65,12 +138,41 @@ describe('ConversionManager', 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) {
         ctx.inputPath = '/path/to/input.docx'
       })
 
-      describe('file setup and pandoc args', function () {
+      describe('successful conversion', function () {
         beforeEach(async function (ctx) {
           ctx.result =
             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)
         })
 
-        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, {
             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 with docx args followed by zip', 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.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',
+          expect(ctx.CommandRunner.promises.run.secondCall.args[1]).toEqual([
+            'zip',
+            '-r',
+            'output-uuid.zip',
+            '.',
           ])
-        })
-      })
-
-      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'
-            )
+          expect(ctx.CommandRunner.promises.run.firstCall.args[4]).toBe(60_000)
+          expect(ctx.CommandRunner.promises.run.secondCall.args[4]).toBe(60_000)
         })
 
-        it('should remove the source document after conversion', async function (ctx) {
+        it('should remove the source document after conversion', function (ctx) {
           sinon.assert.calledWith(
             ctx.fs.unlink,
             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) {
           ctx.CommandRunner.promises.run.resolves({
-            stdout: 'mock-stdout',
-            stderr: 'mock-stderr',
+            stdout: '',
+            stderr: '',
             exitCode: 63,
           })
-
           await expect(
             ctx.ConversionManager.promises.convertToLaTeXWithLock(
               ctx.conversionId,
@@ -184,7 +236,7 @@ describe('ConversionManager', function () {
           ).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, {
             force: 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) {
           ctx.CommandRunner.promises.run
             .onFirstCall()
-            .resolves({
-              stdout: 'mock-pandoc-stdout',
-              stderr: 'mock-pandoc-stderr',
-              exitCode: 0,
-            })
+            .resolves({ stdout: '', stderr: '', exitCode: 0 })
             .onSecondCall()
-            .resolves({
-              stdout: 'mock-zip-stdout',
-              stderr: 'mock-zip-stderr',
-              exitCode: 12,
-            })
-
+            .resolves({ stdout: '', stderr: '', exitCode: 12 })
           await expect(
             ctx.ConversionManager.promises.convertToLaTeXWithLock(
               ctx.conversionId,
@@ -221,7 +264,7 @@ describe('ConversionManager', function () {
           ).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, {
             force: true,
             recursive: true,
@@ -247,7 +290,7 @@ describe('ConversionManager', function () {
           ).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, {
             force: true,
             recursive: true,
@@ -260,303 +303,153 @@ describe('ConversionManager', function () {
       })
     })
 
-    describe('with conversionType=markdown', function () {
-      beforeEach(function (ctx) {
-        ctx.inputPath = '/path/to/input.md'
+    describe('with an unsupported conversion type', function () {
+      it('should reject with an unsupported conversion type error', async function (ctx) {
+        await expect(
+          ctx.ConversionManager.promises.convertToLaTeXWithLock(
+            ctx.conversionId,
+            '/path/to/input.txt',
+            'not-a-real-type'
+          )
+        ).to.be.rejectedWith('unsupported conversion type')
       })
+    })
+  })
 
-      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')
-          )
-        })
+  describe('convertLaTeXToDocumentInDirWithLock', function () {
+    beforeEach(function (ctx) {
+      ctx.compileDir = '/compiles/test-compile-dir'
+      ctx.rootDocPath = 'main.tex'
+    })
 
-        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([
+    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(
             ctx.conversionId,
-            ['zip', '-r', 'output-uuid.zip', '.'],
-            ctx.conversionDir,
-            ctx.Settings.pandocImage,
-            60_000,
-            {},
-            'conversions',
-          ])
+            ctx.compileDir,
+            ctx.rootDocPath,
+            type
+          )
+          expect(ctx.CommandRunner.promises.run.firstCall.args[1]).toEqual(
+            pandocArgs('output-uuid')
+          )
         })
       })
+    })
 
+    describe('with type=docx (representative non-compressing type)', function () {
       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(
+            await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
               ctx.conversionId,
-              ctx.inputPath,
-              'markdown'
+              ctx.compileDir,
+              ctx.rootDocPath,
+              'docx'
             )
         })
 
-        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 acquire a lock on the compile dir', function (ctx) {
+          sinon.assert.calledWith(ctx.LockManager.acquire, ctx.compileDir)
         })
 
-        it('should return the output zip path', function (ctx) {
-          expect(ctx.result).toBe(ctx.outputPath)
+        it('should release the lock', function (ctx) {
+          sinon.assert.called(ctx.lock.release)
         })
-      })
-    })
-  })
-
-  describe('convertLaTeXToDocumentInDirWithLock', function () {
-    describe('successfully', function () {
-      beforeEach(async function (ctx) {
-        ctx.compileDir = '/compiles/test-compile-dir'
-        ctx.rootDocPath = 'main.tex'
-        ctx.type = 'docx'
-        ctx.extension = 'docx'
-
-        ctx.result =
-          await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
-            ctx.conversionId,
-            ctx.compileDir,
-            ctx.rootDocPath,
-            ctx.type
-          )
-      })
 
-      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 pass the conversion timeout in 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}`)
-        )
+        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.compileDir = '/compiles/test-compile-dir'
-
-        ctx.CommandRunner.promises.run.resolves({
-          stdout: 'mock-stdout',
-          stderr: 'mock-stderr',
-          exitCode: 1,
+      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)
         })
-
-        await expect(
-          ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
-            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'
+    describe('with type=markdown (representative compressing type)', function () {
+      describe('successful conversion', function () {
+        beforeEach(async function (ctx) {
+          ctx.result =
+            await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
+              ctx.conversionId,
+              ctx.compileDir,
+              ctx.rootDocPath,
+              'markdown'
+            )
+        })
 
-        ctx.result =
-          await ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
-            ctx.conversionId,
-            ctx.compileDir,
-            ctx.rootDocPath,
-            'markdown'
+        it('should create a UUID-named subdirectory for the output', function (ctx) {
+          sinon.assert.calledWith(
+            ctx.fs.mkdir,
+            Path.join(ctx.compileDir, 'output-uuid'),
+            { recursive: true }
           )
-      })
-
-      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)
+        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 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,
+      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)
         })
-
-        await expect(
-          ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
-            ctx.conversionId,
-            ctx.compileDir,
-            'main.tex',
-            'markdown'
-          )
-        ).to.be.rejectedWith('pandoc latex-to-document conversion failed')
-
-        sinon.assert.called(ctx.lock.release)
       })
     })
 
-    describe('when zip 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
-          .onFirstCall()
-          .resolves({ stdout: '', stderr: '', exitCode: 0 })
-          .onSecondCall()
-          .resolves({ stdout: '', stderr: 'zip error', exitCode: 1 })
-
+    describe('with an unsupported conversion type', function () {
+      it('should reject with an unsupported conversion type error', async function (ctx) {
         await expect(
           ctx.ConversionManager.promises.convertLaTeXToDocumentInDirWithLock(
             ctx.conversionId,
             ctx.compileDir,
-            'main.tex',
-            'markdown'
+            ctx.rootDocPath,
+            'not-a-real-type'
           )
-        ).to.be.rejectedWith('zip compression of export failed')
-
-        sinon.assert.called(ctx.lock.release)
+        ).to.be.rejectedWith('unsupported conversion type')
       })
     })
   })

+ 3 - 0
services/web/frontend/extracted-translations.json

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

+ 0 - 75
services/web/frontend/js/features/ide-react/components/toolbar/download-project.tsx

@@ -7,9 +7,6 @@ import { useProjectContext } from '@/shared/context/project-context'
 import { useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 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 = () => {
   const { t } = useTranslation()
@@ -103,75 +100,3 @@ export const DownloadProjectPDF = () => {
     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>
-  )
-}

+ 61 - 0
services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx

@@ -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[] = [
   {
     key: 'export-document:error',
@@ -44,6 +87,16 @@ const generators: GlobalToastGeneratorEntry[] = [
       isDismissible: true,
     }),
   },
+  {
+    key: 'export-document:success',
+    generator: (data: any) => ({
+      content: <ExportDocumentSuccessToast data={data} />,
+      type: 'success',
+      autoHide: true,
+      delay: 45000,
+      isDismissible: true,
+    }),
+  },
 ]
 
 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 },
+    })
+  )
+}

+ 51 - 0
services/web/frontend/js/features/ide-react/components/toolbar/export-project-with-conversion-button.tsx

@@ -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>
+  )
+}

+ 14 - 8
services/web/frontend/js/features/ide-react/components/toolbar/project-title.tsx

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

+ 4 - 0
services/web/frontend/js/features/ide-react/hooks/use-convert-project.ts

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

+ 5 - 3
services/web/frontend/js/features/project-list/components/new-project-button/import-document-feedback-toast.tsx

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

+ 3 - 0
services/web/locales/en.json

@@ -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.",
   "documentation": "Documentation",
   "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>",
   "does_not_contain_or_significantly_match_your_email": "does not contain or significantly match your email",
   "doesnt_match": "Doesn’t match",
@@ -959,6 +960,7 @@
   "generate_latex_from_prompts_and_images": "Generate LaTeX from prompts and images",
   "generate_token": "Generate token",
   "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_linked_file_compile_error": "This project’s 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",
@@ -1520,6 +1522,7 @@
   "managers_management": "Managers management",
   "managing_your_subscription": "Managing your subscription",
   "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>",
   "marked_as_resolved": "Marked as resolved",
   "math": "Math",