Files
overleaf-cep/services/web/app/src/Features/Project/ProjectRootDocManager.mjs
T
Andrew Rumble 4f02a85aa4 Update paths
GitOrigin-RevId: 399c594dd1bbf739d91874df6be3b70e57fe01e3
2025-11-06 09:05:57 +00:00

339 lines
9.6 KiB
JavaScript

/* eslint-disable
n/handle-callback-err,
max-len,
no-unused-vars,
no-useless-escape,
*/
// 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 async from 'async'
import globby from 'globby'
import _ from 'lodash'
import { promisifyAll } from '@overleaf/promise-utils'
import logger from '@overleaf/logger'
import { BackgroundTaskTracker } from '../../infrastructure/GracefulShutdown.js'
const rootDocResets = new BackgroundTaskTracker('root doc resets')
const ProjectRootDocManager = {
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)
},
setRootDocAutomatically(projectId, callback) {
if (callback == null) {
callback = function () {}
}
return ProjectEntityHandler.getAllDocs(projectId, function (error, docs) {
if (error != null) {
return callback(error)
}
const jobs = _.map(
docs,
(doc, path) =>
function (cb) {
if (
ProjectEntityUpdateHandler.isPathValidForRootDoc(path) &&
DocumentHelper.contentHasDocumentclass(doc.lines)
) {
async.setImmediate(function () {
cb(doc._id)
})
} else {
async.setImmediate(function () {
cb(null)
})
}
}
)
return async.series(jobs, function (rootDocId) {
if (rootDocId != null) {
return ProjectEntityUpdateHandler.setRootDoc(
projectId,
rootDocId,
callback
)
} else {
return callback()
}
})
})
},
findRootDocFileFromDirectory(directoryPath, callback) {
if (callback == null) {
callback = function () {}
}
const filePathsPromise = 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
filePathsPromise.then(
unsortedFiles =>
ProjectRootDocManager._sortFileList(
unsortedFiles,
directoryPath,
function (err, files) {
if (err != null) {
return callback(err)
}
let firstFileInRootFolder
let doc = null
return async.until(
cb => cb(null, doc != null || files.length === 0),
function (cb) {
const file = files.shift()
return fs.readFile(
Path.join(directoryPath, file),
'utf8',
function (error, content) {
if (error != null) {
return cb(error)
}
content = (content || '').replace(/\r/g, '')
if (DocumentHelper.contentHasDocumentclass(content)) {
doc = { path: file, content }
}
if (!firstFileInRootFolder && !file.includes('/')) {
firstFileInRootFolder = { path: file, content }
}
cb(null)
}
)
},
err => {
if (err) {
return callback(err)
}
// if no doc was found, use the first file in the root folder as the main doc
if (!doc && firstFileInRootFolder) {
doc = firstFileInRootFolder
}
callback(null, doc?.path, doc?.content)
}
)
}
),
err => callback(err)
)
// coffeescript's implicit-return mechanism returns filePathsPromise from this method, which confuses mocha
return null
},
setRootDocFromName(projectId, rootDocName, callback) {
if (callback == null) {
callback = function () {}
}
return ProjectEntityHandler.getAllDocPathsFromProjectById(
projectId,
function (error, docPaths) {
let docId, path
if (error != null) {
return callback(error)
}
// 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 ProjectEntityUpdateHandler.setRootDoc(
projectId,
rootDocId,
callback
)
} else {
return callback()
}
}
)
},
ensureRootDocumentIsSet(projectId, callback) {
if (callback == null) {
callback = function () {}
}
return ProjectGetter.getProject(
projectId,
{ rootDoc_id: 1 },
function (error, project) {
if (error != null) {
return callback(error)
}
if (project == null) {
return callback(new Error('project not found'))
}
if (project.rootDoc_id != null) {
return callback()
} else {
return ProjectRootDocManager.setRootDocAutomatically(
projectId,
callback
)
}
}
)
},
/**
* @param {ObjectId | string} project_id
* @param {Function} callback
*/
ensureRootDocumentIsValid(projectId, callback) {
ProjectGetter.getProjectWithoutDocLines(
projectId,
function (error, project) {
if (error != null) {
return callback(error)
}
if (project == null) {
return callback(new Error('project not found'))
}
if (project.rootDoc_id != null) {
ProjectEntityHandler.getDocPathFromProjectByDocId(
project,
project.rootDoc_id,
(err, docPath) => {
if (docPath) return callback()
ProjectEntityUpdateHandler.unsetRootDoc(projectId, () =>
ProjectRootDocManager.setRootDocAutomatically(
projectId,
callback
)
)
}
)
} else {
return ProjectRootDocManager.setRootDocAutomatically(
projectId,
callback
)
}
}
)
},
_sortFileList(listToSort, rootDirectory, callback) {
if (callback == null) {
callback = function () {}
}
return async.mapLimit(
listToSort,
5,
(filePath, cb) =>
fs.stat(Path.join(rootDirectory, filePath), function (err, stat) {
if (err != null) {
return cb(err)
}
return cb(null, {
size: stat.size,
path: filePath,
elements: filePath.split(Path.sep).length,
name: Path.basename(filePath),
})
}),
function (err, files) {
if (err != null) {
return callback(err)
}
return callback(
null,
_.map(
files.sort(ProjectRootDocManager._rootDocSort),
file => file.path
)
)
}
)
},
_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)
},
}
ProjectRootDocManager.promises = promisifyAll(ProjectRootDocManager, {
without: ['_rootDocSort', 'setRootDocAutomaticallyInBackground'],
multiResult: {
findRootDocFileFromDirectory: ['path', 'content'],
},
})
export default ProjectRootDocManager