diff --git a/services/web/app/src/Features/Editor/EditorController.js b/services/web/app/src/Features/Editor/EditorController.js index 80ca646aaf..fd5a419c02 100644 --- a/services/web/app/src/Features/Editor/EditorController.js +++ b/services/web/app/src/Features/Editor/EditorController.js @@ -614,6 +614,24 @@ const EditorController = { ) }, + setMainBibliographyDoc(projectId, newBibliographyDocId, callback) { + ProjectEntityUpdateHandler.setMainBibliographyDoc( + projectId, + newBibliographyDocId, + function (err) { + if (err) { + return callback(err) + } + EditorRealTimeController.emitToRoom( + projectId, + 'mainBibliographyDocUpdated', + newBibliographyDocId + ) + callback() + } + ) + }, + _notifyProjectUsersOfNewFolders(projectId, folders, callback) { async.eachSeries( folders, diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index a836dac80d..cd7da90629 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -90,6 +90,13 @@ const _ProjectController = { await EditorController.promises.setRootDoc(projectId, req.body.rootDocId) } + if (req.body.mainBibliographyDocId != null) { + await EditorController.promises.setMainBibliographyDoc( + projectId, + req.body.mainBibliographyDocId + ) + } + res.sendStatus(204) }, diff --git a/services/web/app/src/Features/Project/ProjectEditorHandler.js b/services/web/app/src/Features/Project/ProjectEditorHandler.js index 17c251357b..a0d82a3bc9 100644 --- a/services/web/app/src/Features/Project/ProjectEditorHandler.js +++ b/services/web/app/src/Features/Project/ProjectEditorHandler.js @@ -23,6 +23,7 @@ module.exports = ProjectEditorHandler = { _id: project._id, name: project.name, rootDoc_id: project.rootDoc_id, + mainBibliographyDoc_id: project.mainBibliographyDoc_id, rootFolder: [this.buildFolderModelView(project.rootFolder[0])], publicAccesLevel: project.publicAccesLevel, dropboxEnabled: !!project.existsInDropbox, diff --git a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js index 14756ab0c5..eff9984795 100644 --- a/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js +++ b/services/web/app/src/Features/Project/ProjectEntityUpdateHandler.js @@ -1112,6 +1112,31 @@ const convertDocToFile = wrapWithLock({ }, }) +async function setMainBibliographyDoc(projectId, newBibliographyDocId) { + logger.debug( + { projectId, mainBibliographyDocId: newBibliographyDocId }, + 'setting main bibliography doc' + ) + if (projectId == null || newBibliographyDocId == null) { + throw new Errors.InvalidError('missing arguments (project or doc)') + } + const docPath = + await ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId( + projectId, + newBibliographyDocId + ) + if (ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc(docPath)) { + await Project.updateOne( + { _id: projectId }, + { mainBibliographyDoc_id: newBibliographyDocId } + ).exec() + } else { + throw new Errors.UnsupportedFileTypeError( + 'invalid file extension for main bibliography doc' + ) + } +} + const ProjectEntityUpdateHandler = { LOCK_NAMESPACE, @@ -1154,6 +1179,8 @@ const ProjectEntityUpdateHandler = { unsetRootDoc: callbackify(unsetRootDoc), + setMainBibliographyDoc: callbackify(setMainBibliographyDoc), + updateDocLines: callbackify(updateDocLines), upsertDoc: callbackifyMultiResult(upsertDoc, ['doc', 'isNew']), @@ -1502,6 +1529,11 @@ const ProjectEntityUpdateHandler = { return VALID_ROOT_DOC_REGEXP.test(docExtension) }, + isPathValidForMainBibliographyDoc(docPath) { + const docExtension = Path.extname(docPath).toLowerCase() + return docExtension === '.bib' + }, + async _cleanUpEntity( project, newProject, diff --git a/services/web/app/src/models/Project.js b/services/web/app/src/models/Project.js index b78dadb116..249ee9979c 100644 --- a/services/web/app/src/models/Project.js +++ b/services/web/app/src/models/Project.js @@ -42,6 +42,7 @@ const ProjectSchema = new Schema( pendingEditor_refs: [{ type: ObjectId, ref: 'User' }], rootDoc_id: { type: ObjectId }, rootFolder: [FolderSchema], + mainBibliographyDoc_id: { type: ObjectId }, version: { type: Number }, // incremented for every change in the project structure (folders and filenames) publicAccesLevel: { type: String, default: 'private' }, compiler: { type: String, default: 'pdflatex' }, diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx index 807d4552e1..d99912fb52 100644 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx @@ -147,10 +147,19 @@ export const IdeReactProvider: FC = ({ children }) => { setProjectJoined(true) } + function handleMainBibliographyDocUpdated(payload: string) { + scopeStore.set('project.mainBibliographyDoc_id', payload) + } + socket.on('joinProjectResponse', handleJoinProjectResponse) + socket.on('mainBibliographyDocUpdated', handleMainBibliographyDocUpdated) return () => { socket.removeListener('joinProjectResponse', handleJoinProjectResponse) + socket.removeListener( + 'mainBibliographyDocUpdated', + handleMainBibliographyDocUpdated + ) } }, [socket, eventEmitter, scopeStore]) diff --git a/services/web/frontend/js/shared/context/project-context.tsx b/services/web/frontend/js/shared/context/project-context.tsx index 358ddcc30b..7577886328 100644 --- a/services/web/frontend/js/shared/context/project-context.tsx +++ b/services/web/frontend/js/shared/context/project-context.tsx @@ -40,6 +40,7 @@ export const ProjectProvider: FC = ({ children }) => { publicAccesLevel: publicAccessLevel, owner, trackChangesState, + mainBibliographyDoc_id: mainBibliographyDocId, } = project || projectFallback const tags = useMemo( @@ -63,6 +64,7 @@ export const ProjectProvider: FC = ({ children }) => { owner, tags, trackChangesState, + mainBibliographyDocId, } }, [ _id, @@ -76,6 +78,7 @@ export const ProjectProvider: FC = ({ children }) => { owner, tags, trackChangesState, + mainBibliographyDocId, ]) return ( diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-context.tsx index 3369fc8fd6..7eedd18654 100644 --- a/services/web/frontend/js/shared/context/types/project-context.tsx +++ b/services/web/frontend/js/shared/context/types/project-context.tsx @@ -14,6 +14,7 @@ export type ProjectContextValue = { _id: string name: string rootDocId?: string + mainBibliographyDocId?: string compiler: string members: ProjectContextMember[] invites: ProjectContextMember[] diff --git a/services/web/test/unit/src/Editor/EditorControllerTests.js b/services/web/test/unit/src/Editor/EditorControllerTests.js index 360ab077e1..208e152486 100644 --- a/services/web/test/unit/src/Editor/EditorControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorControllerTests.js @@ -957,4 +957,73 @@ describe('EditorController', function () { .should.equal(true) }) }) + + describe('setMainBibliographyDoc', function () { + describe('on success', function () { + beforeEach(function (done) { + this.mainBibliographyId = 'bib-doc-id' + this.ProjectEntityUpdateHandler.setMainBibliographyDoc = sinon + .stub() + .yields() + + this.callback = sinon.stub().callsFake(done) + this.EditorController.setMainBibliographyDoc( + this.project_id, + this.mainBibliographyId, + this.callback + ) + }) + + it('should forward the call to the ProjectEntityUpdateHandler', function () { + expect( + this.ProjectEntityUpdateHandler.setMainBibliographyDoc + ).to.have.been.calledWith(this.project_id, this.mainBibliographyId) + }) + + it('should emit the update to the room', function () { + expect( + this.EditorRealTimeController.emitToRoom + ).to.have.been.calledWith( + this.project_id, + 'mainBibliographyDocUpdated', + this.mainBibliographyId + ) + }) + + it('should return nothing', function () { + expect(this.callback).to.have.been.calledWithExactly() + }) + }) + + describe('on error', function () { + beforeEach(function (done) { + this.mainBibliographyId = 'bib-doc-id' + this.error = new Error('oh no') + this.ProjectEntityUpdateHandler.setMainBibliographyDoc = sinon + .stub() + .yields(this.error) + + this.callback = sinon.stub().callsFake(() => done()) + this.EditorController.setMainBibliographyDoc( + this.project_id, + this.mainBibliographyId, + this.callback + ) + }) + + it('should forward the call to the ProjectEntityUpdateHandler', function () { + expect( + this.ProjectEntityUpdateHandler.setMainBibliographyDoc + ).to.have.been.calledWith(this.project_id, this.mainBibliographyId) + }) + + it('should return the error', function () { + expect(this.callback).to.have.been.calledWithExactly(this.error) + }) + + it('should not emit the update to the room', function () { + expect(this.EditorRealTimeController.emitToRoom).to.not.have.been.called + }) + }) + }) }) diff --git a/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js b/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js index 35a4913c83..8cdd530a12 100644 --- a/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js +++ b/services/web/test/unit/src/Project/ProjectEntityUpdateHandlerTests.js @@ -3001,4 +3001,136 @@ describe('ProjectEntityUpdateHandler', function () { }) }) }) + + describe('isPathValidForMainBibliographyDoc', function () { + it('should not allow other endings than .bib', function () { + const endings = ['.tex', '.png', '.jpg', '.pdf', '.docx', '.doc'] + endings.forEach(ending => { + expect( + this.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc( + `/foo/bar/baz${ending}` + ) + ).to.be.false + }) + }) + + it('should allow a mix of lower and uppercase letters', function () { + const endings = ['.bib', '.BiB', '.BIB', '.bIB'] + endings.forEach(ending => { + expect( + this.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc( + `/foo/bar/baz.${ending}` + ) + ).to.be.true + }) + }) + + it('should not allow a path without an extension', function () { + expect( + this.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc( + '/foo/bar/baz' + ) + ).to.be.false + }) + + it('should not allow the empty path', function () { + expect( + this.ProjectEntityUpdateHandler.isPathValidForMainBibliographyDoc('') + ).to.be.false + }) + }) + + describe('setMainBibliographyDoc', function () { + describe('on success', function () { + beforeEach(function (done) { + this.doc = { + _id: new ObjectId(), + name: 'test.bib', + } + this.path = '/path/to/test.bib' + this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId + .withArgs(this.project._id, this.doc._id) + .resolves(this.path) + + this.callback = sinon.stub().callsFake(() => done()) + + this.ProjectEntityUpdateHandler.setMainBibliographyDoc( + this.project._id, + this.doc._id, + this.callback + ) + }) + + it('should update the project with the new main bibliography doc', function () { + expect(this.ProjectModel.updateOne).to.have.been.calledWith( + { _id: this.project._id }, + { mainBibliographyDoc_id: this.doc._id } + ) + }) + }) + + describe('on failure', function () { + describe("when document can't be found", function () { + beforeEach(function (done) { + this.doc = { + _id: new ObjectId(), + name: 'test.bib', + } + this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId + .withArgs(this.project._id, this.doc._id) + .rejects(new Error('error')) + + this.callback = sinon.stub().callsFake(() => done()) + + this.ProjectEntityUpdateHandler.setMainBibliographyDoc( + this.project._id, + this.doc._id, + this.callback + ) + }) + + it('should call the callback with an error', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match.instanceOf(Error) + ) + }) + + it('should not update the project with the new main bibliography doc', function () { + expect(this.ProjectModel.updateOne).to.not.have.been.called + }) + }) + + describe("when path is not a bib file can't be found", function () { + beforeEach(function (done) { + this.doc = { + _id: new ObjectId(), + name: 'test.bib', + } + + this.path = '/path/to/test.tex' + this.ProjectEntityHandler.promises.getDocPathByProjectIdAndDocId + .withArgs(this.project._id, this.doc._id) + .resolves(this.path) + + this.callback = sinon.stub().callsFake(() => done()) + + this.ProjectEntityUpdateHandler.setMainBibliographyDoc( + this.project._id, + this.doc._id, + this.callback + ) + }) + + it('should call the callback with an error', function () { + expect(this.callback).to.have.been.calledWith( + sinon.match.instanceOf(Error) + ) + }) + + it('should not update the project with the new main bibliography doc', function () { + expect(this.ProjectModel.updateOne).to.not.have.been.called + }) + }) + }) + }) })