Merge pull request #391 from sharelatex/ja-restore-files

Restore deletes files from their contents in project-history/OL history
This commit is contained in:
James Allen 2018-04-16 16:14:50 +01:00 committed by GitHub
commit f7a3231b42
26 changed files with 639 additions and 173 deletions

View file

@ -180,7 +180,7 @@ clean_css:
rm -f public/stylesheets/*.css*
clean_ci:
docker-compose down
docker-compose down -v
test: test_unit test_frontend test_acceptance
@ -221,7 +221,7 @@ test_acceptance_module: $(MODULE_MAKEFILES)
fi
test_clean:
docker-compose ${DOCKER_COMPOSE_FLAGS} down
docker-compose ${DOCKER_COMPOSE_FLAGS} down -v
ci:
MOCHA_ARGS="--reporter tap" \

View file

@ -57,26 +57,9 @@ module.exports = EditorHttpController =
privilegeLevel
)
restoreDoc: (req, res, next) ->
project_id = req.params.Project_id
doc_id = req.params.doc_id
name = req.body.name
if !name?
return res.sendStatus 400 # Malformed request
logger.log project_id: project_id, doc_id: doc_id, "restoring doc"
ProjectEntityUpdateHandler.restoreDoc project_id, doc_id, name, (err, doc, folder_id) =>
return next(error) if error?
EditorRealTimeController.emitToRoom(project_id, 'reciveNewDoc', folder_id, doc)
res.json {
doc_id: doc._id
}
_nameIsAcceptableLength: (name)->
return name? and name.length < 150 and name.length != 0
addDoc: (req, res, next) ->
project_id = req.params.Project_id
name = req.body.name

View file

@ -14,8 +14,6 @@ module.exports =
webRouter.delete '/project/:Project_id/doc/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteDoc
webRouter.delete '/project/:Project_id/folder/:entity_id', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.deleteFolder
webRouter.post '/project/:Project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, EditorHttpController.restoreDoc
# Called by the real-time API to load up the current project state.
# This is a post request because it's more than just a getting of data. We take actions
# whenever a user joins a project, like updating the deleted status.

View file

@ -6,6 +6,7 @@ Errors = require "../Errors/Errors"
HistoryManager = require "./HistoryManager"
ProjectDetailsHandler = require "../Project/ProjectDetailsHandler"
ProjectEntityUpdateHandler = require "../Project/ProjectEntityUpdateHandler"
RestoreManager = require "./RestoreManager"
module.exports = HistoryController =
selectHistoryApi: (req, res, next = (error) ->) ->
@ -71,3 +72,29 @@ module.exports = HistoryController =
return res.sendStatus(404) if error instanceof Errors.ProjectHistoryDisabledError
return next(error) if error?
res.sendStatus 204
restoreFileFromV2: (req, res, next) ->
{project_id} = req.params
{version, pathname} = req.body
user_id = AuthenticationController.getLoggedInUserId req
logger.log {project_id, version, pathname}, "restoring file from v2"
RestoreManager.restoreFileFromV2 user_id, project_id, version, pathname, (error, entity) ->
return next(error) if error?
res.json {
type: entity.type,
id: entity._id
}
restoreDocFromDeletedDoc: (req, res, next) ->
{project_id, doc_id} = req.params
{name} = req.body
user_id = AuthenticationController.getLoggedInUserId(req)
if !name?
return res.sendStatus 400 # Malformed request
logger.log {project_id, doc_id, user_id}, "restoring doc from v1 deleted doc"
RestoreManager.restoreDocFromDeletedDoc user_id, project_id, doc_id, name, (err, doc) =>
return next(error) if error?
res.json {
doc_id: doc._id
}

View file

@ -0,0 +1,60 @@
Settings = require 'settings-sharelatex'
Path = require 'path'
FileWriter = require '../../infrastructure/FileWriter'
FileSystemImportManager = require '../Uploads/FileSystemImportManager'
ProjectEntityHandler = require '../Project/ProjectEntityHandler'
ProjectLocator = require '../Project/ProjectLocator'
EditorController = require '../Editor/EditorController'
Errors = require '../Errors/Errors'
moment = require 'moment'
module.exports = RestoreManager =
restoreDocFromDeletedDoc: (user_id, project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
# This is the legacy method for restoring a doc from the SL track-changes/deletedDocs system.
# It looks up the deleted doc's contents, and then creates a new doc with the same content.
# We don't actually remove the deleted doc entry, just create a new one from its lines.
ProjectEntityHandler.getDoc project_id, doc_id, include_deleted: true, (error, lines) ->
return callback(error) if error?
addDocWithName = (name, callback) ->
EditorController.addDoc project_id, null, name, lines, 'restore', user_id, callback
RestoreManager._addEntityWithUniqueName addDocWithName, name, callback
restoreFileFromV2: (user_id, project_id, version, pathname, callback = (error, entity) ->) ->
RestoreManager._writeFileVersionToDisk project_id, version, pathname, (error, fsPath) ->
return callback(error) if error?
basename = Path.basename(pathname)
dirname = Path.dirname(pathname)
if dirname == '.' # no directory
dirname = ''
RestoreManager._findOrCreateFolder project_id, dirname, (error, parent_folder_id) ->
return callback(error) if error?
addEntityWithName = (name, callback) ->
FileSystemImportManager.addEntity user_id, project_id, parent_folder_id, name, fsPath, false, callback
RestoreManager._addEntityWithUniqueName addEntityWithName, basename, callback
_findOrCreateFolder: (project_id, dirname, callback = (error, folder_id) ->) ->
EditorController.mkdirp project_id, dirname, (error, newFolders, lastFolder) ->
return callback(error) if error?
return callback(null, lastFolder?._id)
_addEntityWithUniqueName: (addEntityWithName, basename, callback = (error) ->) ->
addEntityWithName basename, (error, entity) ->
if error?
if error instanceof Errors.InvalidNameError
# likely a duplicate name, so try with a prefix
date = moment(new Date()).format('Do MMM YY H:mm:ss')
# Move extension to the end so the file type is preserved
extension = Path.extname(basename)
basename = Path.basename(basename, extension)
basename = "#{basename} (Restored on #{date})"
if extension != ''
basename = "#{basename}#{extension}"
addEntityWithName basename, callback
else
callback(error)
else
callback(null, entity)
_writeFileVersionToDisk: (project_id, version, pathname, callback = (error, fsPath) ->) ->
url = "#{Settings.apis.project_history.url}/project/#{project_id}/version/#{version}/#{encodeURIComponent(pathname)}"
FileWriter.writeUrlToDisk project_id, url, callback

View file

@ -121,15 +121,6 @@ module.exports = ProjectEntityUpdateHandler = self =
logger.log project_id: project_id, "removing root doc"
Project.update {_id:project_id}, {$unset: {rootDoc_id: true}}, {}, callback
restoreDoc: (project_id, doc_id, name, callback = (error, doc, folder_id) ->) ->
if not SafePath.isCleanFilename name
return callback new Errors.InvalidNameError("invalid element name")
# getDoc will return the deleted doc's lines, but we don't actually remove
# the deleted doc, just create a new one from its lines.
ProjectEntityHandler.getDoc project_id, doc_id, include_deleted: true, (error, lines) ->
return callback(error) if error?
self.addDoc project_id, null, name, lines, callback
addDoc: wrapWithLock (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=>
self.addDocWithoutUpdatingHistory.withoutLock project_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path) ->
return callback(error) if error?

View file

@ -3,21 +3,40 @@ logger = require 'logger-sharelatex'
uuid = require 'uuid'
_ = require 'underscore'
Settings = require 'settings-sharelatex'
request = require 'request'
module.exports =
module.exports = FileWriter =
writeStreamToDisk: (identifier, stream, callback = (error, fsPath) ->) ->
callback = _.once(callback)
fsPath = "#{Settings.path.dumpFolder}/#{identifier}_#{uuid.v4()}"
writeStream = fs.createWriteStream(fsPath)
stream.pipe(writeStream)
stream.pause()
fs.mkdir Settings.path.dumpFolder, (error) ->
stream.resume()
if error? and error.code != 'EEXIST'
# Ignore error about already existing
return callback(error)
stream.on 'error', (err)->
logger.err {err, identifier, fsPath}, "[writeStreamToDisk] something went wrong with incoming stream"
callback(err)
writeStream.on 'error', (err)->
logger.err {err, identifier, fsPath}, "[writeStreamToDisk] something went wrong with writing to disk"
callback(err)
writeStream.on "finish", ->
logger.log {identifier, fsPath}, "[writeStreamToDisk] write stream finished"
callback null, fsPath
writeStream = fs.createWriteStream(fsPath)
stream.pipe(writeStream)
stream.on 'error', (err)->
logger.err {err, identifier, fsPath}, "[writeStreamToDisk] something went wrong with incoming stream"
callback(err)
writeStream.on 'error', (err)->
logger.err {err, identifier, fsPath}, "[writeStreamToDisk] something went wrong with writing to disk"
callback(err)
writeStream.on "finish", ->
logger.log {identifier, fsPath}, "[writeStreamToDisk] write stream finished"
callback null, fsPath
writeUrlToDisk: (identifier, url, callback = (error, fsPath) ->) ->
callback = _.once(callback)
stream = request.get(url)
stream.on 'response', (response) ->
if 200 <= response.statusCode < 300
FileWriter.writeStreamToDisk identifier, stream, callback
else
err = new Error("bad response from url: #{response.statusCode}")
logger.err {err, identifier, url}, err.message
callback(err)

View file

@ -201,8 +201,11 @@ module.exports = class Router
webRouter.get "/project/:Project_id/doc/:doc_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.get "/project/:Project_id/diff", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApiAndInjectUserDetails
webRouter.post "/project/:Project_id/doc/:doc_id/version/:version_id/restore", AuthorizationMiddlewear.ensureUserCanReadProject, HistoryController.selectHistoryApi, HistoryController.proxyToHistoryApi
webRouter.post '/project/:project_id/doc/:doc_id/restore', AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreDocFromDeletedDoc
webRouter.post "/project/:project_id/restore_file", AuthorizationMiddlewear.ensureUserCanWriteProjectContent, HistoryController.restoreFileFromV2
privateApiRouter.post "/project/:Project_id/history/resync", AuthenticationController.httpAuth, HistoryController.resyncProjectHistory
webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject
webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects

View file

@ -11,7 +11,7 @@ aside.file-tree.file-tree-history(ng-controller="FileTreeController", ng-class="
.entity
.entity-name.entity-name-history(
ng-click="history.selection.pathname = pathname",
ng-class="{ 'deleted': doc.deleted }"
ng-class="{ 'deleted': !!doc.deletedAtV }"
)
i.fa.fa-fw.fa-pencil
span {{ pathname }}

View file

@ -122,74 +122,8 @@ div#history(ng-show="ui.view == 'history'")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp; #{translate("loading")}...
.diff-panel.full-size(ng-controller="HistoryDiffController")
.diff(
ng-if="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error && !history.diff.binary"
)
.toolbar.toolbar-alt
span.name
| <strong>{{history.diff.highlights.length}} </strong>
ng-pluralize(
count="history.diff.highlights.length",
when="{\
'one': 'change',\
'other': 'changes'\
}"
)
| in <strong>{{history.diff.pathname}}</strong>
.toolbar-right
a.btn.btn-danger.btn-sm(
href,
ng-if="!history.isV2"
ng-click="openRestoreDiffModal()"
) #{translate("restore_to_before_these_changes")}
.deleted-warning(
ng-show="history.selection.docs[history.selection.pathname].deleted"
) This file was deleted
.diff-editor.hide-ace-cursor(
ace-editor="history",
theme="settings.theme",
font-size="settings.fontSize",
text="history.diff.text",
highlights="history.diff.highlights",
read-only="true",
resize-on="layout:main:resize",
navigate-highlights="true"
)
.diff.diff-binary(ng-show="history.diff.binary")
.toolbar.toolbar-alt
span.name
strong {{history.diff.pathname}}
.alert.alert-info We're still working on showing image and binary changes, sorry. Stay tuned!
.diff-deleted.text-centered(
ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
)
p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
p
a.btn.btn-primary.btn-lg(
href,
ng-click="restoreDeletedDoc()",
ng-disabled="history.diff.restoreInProgress"
) #{translate("restore")}
.diff-deleted.text-centered(
ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
)
p.text-serif #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
p.text-serif #{translate("file_restored_back_to_editor")}
p
a.btn.btn-default(
href,
ng-click="backToEditorAfterRestore()",
) #{translate("file_restored_back_to_editor_btn")}
.loading-panel(ng-show="history.diff.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp;#{translate("loading")}...
.error-panel(ng-show="history.diff.error")
.alert.alert-danger #{translate("generic_something_went_wrong")}
include ./history/diffPanelV1
include ./history/diffPanelV2
script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
.modal-header

View file

@ -0,0 +1,58 @@
.diff-panel.full-size(ng-if="!history.isV2", ng-controller="HistoryDiffController")
.diff(
ng-if="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error && !history.diff.binary"
)
.toolbar.toolbar-alt
span.name
| <strong>{{history.diff.highlights.length}} </strong>
ng-pluralize(
count="history.diff.highlights.length",
when="{\
'one': 'change',\
'other': 'changes'\
}"
)
| in <strong>{{history.diff.pathname}}</strong>
.toolbar-right
a.btn.btn-danger.btn-sm(
href,
ng-click="openRestoreDiffModal()"
) #{translate("restore_to_before_these_changes")}
.diff-editor.hide-ace-cursor(
ace-editor="history",
theme="settings.theme",
font-size="settings.fontSize",
text="history.diff.text",
highlights="history.diff.highlights",
read-only="true",
resize-on="layout:main:resize",
navigate-highlights="true"
)
.diff-deleted.text-centered(
ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
)
p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
p
a.btn.btn-primary.btn-lg(
href,
ng-click="restoreDeletedDoc()",
ng-disabled="history.diff.restoreInProgress"
) #{translate("restore")}
.diff-deleted.text-centered(
ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
)
p.text-serif #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
p.text-serif #{translate("file_restored_back_to_editor")}
p
a.btn.btn-default(
href,
ng-click="backToEditorAfterRestore()",
) #{translate("file_restored_back_to_editor_btn")}
.loading-panel(ng-show="history.diff.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp;#{translate("loading")}...
.error-panel(ng-show="history.diff.error")
.alert.alert-danger #{translate("generic_something_went_wrong")}

View file

@ -0,0 +1,50 @@
.diff-panel.full-size(ng-if="history.isV2", ng-controller="HistoryV2DiffController")
.diff(
ng-if="!!history.diff && !history.diff.loading && !history.diff.error",
ng-class="{ 'diff-binary': history.diff.binary }"
)
.toolbar.toolbar-alt
span.name(ng-if="history.diff.binary")
strong {{history.diff.pathname}}
span.name(ng-if="!history.diff.binary")
| <strong>{{history.diff.highlights.length}} </strong>
ng-pluralize(
count="history.diff.highlights.length",
when="{\
'one': 'change',\
'other': 'changes'\
}"
)
| in <strong>{{history.diff.pathname}}</strong>
.toolbar-right(ng-if="history.selection.docs[history.selection.pathname].deletedAtV")
button.btn.btn-danger.btn-sm(
ng-click="restoreDeletedFile()"
ng-show="!restoreState.error"
ng-disabled="restoreState.inflight"
)
i.fa.fa-fw.fa-step-backward
span(ng-show="!restoreState.inflight")
| Restore this deleted file
span(ng-show="restoreState.inflight")
| Restoring...
span.text-danger(ng-show="restoreState.error")
| Error restoring, sorry
.diff-editor.hide-ace-cursor(
ng-if="!history.diff.binary"
ace-editor="history",
theme="settings.theme",
font-size="settings.fontSize",
text="history.diff.text",
highlights="history.diff.highlights",
read-only="true",
resize-on="layout:main:resize",
navigate-highlights="true"
)
.alert.alert-info(ng-if="history.diff.binary")
| We're still working on showing image and binary changes, sorry. Stay tuned!
.loading-panel(ng-show="history.diff.loading")
i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp;#{translate("loading")}...
.error-panel(ng-show="history.diff.error")
.alert.alert-danger #{translate("generic_something_went_wrong")}

View file

@ -5,7 +5,7 @@
],
"verbose": true,
"legacyWatch": true,
"exec": "make compile",
"exec": "make compile || exit 1",
"watch": [
"public/coffee/",
"public/stylesheets/"

View file

@ -8,6 +8,7 @@ define [
@openFile(entity)
openFile: (file) ->
@ide.fileTreeManager.selectEntity(file)
@$scope.ui.view = "file"
@$scope.openFile = null
@$scope.$apply()

View file

@ -4,6 +4,7 @@ define [
"ide/history/util/displayNameForUser"
"ide/history/controllers/HistoryListController"
"ide/history/controllers/HistoryDiffController"
"ide/history/controllers/HistoryV2DiffController"
"ide/history/directives/infiniteScroll"
], (moment, ColorManager, displayNameForUser) ->
class HistoryManager

View file

@ -49,6 +49,13 @@ define [
diff: null
}
restoreFile: (version, pathname) ->
url = "/project/#{@$scope.project_id}/restore_file"
@ide.$http.post(url, {
version, pathname,
_csrf: window.csrfToken
})
MAX_RECENT_UPDATES_TO_SELECT: 5
autoSelectRecentUpdates: () ->
return if @$scope.history.updates.length == 0
@ -204,7 +211,7 @@ define [
# Map of original pathname -> doc summary
docs_summary = Object.create(null)
updatePathnameWithUpdateVersions = (pathname, update, deleted) ->
updatePathnameWithUpdateVersions = (pathname, update, deletedAtV) ->
# docs_summary is indexed by the original pathname the doc
# had at the start, so we have to look this up from the current
# pathname via original_pathname first
@ -222,8 +229,8 @@ define [
doc_summary.toV,
update.toV
)
if deleted?
doc_summary.deleted = true
if deletedAtV?
doc_summary.deletedAtV = deletedAtV
# Put updates in ascending chronological order
updates = updates.slice().reverse()
@ -241,7 +248,7 @@ define [
updatePathnameWithUpdateVersions(add.pathname, update)
if project_op.remove?
remove = project_op.remove
updatePathnameWithUpdateVersions(remove.pathname, update, true)
updatePathnameWithUpdateVersions(remove.pathname, update, project_op.atV)
return docs_summary

View file

@ -0,0 +1,39 @@
define [
"base"
], (App) ->
App.controller "HistoryV2DiffController", ($scope, ide, event_tracking) ->
$scope.restoreState =
inflight: false
error: false
$scope.restoreDeletedFile = () ->
pathname = $scope.history.selection.pathname
return if !pathname?
version = $scope.history.selection.docs[pathname]?.deletedAtV
return if !version?
event_tracking.sendMB "history-v2-restore-deleted"
$scope.restoreState.inflight = true
ide.historyManager
.restoreFile(version, pathname)
.then (response) ->
{ data } = response
openEntity(data)
.catch () ->
ide.showGenericMessageModal('Sorry, something went wrong with the restore')
.finally () ->
$scope.restoreState.inflight = false
openEntity = (data) ->
iterations = 0
{id, type} = data
do tryOpen = () ->
if iterations > 5
return
entity = ide.fileTreeManager.findEntityById(id)
if entity? and type == 'doc'
ide.editorManager.openDoc(entity)
if entity? and type == 'file'
ide.binaryFilesManager.openFile(entity)
else
setTimeout(tryOpen, 500)

View file

@ -0,0 +1,195 @@
async = require "async"
expect = require("chai").expect
_ = require 'underscore'
ProjectGetter = require "../../../app/js/Features/Project/ProjectGetter.js"
User = require "./helpers/User"
MockProjectHistoryApi = require "./helpers/MockProjectHistoryApi"
MockDocstoreApi = require "./helpers/MockDocstoreApi"
MockFileStoreApi = require "./helpers/MockFileStoreApi"
describe "RestoringFiles", ->
before (done) ->
@owner = new User()
@owner.login (error) =>
throw error if error?
@owner.createProject "example-project", {template: "example"}, (error, @project_id) =>
throw error if error?
done()
describe "restoring a deleted doc", ->
beforeEach (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
@doc = _.find project.rootFolder[0].docs, (doc) ->
doc.name == 'main.tex'
@owner.request {
method: "DELETE",
url: "/project/#{@project_id}/doc/#{@doc._id}",
}, (error, response, body) =>
throw error if error?
expect(response.statusCode).to.equal 204
@owner.request {
method: "POST",
url: "/project/#{@project_id}/doc/#{@doc._id}/restore"
json:
name: "main.tex"
}, (error, response, body) =>
throw error if error?
expect(response.statusCode).to.equal 200
expect(body.doc_id).to.exist
@restored_doc_id = body.doc_id
done()
it 'should have restored the doc', (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
restored_doc = _.find project.rootFolder[0].docs, (doc) ->
doc.name == 'main.tex'
expect(restored_doc._id.toString()).to.equal @restored_doc_id
expect(@doc._id).to.not.equal @restored_doc_id
# console.log @doc_id, @restored_doc_id, MockDocstoreApi.docs[@project_id]
expect(MockDocstoreApi.docs[@project_id][@restored_doc_id].lines).to.deep.equal(
MockDocstoreApi.docs[@project_id][@doc._id].lines
)
done()
describe "restoring from v2 history", ->
describe "restoring a text file", ->
beforeEach (done) ->
MockProjectHistoryApi.addOldFile(@project_id, 42, "foo.tex", "hello world, this is foo.tex!")
@owner.request {
method: "POST",
url: "/project/#{@project_id}/restore_file",
json:
pathname: "foo.tex"
version: 42
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
done()
it "should have created a doc", (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
doc = _.find project.rootFolder[0].docs, (doc) ->
doc.name == 'foo.tex'
doc = MockDocstoreApi.docs[@project_id][doc._id]
expect(doc.lines).to.deep.equal [
"hello world, this is foo.tex!"
]
done()
describe "restoring a binary file", ->
beforeEach (done) ->
MockProjectHistoryApi.addOldFile(@project_id, 42, "image.png", "Mock image.png content")
@owner.request {
method: "POST",
url: "/project/#{@project_id}/restore_file",
json:
pathname: "image.png"
version: 42
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
done()
it "should have created a file", (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
file = _.find project.rootFolder[0].fileRefs, (file) ->
file.name == 'image.png'
file = MockFileStoreApi.files[@project_id][file._id]
expect(file.content).to.equal "Mock image.png content"
done()
describe "restoring to a directory that exists", ->
beforeEach (done) ->
MockProjectHistoryApi.addOldFile(@project_id, 42, "foldername/foo2.tex", "hello world, this is foo-2.tex!")
@owner.request.post {
uri: "project/#{@project_id}/folder",
json:
name: 'foldername'
}, (error, response, body) =>
throw error if error?
expect(response.statusCode).to.equal 200
@owner.request {
method: "POST",
url: "/project/#{@project_id}/restore_file",
json:
pathname: "foldername/foo2.tex"
version: 42
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
done()
it "should have created the doc in the named folder", (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
folder = _.find project.rootFolder[0].folders, (folder) ->
folder.name == 'foldername'
doc = _.find folder.docs, (doc) ->
doc.name == 'foo2.tex'
doc = MockDocstoreApi.docs[@project_id][doc._id]
expect(doc.lines).to.deep.equal [
"hello world, this is foo-2.tex!"
]
done()
describe "restoring to a directory that no longer exists", ->
beforeEach (done) ->
MockProjectHistoryApi.addOldFile(@project_id, 42, "nothere/foo3.tex", "hello world, this is foo-3.tex!")
@owner.request {
method: "POST",
url: "/project/#{@project_id}/restore_file",
json:
pathname: "nothere/foo3.tex"
version: 42
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
done()
it "should have created the folder and restored the doc to it", (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
folder = _.find project.rootFolder[0].folders, (folder) ->
folder.name == 'nothere'
expect(folder).to.exist
doc = _.find folder.docs, (doc) ->
doc.name == 'foo3.tex'
doc = MockDocstoreApi.docs[@project_id][doc._id]
expect(doc.lines).to.deep.equal [
"hello world, this is foo-3.tex!"
]
done()
describe "restoring to a filename that already exists", ->
it "should have created the file with a timestamp appended", ->
beforeEach (done) ->
MockProjectHistoryApi.addOldFile(@project_id, 42, "main.tex", "hello world, this is main.tex!")
@owner.request {
method: "POST",
url: "/project/#{@project_id}/restore_file",
json:
pathname: "main.tex"
version: 42
}, (error, response, body) ->
throw error if error?
expect(response.statusCode).to.equal 200
done()
it "should have created the doc in the root folder", (done) ->
@owner.getProject @project_id, (error, project) =>
throw error if error?
doc = _.find project.rootFolder[0].docs, (doc) ->
doc.name.match(/main \(Restored on/)
expect(doc).to.exist
doc = MockDocstoreApi.docs[@project_id][doc._id]
expect(doc.lines).to.deep.equal [
"hello world, this is main.tex!"
]
done()

View file

@ -36,7 +36,7 @@ module.exports = MockDocUpdaterApi =
res.sendStatus 200
app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
res.send 204
res.sendStatus 204
app.listen 3003, (error) ->
throw error if error?

View file

@ -23,15 +23,23 @@ module.exports = MockDocStoreApi =
docs = (doc for doc_id, doc of @docs[req.params.project_id])
res.send JSON.stringify docs
app.get "/project/:project_id/doc/:doc_id", (req, res, next) =>
{project_id, doc_id} = req.params
doc = @docs[project_id][doc_id]
if doc.deleted and !req.query.include_deleted
res.sendStatus 404
else
res.send JSON.stringify doc
app.delete "/project/:project_id/doc/:doc_id", (req, res, next) =>
{project_id, doc_id} = req.params
if !@docs[project_id]?
res.send 404
res.sendStatus 404
else if !@docs[project_id][doc_id]?
res.send 404
res.sendStatus 404
else
@docs[project_id][doc_id] = undefined
res.send 204
@docs[project_id][doc_id].deleted = true
res.sendStatus 204
app.listen 3016, (error) ->
throw error if error?

View file

@ -4,10 +4,23 @@ app = express()
module.exports = MockProjectHistoryApi =
docs: {}
oldFiles: {}
addOldFile: (project_id, version, pathname, content) ->
@oldFiles["#{project_id}:#{version}:#{pathname}"] = content
run: () ->
app.post "/project", (req, res, next) =>
res.json project: id: 1
app.get "/project/:project_id/version/:version/:pathname", (req, res, next) =>
{project_id, version, pathname} = req.params
key = "#{project_id}:#{version}:#{pathname}"
if @oldFiles[key]?
res.send @oldFiles[key]
else
res.send 404
app.listen 3054, (error) ->
throw error if error?
.on "error", (error) ->

View file

@ -164,35 +164,6 @@ describe "EditorHttpController", ->
it "should return false in the callback", ->
@callback.calledWith(null, null, false).should.equal true
describe "restoreDoc", ->
beforeEach ->
@req.params =
Project_id: @project_id
doc_id: @doc_id
@req.body =
name: @name = "doc-name"
@ProjectEntityUpdateHandler.restoreDoc = sinon.stub().callsArgWith(3, null,
@doc = { "mock": "doc", _id: @new_doc_id = "new-doc-id" }
@folder_id = "mock-folder-id"
)
@EditorRealTimeController.emitToRoom = sinon.stub()
@EditorHttpController.restoreDoc @req, @res
it "should restore the doc", ->
@ProjectEntityUpdateHandler.restoreDoc
.calledWith(@project_id, @doc_id, @name)
.should.equal true
it "should the real-time clients about the new doc", ->
@EditorRealTimeController.emitToRoom
.calledWith(@project_id, 'reciveNewDoc', @folder_id, @doc)
.should.equal true
it "should return the new doc id", ->
@res.json
.calledWith(doc_id: @new_doc_id)
.should.equal true
describe "addDoc", ->
beforeEach ->
@doc = { "mock": "doc" }

View file

@ -22,6 +22,7 @@ describe "HistoryController", ->
"./HistoryManager": @HistoryManager = {}
"../Project/ProjectDetailsHandler": @ProjectDetailsHandler = {}
"../Project/ProjectEntityUpdateHandler": @ProjectEntityUpdateHandler = {}
"./RestoreManager": @RestoreManager = {}
@settings.apis =
trackchanges:
enabled: false

View file

@ -0,0 +1,126 @@
SandboxedModule = require('sandboxed-module')
assert = require('assert')
require('chai').should()
expect = require('chai').expect
sinon = require('sinon')
modulePath = require('path').join __dirname, '../../../../app/js/Features/History/RestoreManager'
Errors = require '../../../../app/js/Features/Errors/Errors'
tk = require("timekeeper")
moment = require('moment')
describe 'RestoreManager', ->
beforeEach ->
@RestoreManager = SandboxedModule.require modulePath, requires:
'../../infrastructure/FileWriter': @FileWriter = {}
'../Uploads/FileSystemImportManager': @FileSystemImportManager = {}
'../Project/ProjectLocator': @ProjectLocator = {}
'../Errors/Errors': Errors
'../Project/ProjectEntityHandler': @ProjectEntityHandler = {}
'../Editor/EditorController': @EditorController = {}
'logger-sharelatex': @logger = {log: sinon.stub(), err: sinon.stub()}
@user_id = 'mock-user-id'
@project_id = 'mock-project-id'
@version = 42
@callback = sinon.stub()
tk.freeze Date.now() # freeze the time for these tests
afterEach ->
tk.reset()
describe 'restoreFileFromV2', ->
beforeEach ->
@RestoreManager._writeFileVersionToDisk = sinon.stub().yields(null, @fsPath = "/tmp/path/on/disk")
@RestoreManager._findFolderOrRootFolderId = sinon.stub().yields(null, @folder_id = 'mock-folder-id')
@FileSystemImportManager.addEntity = sinon.stub().yields(null, @entity = 'mock-entity')
describe "with a file not in a folder", ->
beforeEach ->
@pathname = 'foo.tex'
@RestoreManager.restoreFileFromV2 @user_id, @project_id, @version, @pathname, @callback
it 'should write the file version to disk', ->
@RestoreManager._writeFileVersionToDisk
.calledWith(@project_id, @version, @pathname)
.should.equal true
it 'should find the root folder', ->
@RestoreManager._findFolderOrRootFolderId
.calledWith(@project_id, "")
.should.equal true
it 'should add the entity', ->
@FileSystemImportManager.addEntity
.calledWith(@user_id, @project_id, @folder_id, 'foo.tex', @fsPath, false)
.should.equal true
it 'should call the callback with the entity', ->
@callback.calledWith(null, @entity).should.equal true
describe "with a file in a folder", ->
beforeEach ->
@pathname = 'foo/bar.tex'
@RestoreManager.restoreFileFromV2 @user_id, @project_id, @version, @pathname, @callback
it 'should find the folder', ->
@RestoreManager._findFolderOrRootFolderId
.calledWith(@project_id, "foo")
.should.equal true
it 'should add the entity by its basename', ->
@FileSystemImportManager.addEntity
.calledWith(@user_id, @project_id, @folder_id, 'bar.tex', @fsPath, false)
.should.equal true
describe '_findFolderOrRootFolderId', ->
describe 'with a folder that exists', ->
beforeEach ->
@ProjectLocator.findElementByPath = sinon.stub().yields(null, {_id: @folder_id = 'mock-folder-id'}, 'folder')
@RestoreManager._findFolderOrRootFolderId @project_id, 'folder_name', @callback
it 'should look up the folder', ->
@ProjectLocator.findElementByPath
.calledWith({project_id: @project_id, path: 'folder_name'})
.should.equal true
it 'should return the folder_id', ->
@callback.calledWith(null, @folder_id).should.equal true
describe "with a folder that doesn't exist", ->
beforeEach ->
@ProjectLocator.findElementByPath = sinon.stub().yields(new Errors.NotFoundError())
@RestoreManager._findFolderOrRootFolderId @project_id, 'folder_name', @callback
it 'should return null', ->
@callback.calledWith(null, null).should.equal true
describe '_addEntityWithUniqueName', ->
beforeEach ->
@addEntityWithName = sinon.stub()
@name = 'foo.tex'
describe 'with a valid name', ->
beforeEach ->
@addEntityWithName.yields(null, @entity = 'mock-entity')
@RestoreManager._addEntityWithUniqueName @addEntityWithName, @name, @callback
it 'should add the entity', ->
@addEntityWithName.calledWith(@name).should.equal true
it 'should return the entity', ->
@callback.calledWith(null, @entity).should.equal true
describe "with an invalid name", ->
beforeEach ->
@addEntityWithName.onFirstCall().yields(new Errors.InvalidNameError())
@addEntityWithName.onSecondCall().yields(null, @entity = 'mock-entity')
@RestoreManager._addEntityWithUniqueName @addEntityWithName, @name, @callback
it 'should try to add the entity with its original name', ->
@addEntityWithName.calledWith('foo.tex').should.equal true
it 'should try to add the entity with a unique name', ->
date = moment(new Date()).format('Do MMM YY H:mm:ss')
@addEntityWithName.calledWith("foo (Restored on #{date}).tex").should.equal true
it 'should return the entity', ->
@callback.calledWith(null, @entity).should.equal true

View file

@ -251,27 +251,6 @@ describe 'ProjectEntityUpdateHandler', ->
.calledWith({_id : project_id}, {$unset : {rootDoc_id: true}})
.should.equal true
describe "restoreDoc", ->
beforeEach ->
@doc = { "mock": "doc" }
@ProjectEntityHandler.getDoc = sinon.stub().yields(null, @docLines)
@ProjectEntityUpdateHandler.addDoc = sinon.stub().yields(null, @doc, folder_id)
@ProjectEntityUpdateHandler.restoreDoc project_id, doc_id, @docName, @callback
it 'should get the doc lines', ->
@ProjectEntityHandler.getDoc
.calledWith(project_id, doc_id, include_deleted: true)
.should.equal true
it "should add a new doc with these doc lines", ->
@ProjectEntityUpdateHandler.addDoc
.calledWith(project_id, null, @docName, @docLines)
.should.equal true
it "should call the callback with the new folder and doc", ->
@callback.calledWith(null, @doc, folder_id).should.equal true
describe 'addDoc', ->
beforeEach ->
@path = "/path/to/doc"

View file

@ -130,12 +130,13 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) ->
project_ops: [{
remove:
pathname: "main.tex"
atV: 2
}]
fromV: 1, toV: 2
}])
expect(result).to.deep.equal({
"main.tex": { fromV: 0, toV: 2, deleted: true }
"main.tex": { fromV: 0, toV: 2, deletedAtV: 2 }
})
it "should track single deletions", ->
@ -143,10 +144,11 @@ define ['ide/history/HistoryV2Manager'], (HistoryV2Manager) ->
project_ops: [{
remove:
pathname: "main.tex"
atV: 1
}]
fromV: 0, toV: 1
}])
expect(result).to.deep.equal({
"main.tex": { fromV: 0, toV: 1, deleted: true }
"main.tex": { fromV: 0, toV: 1, deletedAtV: 1 }
})