feat: per-file Convert in explorer menu + fix export success toast
Build and Deploy Verso / deploy (push) Successful in 9m50s

- Add "Convert to Typst (.typ)" in the file tree context menu for .tex
  docs, and "Convert to LaTeX (.tex)" for .typ docs. Clicking runs pandoc
  on the file content and creates the converted file in the same folder.
- New backend endpoint POST /project/:id/doc/:id/convert/:type that reads
  the doc from document-updater, runs pandoc directly, and creates the
  result via ProjectEntityUpdateHandler (file tree updates via socket).
- Rewrite the export success toast for typst and latex conversions: no
  more link to /contact, replaced with a plain warning that errors are
  expected (pandoc does not support all constructs).
- Add i18n keys: convert_to_typst, convert_to_latex,
  typst_export_feedback_message, latex_export_feedback_message (EN + FR)
  and all four to extracted-translations.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-17 15:18:31 +00:00
parent de22dcf87f
commit ac2315bc8e
9 changed files with 186 additions and 2 deletions
@@ -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)
+6
View File
@@ -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(
@@ -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": "",
@@ -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() {
</li>
</>
) : null}
{(canConvertToTypst || canConvertToLatex) ? (
<>
<DropdownDivider />
{canConvertToTypst && (
<li role="none">
<DropdownItem onClick={convertToTypst}>
{t('convert_to_typst')}
</DropdownItem>
</li>
)}
{canConvertToLatex && (
<li role="none">
<DropdownItem onClick={convertToLatex}>
{t('convert_to_latex')}
</DropdownItem>
</li>
)}
</>
) : null}
{canDelete ? (
<>
<DropdownDivider />
@@ -42,6 +42,7 @@ const ExportDocumentErrorToast = ({ data }: { data?: any }) => {
}
const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
const { t } = useTranslation()
const type = data?.type
if (type === 'docx') {
return (
@@ -85,6 +86,10 @@ const ExportDocumentSuccessToast = ({ data }: { data?: any }) => {
]}
/>
)
} else if (type === 'typst') {
return <span>{t('typst_export_feedback_message')}</span>
} else if (type === 'latex') {
return <span>{t('latex_export_feedback_message')}</span>
} else {
return (
<Trans
@@ -175,7 +180,7 @@ export const hidePreparingExportToast = (handle: string) => {
}
export const showExportDocumentSuccess = (
type: 'docx' | 'markdown' | 'html'
type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex'
) => {
window.dispatchEvent(
new CustomEvent('ide:show-toast', {
@@ -0,0 +1,32 @@
import { postJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { useCallback, useState } from 'react'
import {
showExportDocumentError,
hideExportDocumentError,
} from '../components/toolbar/export-document-toasts'
export default function useConvertDoc(
type: 'typst' | 'latex',
docId: string | null
) {
const { projectId } = useProjectContext()
const [converting, setConverting] = useState(false)
const convert = useCallback(async () => {
if (!docId) return
setConverting(true)
hideExportDocumentError()
try {
await postJSON(`/project/${projectId}/doc/${docId}/convert/${type}`)
// File tree updates automatically via socket event
} catch (err: any) {
const errorMessage = err?.data?.error
showExportDocumentError(errorMessage)
} finally {
setConverting(false)
}
}, [projectId, docId, type])
return { convert, converting }
}
@@ -45,7 +45,7 @@ export default function useConvertProject(
if (downloadUrl) {
const url = new URL(downloadUrl, window.location.origin)
location.assign(url.toString())
showExportDocumentSuccess(type)
showExportDocumentSuccess(type as 'docx' | 'markdown' | 'html' | 'typst' | 'latex')
} else {
showExportDocumentError()
}
+4
View File
@@ -878,6 +878,8 @@
"explore_plans": "Explore plans",
"export_as_docx": "Export as Word document (.docx)",
"export_as_html": "Export as HTML (.html)",
"convert_to_latex": "Convert to LaTeX (.tex)",
"convert_to_typst": "Convert to Typst (.typ)",
"export_as_typst": "Export as Typst",
"export_as_latex": "Export as LaTeX (.tex)",
"export_as_markdown": "Export as Markdown (.md)",
@@ -1429,6 +1431,7 @@
"latex_articles_page_title": "Articles - Papers, Presentations, Reports and more",
"latex_examples": "LaTeX examples",
"latex_examples_page_title": "Examples - Equations, Formatting, TikZ, Packages and More",
"latex_export_feedback_message": "This conversion may contain errors — pandoc does not support all Typst constructs.",
"latex_places_figures_according_to_a_special_algorithm": "LaTeX places figures according to a special algorithm. You can use something called placement parameters to influence the positioning of the figure. <0>Find out how</>",
"latex_places_tables_according_to_a_special_algorithm": "LaTeX places tables according to a special algorithm. You can use “placement parameters” to influence the position of the table. <0>This article</0> explains how to do this.",
"latex_templates": "LaTeX Templates",
@@ -2895,6 +2898,7 @@
"turn_on_link_sharing": "Turn on link sharing",
"turn_on_password_visibility": "Turn on password visibility",
"tutorials": "Tutorials",
"typst_export_feedback_message": "This conversion may contain errors — pandoc does not support all LaTeX constructs.",
"uk": "Ukrainian",
"unable_to_extract_the_supplied_zip_file": "Opening this content on Overleaf failed because the zip file could not be extracted. Please ensure that it is a valid zip file. If this keeps happening for links on a particular site, please report this to them.",
"unarchive": "Restore",
+4
View File
@@ -881,6 +881,8 @@
"explore_plans": "Découvrir les offres",
"export_as_docx": "Exporter sous forme de document Word (.docx)",
"export_as_html": "Exporter au format HTML (.html)",
"convert_to_latex": "Convertir en LaTeX (.tex)",
"convert_to_typst": "Convertir en Typst (.typ)",
"export_as_typst": "Exporter en Typst",
"export_as_latex": "Exporter en LaTeX (.tex)",
"export_as_markdown": "Exporter en Markdown (.md)",
@@ -1432,6 +1434,7 @@
"latex_articles_page_title": "Articles - Documents, présentations, rapports et plus",
"latex_examples": "Exemples LaTeX",
"latex_examples_page_title": "Exemples - Équations, formatage, TikZ, packages et plus",
"latex_export_feedback_message": "Cette conversion peut contenir des erreurs — pandoc ne supporte pas toutes les constructions Typst.",
"latex_places_figures_according_to_a_special_algorithm": "LaTeX place les figures selon un algorithme spécial. Vous pouvez utiliser quelque chose appelé « paramètres de placement » pour influencer le positionnement de la figure. <0>Découvrez comment</>",
"latex_places_tables_according_to_a_special_algorithm": "LaTeX place les tableaux selon un algorithme spécial. Vous pouvez utiliser des « paramètres de placement » pour influencer la position du tableau. <0>This article</0> explique comment procéder.",
"latex_templates": "Modèles LaTeX",
@@ -2900,6 +2903,7 @@
"turn_on_link_sharing": "Activer le partage par lien",
"turn_on_password_visibility": "Activer la visibilité du mot de passe",
"tutorials": "Tutoriels",
"typst_export_feedback_message": "Cette conversion peut contenir des erreurs — pandoc ne supporte pas toutes les constructions LaTeX.",
"uk": "Ukrainien",
"unable_to_extract_the_supplied_zip_file": "Louverture de ce contenu sur Overleaf a échoué car larchive na pas pu être extraite. Veuillez vous assurer de la validité de cette archive. Si cela se produit régulièrement pour un site donné, veuillez leur faire part du problème.",
"unarchive": "Restaurer",