diff --git a/services/web/README.md b/services/web/README.md index 51f73d02ec..811e7fbd33 100644 --- a/services/web/README.md +++ b/services/web/README.md @@ -43,7 +43,6 @@ Unit tests can be run in the `test_unit` container defined in `docker-compose.te The makefile contains a short cut to run these: ``` -make install # Only needs running once, or when npm packages are updated make unit_test ``` diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee index 1b138d646f..b2ed088d15 100644 --- a/services/web/app/coffee/Features/Errors/Errors.coffee +++ b/services/web/app/coffee/Features/Errors/Errors.coffee @@ -47,6 +47,13 @@ UnsupportedExportRecordsError = (message) -> return error UnsupportedExportRecordsError.prototype.__proto___ = Error.prototype +V1HistoryNotSyncedError = (message) -> + error = new Error(message) + error.name = "V1HistoryNotSyncedError" + error.__proto__ = V1HistoryNotSyncedError.prototype + return error +V1HistoryNotSyncedError.prototype.__proto___ = Error.prototype + ProjectHistoryDisabledError = (message) -> error = new Error(message) error.name = "ProjectHistoryDisabledError " @@ -62,4 +69,5 @@ module.exports = Errors = UnsupportedFileTypeError: UnsupportedFileTypeError UnsupportedBrandError: UnsupportedBrandError UnsupportedExportRecordsError: UnsupportedExportRecordsError + V1HistoryNotSyncedError: V1HistoryNotSyncedError ProjectHistoryDisabledError: ProjectHistoryDisabledError diff --git a/services/web/app/coffee/Features/Project/ProjectController.coffee b/services/web/app/coffee/Features/Project/ProjectController.coffee index 9312ba0b1b..f2c92efcb3 100644 --- a/services/web/app/coffee/Features/Project/ProjectController.coffee +++ b/services/web/app/coffee/Features/Project/ProjectController.coffee @@ -299,6 +299,8 @@ module.exports = ProjectController = autoPairDelimiters: user.ace.autoPairDelimiters pdfViewer : user.ace.pdfViewer syntaxValidation: user.ace.syntaxValidation + fontFamily: user.ace.fontFamily + lineHeight: user.ace.lineHeight } trackChangesState: project.track_changes privilegeLevel: privilegeLevel @@ -311,6 +313,7 @@ module.exports = ProjectController = maxDocLength: Settings.max_doc_length useV2History: !!project.overleaf?.history?.display showRichText: req.query?.rt == 'true' + showPublishModal: req.query?.pm == 'true' timer.done() _buildProjectList: (allProjects, v1Projects = [])-> diff --git a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee index 0b66b38ad6..529164a49e 100644 --- a/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectCreationHandler.coffee @@ -16,38 +16,42 @@ AnalyticsManger = require("../Analytics/AnalyticsManager") module.exports = ProjectCreationHandler = - createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)-> + createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)-> metrics.inc("project-creation") if arguments.length == 3 - callback = projectHistoryId - projectHistoryId = null + callback = attributes + attributes = null ProjectDetailsHandler.validateProjectName projectName, (error) -> return callback(error) if error? logger.log owner_id:owner_id, projectName:projectName, "creating blank project" - if projectHistoryId? - ProjectCreationHandler._createBlankProject owner_id, projectName, projectHistoryId, (error, project) -> + if attributes? + ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) -> return callback(error) if error? AnalyticsManger.recordEvent( - owner_id, 'project-imported', { projectId: project._id, projectHistoryId: projectHistoryId } + owner_id, 'project-imported', { projectId: project._id, attributes: attributes } ) callback(error, project) else HistoryManager.initializeProject (error, history) -> return callback(error) if error? - ProjectCreationHandler._createBlankProject owner_id, projectName, history?.overleaf_id, (error, project) -> + attributes = overleaf: history: id: history?.overleaf_id + ProjectCreationHandler._createBlankProject owner_id, projectName, attributes, (error, project) -> return callback(error) if error? AnalyticsManger.recordEvent( owner_id, 'project-created', { projectId: project._id } ) callback(error, project) - _createBlankProject : (owner_id, projectName, projectHistoryId, callback = (error, project) ->)-> + _createBlankProject : (owner_id, projectName, attributes, callback = (error, project) ->)-> rootFolder = new Folder {'name':'rootFolder'} - project = new Project - owner_ref : new ObjectId(owner_id) - name : projectName - project.overleaf.history.id = projectHistoryId + + attributes.owner_ref = new ObjectId(owner_id) + attributes.name = projectName + project = new Project attributes + + Object.assign(project, attributes) + if Settings.apis?.project_history?.displayHistoryForNewProjects project.overleaf.history.display = true if Settings.currentImageName? diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee index ebf34a9acb..27ead91841 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee @@ -405,14 +405,41 @@ module.exports = ProjectEntityUpdateHandler = self = DocumentUpdaterHandler.resyncProjectHistory project_id, projectHistoryId, docs, files, callback _cleanUpEntity: (project, entity, entityType, path, userId, callback = (error) ->) -> + self._updateProjectStructureWithDeletedEntity project, entity, entityType, path, userId, (error) -> + return callback(error) if error? + if(entityType.indexOf("file") != -1) + self._cleanUpFile project, entity, path, userId, callback + else if (entityType.indexOf("doc") != -1) + self._cleanUpDoc project, entity, path, userId, callback + else if (entityType.indexOf("folder") != -1) + self._cleanUpFolder project, entity, path, userId, callback + else + callback() + + # Note: the _cleanUpEntity code and _updateProjectStructureWithDeletedEntity + # methods both need to recursively iterate over the entities in folder. + # These are currently using separate implementations of the recursion. In + # future, these could be simplified using a common project entity iterator. + _updateProjectStructureWithDeletedEntity: (project, entity, entityType, entityPath, userId, callback = (error) ->) -> + # compute the changes to the project structure if(entityType.indexOf("file") != -1) - self._cleanUpFile project, entity, path, userId, callback + changes = oldFiles: [ {file: entity, path: entityPath} ] else if (entityType.indexOf("doc") != -1) - self._cleanUpDoc project, entity, path, userId, callback + changes = oldDocs: [ {doc: entity, path: entityPath} ] else if (entityType.indexOf("folder") != -1) - self._cleanUpFolder project, entity, path, userId, callback - else - callback() + changes = {oldDocs: [], oldFiles: []} + _recurseFolder = (folder, folderPath) -> + for doc in folder.docs + changes.oldDocs.push {doc, path: path.join(folderPath, doc.name)} + for file in folder.fileRefs + changes.oldFiles.push {file, path: path.join(folderPath, file.name)} + for childFolder in folder.folders + _recurseFolder(childFolder, path.join(folderPath, childFolder.name)) + _recurseFolder entity, entityPath + # now send the project structure changes to the docupdater + project_id = project._id.toString() + projectHistoryId = project.overleaf?.history?.id + DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback _cleanUpDoc: (project, doc, path, userId, callback = (error) ->) -> project_id = project._id.toString() @@ -429,21 +456,10 @@ module.exports = ProjectEntityUpdateHandler = self = return callback(error) if error? DocumentUpdaterHandler.deleteDoc project_id, doc_id, (error) -> return callback(error) if error? - DocstoreManager.deleteDoc project_id, doc_id, (error) -> - return callback(error) if error? - changes = oldDocs: [ {doc, path} ] - projectHistoryId = project.overleaf?.history?.id - DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback + DocstoreManager.deleteDoc project_id, doc_id, callback _cleanUpFile: (project, file, path, userId, callback = (error) ->) -> - ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, (error) -> - return callback(error) if error? - project_id = project._id.toString() - projectHistoryId = project.overleaf?.history?.id - changes = oldFiles: [ {file, path} ] - # we are now keeping a copy of every file versio so we no longer delete - # the file from the filestore - DocumentUpdaterHandler.updateProjectStructure project_id, projectHistoryId, userId, changes, callback + ProjectEntityMongoUpdateHandler._insertDeletedFileReference project._id, file, callback _cleanUpFolder: (project, folder, folderPath, userId, callback = (error) ->) -> jobs = [] diff --git a/services/web/app/coffee/Features/User/UserController.coffee b/services/web/app/coffee/Features/User/UserController.coffee index 7dc82c1a7b..ccbd0a86f1 100644 --- a/services/web/app/coffee/Features/User/UserController.coffee +++ b/services/web/app/coffee/Features/User/UserController.coffee @@ -81,6 +81,11 @@ module.exports = UserController = user.ace.pdfViewer = req.body.pdfViewer if req.body.syntaxValidation? user.ace.syntaxValidation = req.body.syntaxValidation + if req.body.fontFamily? + user.ace.fontFamily = req.body.fontFamily + if req.body.lineHeight? + user.ace.lineHeight = req.body.lineHeight + user.save (err)-> newEmail = req.body.email?.trim().toLowerCase() if !newEmail? or newEmail == user.email or req.externalAuthenticationSystemUsed() diff --git a/services/web/app/coffee/infrastructure/ExpressLocals.coffee b/services/web/app/coffee/infrastructure/ExpressLocals.coffee index 37cce62ddd..6943a79cb2 100644 --- a/services/web/app/coffee/infrastructure/ExpressLocals.coffee +++ b/services/web/app/coffee/infrastructure/ExpressLocals.coffee @@ -183,6 +183,9 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> # Don't include the query string parameters, otherwise Google # treats ?nocdn=true as the canonical version res.locals.currentUrl = Url.parse(req.originalUrl).pathname + res.locals.capitalize = (string) -> + return "" if string.length == 0 + return string.charAt(0).toUpperCase() + string.slice(1) next() webRouter.use (req, res, next)-> @@ -321,5 +324,7 @@ module.exports = (app, webRouter, privateApiRouter, publicApiRouter)-> chatMessageBorderLightness : if isOl then "40%" else "70%" chatMessageBgSaturation : if isOl then "85%" else "60%" chatMessageBgLightness : if isOl then "40%" else "97%" + defaultFontFamily : if isOl then 'lucida' else 'monaco' + defaultLineHeight : if isOl then 'normal' else 'compact' renderAnnouncements : !isOl next() diff --git a/services/web/app/coffee/infrastructure/Features.coffee b/services/web/app/coffee/infrastructure/Features.coffee index be7cd21d89..3878a97dbc 100644 --- a/services/web/app/coffee/infrastructure/Features.coffee +++ b/services/web/app/coffee/infrastructure/Features.coffee @@ -14,9 +14,9 @@ module.exports = Features = return Settings.enableGithubSync when 'v1-return-message' return Settings.accountMerge? and Settings.overleaf? - when 'publish-modal' - return Settings.showPublishModal when 'custom-togglers' return Settings.overleaf? + when 'templates' + return !Settings.overleaf? else throw new Error("unknown feature: #{feature}") diff --git a/services/web/app/coffee/infrastructure/LockManager.coffee b/services/web/app/coffee/infrastructure/LockManager.coffee index dd29bd0bde..b117ce49ea 100644 --- a/services/web/app/coffee/infrastructure/LockManager.coffee +++ b/services/web/app/coffee/infrastructure/LockManager.coffee @@ -3,20 +3,37 @@ Settings = require('settings-sharelatex') RedisWrapper = require("./RedisWrapper") rclient = RedisWrapper.client("lock") logger = require "logger-sharelatex" +os = require "os" +crypto = require "crypto" + +HOST = os.hostname() +PID = process.pid +RND = crypto.randomBytes(4).toString('hex') +COUNT = 0 module.exports = LockManager = LOCK_TEST_INTERVAL: 50 # 50ms between each test of the lock + MAX_TEST_INTERVAL: 1000 # back off to 1s between each test of the lock MAX_LOCK_WAIT_TIME: 10000 # 10s maximum time to spend trying to get the lock REDIS_LOCK_EXPIRY: 30 # seconds. Time until lock auto expires in redis SLOW_EXECUTION_THRESHOLD: 5000 # 5s, if execution takes longer than this then log + # Use a signed lock value as described in + # http://redis.io/topics/distlock#correct-implementation-with-a-single-instance + # to prevent accidental unlocking by multiple processes + randomLock : () -> + time = Date.now() + return "locked:host=#{HOST}:pid=#{PID}:random=#{RND}:time=#{time}:count=#{COUNT++}" + + unlockScript: 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end' + runWithLock: (namespace, id, runner = ( (releaseLock = (error) ->) -> ), callback = ( (error) -> )) -> # This error is defined here so we get a useful stacktrace slowExecutionError = new Error "slow execution during lock" timer = new metrics.Timer("lock.#{namespace}") key = "lock:web:#{namespace}:#{id}" - LockManager._getLock key, namespace, (error) -> + LockManager._getLock key, namespace, (error, lockValue) -> return callback(error) if error? # The lock can expire in redis but the process carry on. This setTimout call @@ -27,7 +44,7 @@ module.exports = LockManager = exceededLockTimeout = setTimeout countIfExceededLockTimeout, LockManager.REDIS_LOCK_EXPIRY * 1000 runner (error1, values...) -> - LockManager._releaseLock key, (error2) -> + LockManager._releaseLock key, lockValue, (error2) -> clearTimeout exceededLockTimeout timeTaken = new Date - timer.start @@ -39,19 +56,21 @@ module.exports = LockManager = return callback(error) if error? callback null, values... - _tryLock : (key, namespace, callback = (err, isFree)->)-> - rclient.set key, "locked", "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)-> + _tryLock : (key, namespace, callback = (err, isFree, lockValue)->)-> + lockValue = LockManager.randomLock() + rclient.set key, lockValue, "EX", LockManager.REDIS_LOCK_EXPIRY, "NX", (err, gotLock)-> return callback(err) if err? if gotLock == "OK" metrics.inc "lock.#{namespace}.try.success" - callback err, true + callback err, true, lockValue else metrics.inc "lock.#{namespace}.try.failed" logger.log key: key, redis_response: gotLock, "lock is locked" callback err, false - _getLock: (key, namespace, callback = (error) ->) -> + _getLock: (key, namespace, callback = (error, lockValue) ->) -> startTime = Date.now() + testInterval = LockManager.LOCK_TEST_INTERVAL attempts = 0 do attempt = () -> if Date.now() - startTime > LockManager.MAX_LOCK_WAIT_TIME @@ -59,13 +78,23 @@ module.exports = LockManager = return callback(new Error("Timeout")) attempts += 1 - LockManager._tryLock key, namespace, (error, gotLock) -> + LockManager._tryLock key, namespace, (error, gotLock, lockValue) -> return callback(error) if error? if gotLock metrics.gauge "lock.#{namespace}.get.success.tries", attempts - callback(null) + callback(null, lockValue) else - setTimeout attempt, LockManager.LOCK_TEST_INTERVAL + setTimeout attempt, testInterval + # back off when the lock is taken to avoid overloading + testInterval = Math.min(testInterval * 2, LockManager.MAX_TEST_INTERVAL) - _releaseLock: (key, callback)-> - rclient.del key, callback + _releaseLock: (key, lockValue, callback)-> + rclient.eval LockManager.unlockScript, 1, key, lockValue, (err, result) -> + if err? + return callback(err) + else if result? and result isnt 1 # successful unlock should release exactly one key + logger.error {key:key, lockValue:lockValue, redis_err:err, redis_result:result}, "unlocking error" + metrics.inc "unlock-error" + return callback(new Error("tried to release timed out lock")) + else + callback(null,result) diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index d3916ab4b0..009f582b2a 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -20,14 +20,16 @@ UserSchema = new Schema loginCount : {type : Number, default: 0} holdingAccount : {type : Boolean, default: false} ace : { - mode : {type : String, default: 'none'} - theme : {type : String, default: 'textmate'} - fontSize : {type : Number, default:'12'} - autoComplete: {type : Boolean, default: true} - autoPairDelimiters: {type : Boolean, default: true} - spellCheckLanguage : {type : String, default: "en"} - pdfViewer : {type : String, default: "pdfjs"} - syntaxValidation : {type : Boolean} + mode : {type : String, default: 'none'} + theme : {type : String, default: 'textmate'} + fontSize : {type : Number, default:'12'} + autoComplete : {type : Boolean, default: true} + autoPairDelimiters : {type : Boolean, default: true} + spellCheckLanguage : {type : String, default: "en"} + pdfViewer : {type : String, default: "pdfjs"} + syntaxValidation : {type : Boolean} + fontFamily : {type : String} + lineHeight : {type : String} } features : { collaborators: { type:Number, default: Settings.defaultFeatures.collaborators } diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug index a9ba703a32..7415c2ed77 100644 --- a/services/web/app/views/project/editor.pug +++ b/services/web/app/views/project/editor.pug @@ -154,7 +154,10 @@ block requirejs }, "ace/ext-language_tools": { "deps": ["ace/ace"] - } + }, + "ace/keybinding-vim": { + "deps": ["ace/ace"] + }, }, "config":{ "moment":{ diff --git a/services/web/app/views/project/editor/editor.pug b/services/web/app/views/project/editor/editor.pug index fed1889d79..3cac3a9490 100644 --- a/services/web/app/views/project/editor/editor.pug +++ b/services/web/app/views/project/editor/editor.pug @@ -58,6 +58,7 @@ div.full-size( read-only="!permissions.write", file-name="editor.open_doc_name", on-ctrl-enter="recompileViaKey", + on-save="recompileViaKey", on-ctrl-j="toggleReviewPanel", on-ctrl-shift-c="addNewCommentFromKbdShortcut", on-ctrl-shift-a="toggleTrackChangesFromKbdShortcut", @@ -68,6 +69,8 @@ div.full-size( track-changes= "editor.trackChanges", doc-id="editor.open_doc_id" renderer-data="reviewPanel.rendererData" + font-family="settings.fontFamily || ui.defaultFontFamily" + line-height="settings.lineHeight || ui.defaultLineHeight" ) != moduleIncludes('editor:body', locals) diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index bf7b9ba331..ed6ef85b44 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/services/web/app/views/project/editor/left-menu.pug @@ -150,6 +150,26 @@ aside#left-menu.full-size( each size in ['10','11','12','13','14','16','20','24'] option(value=size) #{size}px + .form-controls + label(for="fontFamily") #{translate("font_family")} + select( + name="fontFamily" + ng-model="settings.fontFamily" + ) + option(value="", disabled) #{translate("default")} + each fontFamily in ['monaco', 'lucida'] + option(value=fontFamily) #{capitalize(fontFamily)} + + .form-controls + label(for="lineHeight") #{translate("line_height")} + select( + name="lineHeight" + ng-model="settings.lineHeight" + ) + option(value="", disabled) #{translate("default")} + each lineHeight in ['compact', 'normal', 'wide'] + option(value=lineHeight) #{translate(lineHeight)} + .form-controls label(for="pdfViewer") #{translate("pdf_viewer")} select( diff --git a/services/web/app/views/project/list/project-list.pug b/services/web/app/views/project/list/project-list.pug index 240f62bd54..df3c2bf681 100644 --- a/services/web/app/views/project/list/project-list.pug +++ b/services/web/app/views/project/list/project-list.pug @@ -32,14 +32,16 @@ ng-click="downloadSelectedProjects()" ) i.fa.fa-cloud-download + - var archiveButtonString = settings.overleaf ? translate("archive") : translate("delete") + - var archiveButtonIcon = settings.overleaf ? "fa-inbox" : "fa-trash-o" a.btn.btn-default( href, - tooltip=translate('delete'), + tooltip=`{{ isArchiveableProjectSelected ? '${archiveButtonString}' : '${translate("leave")}' }}`, tooltip-placement="bottom", tooltip-append-to-body="true", ng-click="openArchiveProjectsModal()" ) - i.fa.fa-trash-o + i.fa(ng-class=`isArchiveableProjectSelected ? '${archiveButtonIcon}' : 'fa-sign-out'`) .btn-group.dropdown(ng-hide="selectedProjects.length < 1", dropdown) a.btn.btn-default.dropdown-toggle( diff --git a/services/web/app/views/project/list/side-bar.pug b/services/web/app/views/project/list/side-bar.pug index d01938b72d..e342b792a5 100644 --- a/services/web/app/views/project/list/side-bar.pug +++ b/services/web/app/views/project/list/side-bar.pug @@ -41,7 +41,7 @@ li(ng-class="{active: (filter == 'shared')}", ng-click="filterProjects('shared')") a(href) #{translate("shared_with_you")} li(ng-class="{active: (filter == 'archived')}", ng-click="filterProjects('archived')") - a(href) #{translate("deleted_projects")} + a(href) #{settings.overleaf ? translate("archived_projects") : translate("deleted_projects")} if isShowingV1Projects li(ng-class="{active: (filter == 'v1')}", ng-click="filterProjects('v1')") a(href) #{translate("v1_projects")} diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 078202655f..7c8602eb76 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -80,6 +80,8 @@ define [ miniReviewPanelVisible: false chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed + defaultFontFamily: window.uiConfig.defaultFontFamily + defaultLineHeight: window.uiConfig.defaultLineHeight } $scope.user = window.user diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index fd6a8224e4..ef3c5b20f8 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -3,6 +3,7 @@ define [ "ace/ace" "ace/ext-searchbox" "ace/ext-modelist" + "ace/keybinding-vim" "ide/editor/directives/aceEditor/undo/UndoManager" "ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager" "ide/editor/directives/aceEditor/spell-check/SpellCheckManager" @@ -14,9 +15,10 @@ define [ "ide/graphics/services/graphics" "ide/preamble/services/preamble" "ide/files/services/files" -], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) -> +], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) -> EditSession = ace.require('ace/edit_session').EditSession ModeList = ace.require('ace/ext/modelist') + Vim = ace.require('ace/keyboard/vim').Vim # set the path for ace workers if using a CDN (from editor.pug) if window.aceWorkerPath != "" @@ -60,6 +62,7 @@ define [ onCtrlJ: "=" # Toggle the review panel onCtrlShiftC: "=" # Add a new comment onCtrlShiftA: "=" # Toggle track-changes on/off + onSave: "=" # Cmd/Ctrl-S or :w in Vim syntaxValidation: "=" reviewPanel: "=" eventsBridge: "=" @@ -67,6 +70,8 @@ define [ trackChangesEnabled: "=" docId: "=" rendererData: "=" + lineHeight: "=" + fontFamily: "=" } link: (scope, element, attrs) -> # Don't freak out if we're already in an apply callback @@ -106,16 +111,26 @@ define [ metadataManager = new MetadataManager(scope, editor, element, metadata) autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files) - # Prevert Ctrl|Cmd-S from triggering save dialog - editor.commands.addCommand - name: "save", - bindKey: win: "Ctrl-S", mac: "Command-S" - exec: () -> - readOnly: true + scope.$watch "onSave", (callback) -> + if callback? + Vim.defineEx 'write', 'w', callback + editor.commands.addCommand + name: "save", + bindKey: win: "Ctrl-S", mac: "Command-S" + exec: callback + readOnly: true + # Not technically 'save', but Ctrl-. recompiles in OL v1 + # so maintain compatibility + editor.commands.addCommand + name: "recompile_v1", + bindKey: win: "Ctrl-.", mac: "Ctrl-." + exec: callback + readOnly: true editor.commands.removeCommand "transposeletters" editor.commands.removeCommand "showSettingsMenu" editor.commands.removeCommand "foldall" + # For European keyboards, the / is above 7 so needs Shift pressing. # This comes through as Command-Shift-/ on OS X, which is mapped to # toggleBlockComment. @@ -266,6 +281,29 @@ define [ "font-size": value + "px" }) + scope.$watch "fontFamily", (value) -> + if value? + switch value + when 'monaco' + editor.setOption('fontFamily', '"Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro", monospace') + when 'lucida' + editor.setOption('fontFamily', '"Lucida Console", monospace') + else + editor.setOption('fontFamily', null) + + scope.$watch "lineHeight", (value) -> + if value? + switch value + when 'compact' + editor.container.style.lineHeight = 1.33 + when 'normal' + editor.container.style.lineHeight = 1.6 + when 'wide' + editor.container.style.lineHeight = 2 + else + editor.container.style.lineHeight = 1.6 + editor.renderer.updateFontSize() + scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) -> if old_sharejs_doc? detachFromAce(old_sharejs_doc) diff --git a/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee b/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee index df65a2f721..9920372aab 100644 --- a/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee +++ b/services/web/public/coffee/ide/settings/controllers/SettingsController.coffee @@ -8,6 +8,12 @@ define [ if $scope.settings.pdfViewer not in ["pdfjs", "native"] $scope.settings.pdfViewer = "pdfjs" + if $scope.settings.fontFamily? and $scope.settings.fontFamily not in ["monaco", "lucida"] + delete $scope.settings.fontFamily + + if $scope.settings.lineHeight? and $scope.settings.lineHeight not in ["compact", "normal", "wide"] + delete $scope.settings.lineHeight + $scope.fontSizeAsStr = (newVal) -> if newVal? $scope.settings.fontSize = newVal @@ -41,6 +47,14 @@ define [ if syntaxValidation != oldSyntaxValidation settings.saveSettings({syntaxValidation: syntaxValidation}) + $scope.$watch "settings.fontFamily", (fontFamily, oldFontFamily) => + if fontFamily != oldFontFamily + settings.saveSettings({fontFamily: fontFamily}) + + $scope.$watch "settings.lineHeight", (lineHeight, oldLineHeight) => + if lineHeight != oldLineHeight + settings.saveSettings({lineHeight: lineHeight}) + $scope.$watch "project.spellCheckLanguage", (language, oldLanguage) => return if @ignoreUpdates if oldLanguage? and language != oldLanguage diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 5880129294..36520d2cc7 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -8,6 +8,7 @@ define [ $scope.notifications = window.data.notifications $scope.allSelected = false $scope.selectedProjects = [] + $scope.isArchiveableProjectSelected = false $scope.filter = "all" $scope.predicate = "lastUpdated" $scope.nUntagged = 0 @@ -85,6 +86,8 @@ define [ $scope.updateSelectedProjects = () -> $scope.selectedProjects = $scope.projects.filter (project) -> project.selected + $scope.isArchiveableProjectSelected = $scope.selectedProjects.some (project) -> + window.user_id == project.owner._id $scope.getSelectedProjects = () -> $scope.selectedProjects diff --git a/services/web/public/js/ace-1.2.5/theme-overleaf.js b/services/web/public/js/ace-1.2.5/theme-overleaf.js new file mode 100644 index 0000000000..f2162a542c --- /dev/null +++ b/services/web/public/js/ace-1.2.5/theme-overleaf.js @@ -0,0 +1,71 @@ +ace.define("ace/theme/overleaf",["require","exports","module","ace/lib/dom"], function(require, exports, module) { +"use strict"; + +exports.isDark = false; +exports.cssClass = "ace-overleaf"; +exports.cssText = ".ace-overleaf .ace_gutter {\ +background: #f0f0f0;\ +color: #333;\ +}\ +.ace-overleaf .ace_print-margin {\ +width: 1px;\ +background: #e8e8e8;\ +}\ +.ace-overleaf {\ +background-color: #FFFFFF;\ +color: black;\ +}\ +.ace-overleaf .ace_cursor {\ +color: black;\ +}\ +.ace-overleaf .ace_marker-layer .ace_selection {\ +background: rgb(181, 213, 255);\ +}\ +.ace-overleaf.ace_multiselect .ace_selection.ace_start {\ +box-shadow: 0 0 3px 0px white;\ +}\ +.ace-overleaf .ace_marker-layer .ace_step {\ +background: rgb(252, 255, 0);\ +}\ +.ace-overleaf .ace_marker-layer .ace_bracket {\ +border: 1px solid #5A5CAD;\ +}\ +.ace-overleaf .ace_marker-layer .ace_active-line {\ +background: rgba(0, 0, 0, 0.07);\ +}\ +.ace-overleaf .ace_gutter-active-line {\ +background-color: #dcdcdc;\ +}\ +.ace-overleaf .ace_marker-layer .ace_selected-word {\ +background: rgb(250, 250, 255);\ +border: 1px solid rgb(200, 200, 250);\ +}\ +.ace-overleaf .ace_fold {\ +background-color: #6B72E6;\ +}\ +.ace-overleaf .ace_comment {\ +color: #0080FF;\ +font-style: italic;\ +}\ +.ace-overleaf .ace_storage,\ +.ace-overleaf .ace_keyword {\ +color: #3F7F7F;\ +}\ +.ace-overleaf .ace_variable,\ +.ace-overleaf .ace_string {\ +color: #5A5CAD;\ +}\ +"; +exports.$id = "ace/theme/overleaf"; + +var dom = require("../lib/dom"); +dom.importCssString(exports.cssText, exports.cssClass); +}); + (function() { + ace.require(["ace/theme/overleaf"], function(m) { + if (typeof module == "object" && typeof exports == "object" && module) { + module.exports = m; + } + }); + })(); + \ No newline at end of file diff --git a/services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee b/services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee index 02019b74dd..7745a12ac5 100644 --- a/services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee +++ b/services/web/test/acceptance/coffee/ProjectStructureMongoLockTest.coffee @@ -22,6 +22,7 @@ describe "ProjectStructureMongoLock", -> before (done) -> # We want to instantly fail if the lock is taken LockManager.MAX_LOCK_WAIT_TIME = 1 + @lockValue = "lock-value" userDetails = holdingAccount:false, email: 'test@example.com' @@ -33,11 +34,13 @@ describe "ProjectStructureMongoLock", -> @locked_project = project namespace = ProjectEntityMongoUpdateHandler.LOCK_NAMESPACE @lock_key = "lock:web:#{namespace}:#{project._id}" - LockManager._getLock @lock_key, namespace, done + LockManager._getLock @lock_key, namespace, (err, lockValue) => + @lockValue = lockValue + done() return after (done) -> - LockManager._releaseLock @lock_key, done + LockManager._releaseLock @lock_key, @lockValue, done describe 'interacting with the locked project', -> LOCKING_UPDATE_METHODS = ['addDoc', 'addFile', 'mkdirp', 'moveEntity', 'renameEntity', 'addFolder'] diff --git a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee index 042dc34d09..6d86545622 100644 --- a/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectCreationHandlerTests.coffee @@ -98,7 +98,11 @@ describe 'ProjectCreationHandler', -> it "should set the overleaf id if overleaf id provided", (done)-> overleaf_id = 2345 - @handler.createBlankProject ownerId, projectName, overleaf_id, (err, project)-> + attributes = + overleaf: + history: + id: overleaf_id + @handler.createBlankProject ownerId, projectName, attributes, (err, project)-> project.overleaf.history.id.should.equal overleaf_id done() diff --git a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee index 2838db9c56..3a5b7a58bd 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee @@ -872,6 +872,12 @@ describe 'ProjectEntityUpdateHandler', -> .calledWith(@project, @entity, @path, userId) .should.equal true + it "should should send the update to the doc updater", -> + oldDocs = [ doc: @entity, path: @path ] + @DocumentUpdaterHandler.updateProjectStructure + .calledWith(project_id, projectHistoryId, userId, {oldDocs}) + .should.equal true + describe "a folder", -> beforeEach (done) -> @folder = @@ -905,6 +911,13 @@ describe 'ProjectEntityUpdateHandler', -> .calledWith(@project, @doc2, "/folder/doc-name-2", userId) .should.equal true + it "should should send one update to the doc updater for all docs and files", -> + oldFiles = [ {file: @file2, path: "/folder/file-name-2"}, {file: @file1, path: "/folder/subfolder/file-name-1"} ] + oldDocs = [ {doc: @doc2, path: "/folder/doc-name-2"}, { doc: @doc1, path: "/folder/subfolder/doc-name-1"} ] + @DocumentUpdaterHandler.updateProjectStructure + .calledWith(project_id, projectHistoryId, userId, {oldFiles, oldDocs}) + .should.equal true + describe "_cleanUpDoc", -> beforeEach -> @doc = @@ -941,12 +954,6 @@ describe 'ProjectEntityUpdateHandler', -> .calledWith(project_id, @doc._id.toString()) .should.equal true - it "should should send the update to the doc updater", -> - oldDocs = [ doc: @doc, path: @path ] - @DocumentUpdaterHandler.updateProjectStructure - .calledWith(project_id, projectHistoryId, userId, {oldDocs}) - .should.equal true - it "should call the callback", -> @callback.called.should.equal true diff --git a/services/web/test/unit/coffee/infrastructure/LockManager/ReleasingTheLock.coffee b/services/web/test/unit/coffee/infrastructure/LockManager/ReleasingTheLock.coffee index 256f72f95f..76747defbb 100644 --- a/services/web/test/unit/coffee/infrastructure/LockManager/ReleasingTheLock.coffee +++ b/services/web/test/unit/coffee/infrastructure/LockManager/ReleasingTheLock.coffee @@ -3,23 +3,25 @@ assert = require('assert') path = require('path') modulePath = path.join __dirname, '../../../../../app/js/infrastructure/LockManager.js' lockKey = "lock:web:{#{5678}}" +lockValue = "123456" SandboxedModule = require('sandboxed-module') describe 'LockManager - releasing the lock', ()-> - deleteStub = sinon.stub().callsArgWith(1) + deleteStub = sinon.stub().callsArgWith(4) mocks = "logger-sharelatex": log:-> "./RedisWrapper": client: ()-> auth:-> - del:deleteStub - - LockManager = SandboxedModule.require(modulePath, requires: mocks) + eval:deleteStub + LockManager = SandboxedModule.require(modulePath, requires: mocks) + LockManager.unlockScript = "this is the unlock script" + it 'should put a all data into memory', (done)-> - LockManager._releaseLock lockKey, -> - deleteStub.calledWith(lockKey).should.equal true + LockManager._releaseLock lockKey, lockValue, -> + deleteStub.calledWith(LockManager.unlockScript, 1, lockKey, lockValue).should.equal true done() diff --git a/services/web/test/unit/coffee/infrastructure/LockManager/tryLockTests.coffee b/services/web/test/unit/coffee/infrastructure/LockManager/tryLockTests.coffee index b3719623bb..9988b8583b 100644 --- a/services/web/test/unit/coffee/infrastructure/LockManager/tryLockTests.coffee +++ b/services/web/test/unit/coffee/infrastructure/LockManager/tryLockTests.coffee @@ -22,10 +22,11 @@ describe 'LockManager - trying the lock', -> describe "when the lock is not set", -> beforeEach -> @set.callsArgWith(5, null, "OK") + @LockManager.randomLock = sinon.stub().returns("random-lock-value") @LockManager._tryLock @key, @namespace, @callback it "should set the lock key with an expiry if it is not set", -> - @set.calledWith(@key, "locked", "EX", 30, "NX") + @set.calledWith(@key, "random-lock-value", "EX", 30, "NX") .should.equal true it "should return the callback with true", ->