Files
overleaf-cep/services/web/app/src/Features/Project/ProjectRootDocManager.mjs
T
Jakob Ackermann cb0266035d [web] remove unnecessary filtering of rootFolder (#31585)
11 years ago, the db.projects collection was storing doc lines in the
file-tree/rootFolder. Any operations on the project that did not need
those lines were benefitting from excluding all those entries from the
file-tree. These days, the verbose exclusions are not useful anymore and
merely add load on mongo.

REF: 9805c6a9ff
GitOrigin-RevId: 89f544688934c1ed1ca98877ffbe8baefe66c126
2026-02-19 09:06:13 +00:00

229 lines
6.8 KiB
JavaScript

// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
import ProjectEntityHandler from './ProjectEntityHandler.mjs'
import ProjectEntityUpdateHandler from './ProjectEntityUpdateHandler.mjs'
import ProjectGetter from './ProjectGetter.mjs'
import DocumentHelper from '../Documents/DocumentHelper.mjs'
import Path from 'node:path'
import fs from 'node:fs'
import pLimit from 'p-limit'
import globby from 'globby'
import { callbackify, callbackifyMultiResult } from '@overleaf/promise-utils'
import logger from '@overleaf/logger'
import { BackgroundTaskTracker } from '../../infrastructure/GracefulShutdown.mjs'
const rootDocResets = new BackgroundTaskTracker('root doc resets')
function setRootDocAutomaticallyInBackground(projectId) {
rootDocResets.add()
setTimeout(async () => {
try {
await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
} catch (err) {
logger.warn({ err }, 'failed to set root doc automatically in background')
} finally {
rootDocResets.done()
}
}, 30 * 1000)
}
async function setRootDocAutomatically(projectId) {
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
for (const [path, doc] of Object.entries(docs)) {
if (
ProjectEntityUpdateHandler.isPathValidForRootDoc(path) &&
DocumentHelper.contentHasDocumentclass(doc.lines) &&
doc._id
) {
return await ProjectEntityUpdateHandler.promises.setRootDoc(
projectId,
doc._id
)
}
}
}
async function findRootDocFileFromDirectory(directoryPath) {
const unsortedFiles = await globby(['**/*.{tex,Rtex,Rnw}'], {
cwd: directoryPath,
followSymlinkedDirectories: false,
onlyFiles: true,
case: false,
})
// the search order is such that we prefer files closer to the project root, then
// we go by file size in ascending order, because people often have a main
// file that just includes a bunch of other files; then we go by name, in
// order to be deterministic
const files = await _sortFileList(unsortedFiles, directoryPath)
let firstFileInRootFolder
let doc = null
while (files.length > 0 && doc == null) {
const file = files.shift()
const content = await fs.promises.readFile(
Path.join(directoryPath, file),
'utf8'
)
const normalizedContent = (content || '').replace(/\r/g, '')
if (DocumentHelper.contentHasDocumentclass(normalizedContent)) {
doc = { path: file, content: normalizedContent }
}
if (!firstFileInRootFolder && !file.includes('/')) {
firstFileInRootFolder = { path: file, content: normalizedContent }
}
}
// if no doc was found, use the first file in the root folder as the main doc
if (!doc && firstFileInRootFolder) {
doc = firstFileInRootFolder
}
return { path: doc?.path, content: doc?.content }
}
async function setRootDocFromName(projectId, rootDocName) {
const docPaths =
await ProjectEntityHandler.promises.getAllDocPathsFromProjectById(projectId)
let docId, path
// strip off leading and trailing quotes from rootDocName
rootDocName = rootDocName.replace(/^'|'$/g, '')
// prepend a slash for the root folder if not present
if (rootDocName[0] !== '/') {
rootDocName = `/${rootDocName}`
}
// find the root doc from the filename
let rootDocId = null
for (docId in docPaths) {
// docpaths have a leading / so allow matching "folder/filename" and "/folder/filename"
path = docPaths[docId]
if (path === rootDocName) {
rootDocId = docId
}
}
// try a basename match if there was no match
if (!rootDocId) {
for (docId in docPaths) {
path = docPaths[docId]
if (Path.basename(path) === Path.basename(rootDocName)) {
rootDocId = docId
}
}
}
// set the root doc id if we found a match
if (rootDocId != null) {
return await ProjectEntityUpdateHandler.promises.setRootDoc(
projectId,
rootDocId
)
}
}
async function ensureRootDocumentIsSet(projectId) {
const project = await ProjectGetter.promises.getProject(projectId, {
rootDoc_id: 1,
})
if (!project) {
throw new Error('project not found')
}
if (project.rootDoc_id != null) {
return
}
return await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
}
/**
* @param {ObjectId | string} projectId
*/
async function ensureRootDocumentIsValid(projectId) {
const project = await ProjectGetter.promises.getProject(projectId, {
rootFolder: 1,
rootDoc_id: 1,
})
if (!project) {
throw new Error('project not found')
}
if (project.rootDoc_id != null) {
const docPath =
await ProjectEntityHandler.promises.getDocPathFromProjectByDocId(
project,
project.rootDoc_id
)
if (docPath) {
return
}
await ProjectEntityUpdateHandler.promises.unsetRootDoc(projectId)
}
return await ProjectRootDocManager.promises.setRootDocAutomatically(projectId)
}
async function _sortFileList(listToSort, rootDirectory) {
const limit = pLimit(5)
const files = await Promise.all(
listToSort.map(filePath =>
limit(async () => {
const fullPath = Path.join(rootDirectory, filePath)
const stat = await fs.promises.stat(fullPath)
return {
size: stat.size,
path: filePath,
elements: filePath.split(Path.sep).length,
name: Path.basename(filePath),
}
})
)
)
return files.sort(_rootDocSort).map(file => file.path)
}
function _rootDocSort(a, b) {
// sort first by folder depth
if (a.elements !== b.elements) {
return a.elements - b.elements
}
// ensure main.tex is at the start of each folder
if (a.name === 'main.tex' && b.name !== 'main.tex') {
return -1
}
if (a.name !== 'main.tex' && b.name === 'main.tex') {
return 1
}
// prefer smaller files
if (a.size !== b.size) {
return a.size - b.size
}
// otherwise, use the full path name
return a.path.localeCompare(b.path)
}
const ProjectRootDocManager = {
setRootDocAutomaticallyInBackground,
setRootDocAutomatically: callbackify(setRootDocAutomatically),
findRootDocFileFromDirectory: callbackifyMultiResult(
findRootDocFileFromDirectory,
['path', 'content']
),
setRootDocFromName: callbackify(setRootDocFromName),
ensureRootDocumentIsSet: callbackify(ensureRootDocumentIsSet),
ensureRootDocumentIsValid: callbackify(ensureRootDocumentIsValid),
promises: {
setRootDocAutomatically,
findRootDocFileFromDirectory,
setRootDocFromName,
ensureRootDocumentIsSet,
ensureRootDocumentIsValid,
},
}
export default ProjectRootDocManager