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) ? ( + <> + + {canConvertToTypst && ( +
  • + + {t('convert_to_typst')} + +
  • + )} + {canConvertToLatex && ( +
  • + + {t('convert_to_latex')} + +
  • + )} + + ) : null} {canDelete ? ( <> diff --git a/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx b/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx index 05d9ab1ccd..5d5b3b1c7a 100644 --- a/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx +++ b/services/web/frontend/js/features/ide-react/components/toolbar/export-document-toasts.tsx @@ -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 {t('typst_export_feedback_message')} + } else if (type === 'latex') { + return {t('latex_export_feedback_message')} } else { return ( { } export const showExportDocumentSuccess = ( - type: 'docx' | 'markdown' | 'html' + type: 'docx' | 'markdown' | 'html' | 'typst' | 'latex' ) => { window.dispatchEvent( new CustomEvent('ide:show-toast', { diff --git a/services/web/frontend/js/features/ide-react/hooks/use-convert-doc.ts b/services/web/frontend/js/features/ide-react/hooks/use-convert-doc.ts new file mode 100644 index 0000000000..497a9cab22 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/hooks/use-convert-doc.ts @@ -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 } +} diff --git a/services/web/frontend/js/features/ide-react/hooks/use-convert-project.ts b/services/web/frontend/js/features/ide-react/hooks/use-convert-project.ts index 23052b4556..c6b449dfcf 100644 --- a/services/web/frontend/js/features/ide-react/hooks/use-convert-project.ts +++ b/services/web/frontend/js/features/ide-react/hooks/use-convert-project.ts @@ -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() } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 11e8f0a84f..a5dc048768 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 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", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index 4c9c383639..483236e880 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -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 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": "L’ouverture de ce contenu sur Overleaf a échoué car l’archive n’a 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",