Files
Verso/services/web/app/src/Features/Uploads/ProjectUploadController.mjs
T
claude 9d11683920
Build and Deploy Verso / deploy (push) Successful in 1m14s
feat: Typst → LaTeX import, and fix export button visibility
Typst → LaTeX import:
- CLSI ConversionManager: add 'typst' to CONVERSION_CONFIGS
  (pandoc input.typ --from typst --to latex --standalone → zip archive)
- Web controller: allow 'typst' as a valid importDocument conversion type
- Frontend modal: add .typ file config to ImportDocumentModal
- New project button modal: add 'import_typst' variant + switch case
- New project button: show "Import Typst file" when enablePandocConversions
  is true (no split test gate — Verso has no SaaS split test infra)
- Locales: add choose_typst_file and import_typst_file keys (18 locales)

Export button fix:
- Remove featureFlag="export-typst" from ExportProjectWithConversionButton
  so the button shows whenever enablePandocConversions is true, without
  needing an unconfigured split test to return 'enabled'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 08:54:54 +00:00

319 lines
9.1 KiB
JavaScript

import logger from '@overleaf/logger'
import metrics from '@overleaf/metrics'
import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import Path from 'node:path'
import FileSystemImportManager from './FileSystemImportManager.mjs'
import ProjectUploadManager from './ProjectUploadManager.mjs'
import SessionManager from '../Authentication/SessionManager.mjs'
import EditorController from '../Editor/EditorController.mjs'
import ProjectLocator from '../Project/ProjectLocator.mjs'
import Settings from '@overleaf/settings'
import { InvalidZipFileError } from './ArchiveErrors.mjs'
import multer from 'multer'
import lodash from 'lodash'
import { expressify } from '@overleaf/promise-utils'
import {
DuplicateNameError,
FileTooLargeError,
DocumentConversionError,
} from '../Errors/Errors.js'
import DocumentConversionManager from './DocumentConversionManager.mjs'
import ProjectOptionsHandler from '../Project/ProjectOptionsHandler.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
const defaultsDeep = lodash.defaultsDeep
// Send a JSON response compatible with both normal mode and streaming mode
// (where startStreamingResponse already sent HTTP 200 + chunked headers).
function sendUploadResponse(res, statusCode, body) {
if (res.headersSent) {
return res.end(JSON.stringify(body))
}
return res.status(statusCode).json(body)
}
const upload = multer(
defaultsDeep(
{
dest: Settings.path.uploadFolder,
limits: {
fileSize: Settings.maxUploadSize,
},
},
Settings.multerOptions
)
)
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
function uploadProject(req, res, next) {
const timer = new metrics.Timer('project-upload')
const userId = SessionManager.getLoggedInUserId(req.session)
const { path } = req.file
const name = Path.basename(req.body.name, '.zip')
return ProjectUploadManager.createProjectFromZipArchive(
userId,
name,
path,
function (error, project) {
fs.unlink(path, function () {})
timer.done()
if (error != null) {
logger.error(
{ err: error, filePath: path, fileName: name },
'error uploading project'
)
if (error instanceof InvalidZipFileError) {
return res.status(422).json({
success: false,
error: req.i18n.translate(error.message),
})
} else {
return res.status(500).json({
success: false,
error: req.i18n.translate('upload_failed'),
})
}
} else {
return res.json({ success: true, project_id: project._id })
}
}
)
}
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
async function uploadFile(req, res, next) {
const timer = new metrics.Timer('file-upload')
const name = req.body.name
const { path } = req.file
const projectId = req.params.Project_id
const userId = SessionManager.getLoggedInUserId(req.session)
let { folder_id: folderId } = req.query
if (name == null || name.length === 0 || name.length > 150) {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
})
return sendUploadResponse(res, 422, {
success: false,
error: 'invalid_filename',
})
}
try {
// preserve the directory structure from an uploaded folder
const { relativePath } = req.body
// NOTE: Uppy sends a "null" string for `relativePath` when the file is not nested in a folder
if (relativePath && relativePath !== 'null') {
const { path } = await ProjectLocator.promises.findElement({
project_id: projectId,
element_id: folderId,
type: 'folder',
})
const { lastFolder } = await EditorController.promises.mkdirp(
projectId,
Path.dirname(Path.join('/', path.fileSystem, relativePath)),
userId
)
folderId = lastFolder._id
}
} catch (error) {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
})
return sendUploadResponse(res, 500, { success: false })
}
return FileSystemImportManager.addEntity(
userId,
projectId,
folderId,
name,
path,
true,
function (error, entity) {
fs.unlink(path, function () {})
timer.done()
if (error != null) {
if (error.name === 'InvalidNameError') {
return sendUploadResponse(res, 422, {
success: false,
error: 'invalid_filename',
})
} else if (error instanceof DuplicateNameError) {
return sendUploadResponse(res, 422, {
success: false,
error: 'duplicate_file_name',
})
} else if (error.message === 'project_has_too_many_files') {
return sendUploadResponse(res, 422, {
success: false,
error: 'project_has_too_many_files',
})
} else if (error.message === 'folder_not_found') {
return sendUploadResponse(res, 422, {
success: false,
error: 'folder_not_found',
})
} else {
logger.error(
{
err: error,
projectId,
filePath: path,
fileName: name,
folderId,
},
'error uploading file'
)
return sendUploadResponse(res, 422, { success: false })
}
} else {
return sendUploadResponse(res, 200, {
success: true,
entity_id: entity?._id,
entity_type: entity?.type,
hash: entity?.hash,
})
}
}
)
}
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
async function importDocument(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const { path } = req.file
const conversionType = req.query.type
if (!['docx', 'markdown', 'typst'].includes(conversionType)) {
return res.status(400).json({
success: false,
error: req.i18n.translate('invalid_import_type'),
})
}
const name = Path.basename(req.body.name, Path.extname(req.body.name))
logger.debug({ path, userId, conversionType }, 'importing document file')
try {
const archivePath =
await DocumentConversionManager.promises.convertDocumentToLaTeXZipArchive(
path,
userId,
conversionType
)
try {
const project =
await ProjectUploadManager.promises.createProjectFromZipArchive(
userId,
name,
archivePath
)
await ProjectOptionsHandler.promises.setCompiler(project._id, 'lualatex')
AnalyticsManager.recordEventForUserInBackground(
userId,
'convert-format',
{
sourceFormat: conversionType,
targetFormat: 'latex',
status: 'success',
operation: 'import',
}
)
res.json({ success: true, project_id: project._id })
} finally {
await fsPromises.unlink(archivePath).catch(unlinkErr => {
logger.warn(
{ err: unlinkErr, archivePath },
'error unlinking after docx conversion'
)
})
}
} catch (error) {
AnalyticsManager.recordEventForUserInBackground(userId, 'convert-format', {
sourceFormat: conversionType,
targetFormat: 'latex',
status: 'failure',
operation: 'import',
})
if (
error instanceof FileTooLargeError ||
error?.name === 'FileTooLargeError'
) {
return res.status(422).json({
success: false,
error: req.i18n.translate('file_too_large'),
})
}
if (error instanceof DocumentConversionError) {
return res.status(422).json({
success: false,
error: error.message || req.i18n.translate('upload_failed'),
})
}
logger.error({ error, userId }, 'unhandled error while importing document')
res.status(500).json({
success: false,
error: req.i18n.translate('upload_failed'),
})
} finally {
await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn(
{ err: unlinkErr, path },
'error unlinking uploaded file in importDocx'
)
})
}
}
/**
* @param {any} req
* @param {any} res
* @param {any} next
*/
function multerMiddleware(req, res, next) {
if (upload == null) {
return sendUploadResponse(res, 500, {
success: false,
error: req.i18n.translate('upload_failed'),
})
}
return upload.single('qqfile')(
req,
res,
/** @param {any} err */ function (err) {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return sendUploadResponse(res, 422, {
success: false,
error: req.i18n.translate('file_too_large'),
})
}
if (err) return next(err)
if (!req.file?.path) {
logger.info({ req }, 'missing req.file.path on upload')
return sendUploadResponse(res, 400, {
success: false,
error: 'invalid_upload_request',
})
}
next()
}
)
}
export default {
uploadProject,
uploadFile: expressify(uploadFile),
multerMiddleware,
importDocument: expressify(importDocument),
}