diff --git a/services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs b/services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs
index bd34559b14..42b8ea9cbd 100644
--- a/services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs
+++ b/services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs
@@ -13,6 +13,15 @@ import { expressify } from '@overleaf/promise-utils'
import { pipeline } from 'node:stream/promises'
import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import { DocumentConversionError } from '../Errors/Errors.js'
+import ProjectLocator from '../Project/ProjectLocator.mjs'
+import ProjectEntityUpdateHandler from '../Project/ProjectEntityUpdateHandler.mjs'
+import { execFile } from 'node:child_process'
+import { promisify } from 'node:util'
+import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import nodePath from 'node:path'
+
+const execFileAsync = promisify(execFile)
const { z, zz, parseReq } = Validation
@@ -166,9 +175,86 @@ async function downloadPreparedProjectExport(req, res) {
})
}
+const DOC_CONVERSION_CONFIGS = {
+ typst: { fromExt: 'tex', toExt: 'typ', pandocFrom: 'latex', pandocTo: 'typst' },
+ latex: { fromExt: 'typ', toExt: 'tex', pandocFrom: 'typst', pandocTo: 'latex' },
+}
+
+async function convertDocInProject(req, res) {
+ const { Project_id: projectId, Doc_id: docId, type } = req.params
+ const userId = SessionManager.getLoggedInUserId(req.session)
+
+ const config = DOC_CONVERSION_CONFIGS[type]
+ if (!config) return res.status(400).json({ error: 'unsupported conversion type' })
+
+ const { lines } = await DocumentUpdaterHandler.promises.getDocument(
+ projectId,
+ docId,
+ -1
+ )
+ const content = lines.join('\n')
+
+ const { element, folder } = await ProjectLocator.promises.findElement({
+ project_id: projectId,
+ element_id: docId,
+ type: 'doc',
+ })
+ const parentFolderId = folder._id
+
+ const baseName = element.name.endsWith('.' + config.fromExt)
+ ? element.name.slice(0, -(config.fromExt.length + 1))
+ : element.name
+ const outputName = baseName + '.' + config.toExt
+
+ const tmpDir = await mkdtemp(nodePath.join(tmpdir(), 'verso-convert-'))
+ let convertedContent
+ try {
+ const inputPath = nodePath.join(tmpDir, `input.${config.fromExt}`)
+ const outputPath = nodePath.join(tmpDir, `output.${config.toExt}`)
+ await writeFile(inputPath, content, 'utf8')
+ try {
+ await execFileAsync(
+ 'pandoc',
+ [
+ inputPath,
+ '--from', config.pandocFrom,
+ '--to', config.pandocTo,
+ '--output', outputPath,
+ '--standalone',
+ ],
+ { timeout: 30_000 }
+ )
+ } catch (err) {
+ return res.status(422).json({ error: err.stderr || err.message })
+ }
+ convertedContent = await readFile(outputPath, 'utf8')
+ } finally {
+ await rm(tmpDir, { recursive: true, force: true })
+ }
+
+ const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
+ projectId,
+ parentFolderId,
+ outputName,
+ convertedContent.split('\n'),
+ userId,
+ 'convert'
+ )
+
+ ProjectAuditLogHandler.addEntryInBackground(
+ projectId,
+ 'doc-converted',
+ userId,
+ req.ip
+ )
+
+ res.json({ docId: doc._id, name: doc.name })
+}
+
export default {
exportProjectConversion: expressify(exportProjectConversion),
downloadPreparedProjectExport: expressify(downloadPreparedProjectExport),
+ convertDocInProject: expressify(convertDocInProject),
downloadProject(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs
index 0eddf33db9..e61b303195 100644
--- a/services/web/app/src/router.mjs
+++ b/services/web/app/src/router.mjs
@@ -836,6 +836,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanReadProject,
ProjectDownloadsController.downloadPreparedProjectExport
)
+ webRouter.post(
+ '/project/:Project_id/doc/:Doc_id/convert/:type',
+ AuthenticationController.requireLogin(),
+ AuthorizationMiddleware.ensureUserCanWriteProjectContent,
+ ProjectDownloadsController.convertDocInProject
+ )
}
webRouter.get(
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 42c3087bb5..5b223a2816 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -387,6 +387,8 @@
"continue_using_free_features": "",
"continue_with_free_plan": "",
"conversion_error_details": "",
+ "convert_to_latex": "",
+ "convert_to_typst": "",
"cookie_banner": "",
"cookie_banner_info": "",
"copied": "",
@@ -1099,6 +1101,7 @@
"last_verified": "",
"latam_discount_modal_info": "",
"latam_discount_modal_title": "",
+ "latex_export_feedback_message": "",
"latex_places_figures_according_to_a_special_algorithm": "",
"latex_places_tables_according_to_a_special_algorithm": "",
"layout_options": "",
@@ -2260,6 +2263,7 @@
"turn_off_link_sharing": "",
"turn_on": "",
"turn_on_link_sharing": "",
+ "typst_export_feedback_message": "",
"unarchive": "",
"uncategorized": "",
"uncategorized_projects": "",
diff --git a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx
index 3ffdbe7642..885d7bbd20 100644
--- a/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx
+++ b/services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx
@@ -8,6 +8,11 @@ import {
DropdownItem,
} from '@/shared/components/dropdown/dropdown-menu'
import { useFileTreeActionable } from '../../contexts/file-tree-actionable'
+import { useFileTreeSelectable } from '../../contexts/file-tree-selectable'
+import { useFileTreeData } from '@/shared/context/file-tree-data-context'
+import { findInTree } from '../../util/find-in-tree'
+import useConvertDoc from '@/features/ide-react/hooks/use-convert-doc'
+import getMeta from '@/utils/meta'
function FileTreeItemMenuItems() {
const { t } = useTranslation()
@@ -30,6 +35,25 @@ function FileTreeItemMenuItems() {
const { project } = useProjectContext()
const projectOwner = project?.owner?._id
+ const { fileTreeData } = useFileTreeData()
+ const { selectedEntityIds } = useFileTreeSelectable()
+ const selectedEntityId =
+ selectedEntityIds.size === 1 ? Array.from(selectedEntityIds)[0] : null
+ const selectedEntityType = selectedEntityId
+ ? findInTree(fileTreeData, selectedEntityId)?.type
+ : null
+ const isDoc = selectedEntityType === 'doc'
+ const enablePandocConversions =
+ getMeta('ol-ExposedSettings')?.enablePandocConversions
+
+ const canConvertToTypst =
+ enablePandocConversions && canRename && isDoc && selectedFileName?.endsWith('.tex')
+ const canConvertToLatex =
+ enablePandocConversions && canRename && isDoc && selectedFileName?.endsWith('.typ')
+
+ const { convert: convertToTypst } = useConvertDoc('typst', selectedEntityId)
+ const { convert: convertToLatex } = useConvertDoc('latex', selectedEntityId)
+
const downloadWithAnalytics = useCallback(() => {
// we are only interested in downloads of bib files WRT analytics, for the purposes of promoting the tpr integrations
if (selectedFileName?.endsWith('.bib')) {
@@ -75,6 +99,25 @@ function FileTreeItemMenuItems() {
>
) : null}
+ {(canConvertToTypst || canConvertToLatex) ? (
+ <>
+