diff --git a/doc/api/http_api.md b/doc/api/http_api.md index 5be9cb3a..7cdadefc 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -526,6 +526,16 @@ copies a pad with full history and chat. If force is true and the destination pa * `{code: 0, message:"ok", data: null}` * `{code: 1, message:"padID does not exist", data: null}` +#### copyPadWithoutHistory(sourceID, destinationID[, force=false]) +* API >= 1.2.15 + +copies a pad without copying the history and chat. If force is true and the destination pad exists, it will be overwritten. +Note that all the revisions will be lost! In most of the cases one should use `copyPad` API instead. + +*Example returns:* +* `{code: 0, message:"ok", data: null}` +* `{code: 1, message:"padID does not exist", data: null}` + #### movePad(sourceID, destinationID[, force=false]) * API >= 1.2.8 diff --git a/src/node/db/API.js b/src/node/db/API.js index 71bd09ec..20946111 100644 --- a/src/node/db/API.js +++ b/src/node/db/API.js @@ -597,6 +597,21 @@ exports.copyPad = async function(sourceID, destinationID, force) await pad.copy(destinationID, force); } +/** +copyPadWithoutHistory(sourceID, destinationID[, force=false]) copies a pad. If force is true, + the destination will be overwritten if it exists. + +Example returns: + +{code: 0, message:"ok", data: {padID: destinationID}} +{code: 1, message:"padID does not exist", data: null} +*/ +exports.copyPadWithoutHistory = async function(sourceID, destinationID, force) +{ + let pad = await getPadSafe(sourceID, true); + await pad.copyPadWithoutHistory(destinationID, force); +} + /** movePad(sourceID, destinationID[, force=false]) moves a pad. If force is true, the destination will be overwritten if it exists. diff --git a/src/node/db/Pad.js b/src/node/db/Pad.js index 3fe1dda2..33cc38bc 100644 --- a/src/node/db/Pad.js +++ b/src/node/db/Pad.js @@ -357,16 +357,9 @@ Pad.prototype.init = async function init(text) { } Pad.prototype.copy = async function copy(destinationID, force) { - + let destGroupID; let sourceID = this.id; - // allow force to be a string - if (typeof force === "string") { - force = (force.toLowerCase() === "true"); - } else { - force = !!force; - } - // Kick everyone from this pad. // This was commented due to https://github.com/ether/etherpad-lite/issues/3183. // Do we really need to kick everyone out? @@ -375,31 +368,15 @@ Pad.prototype.copy = async function copy(destinationID, force) { // flush the source pad: this.saveToDatabase(); - // if it's a group pad, let's make sure the group exists. - let destGroupID; - if (destinationID.indexOf("$") >= 0) { - destGroupID = destinationID.split("$")[0] - let groupExists = await groupManager.doesGroupExist(destGroupID); + try { + // if it's a group pad, let's make sure the group exists. + destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); - // group does not exist - if (!groupExists) { - throw new customError("groupID does not exist for destinationID", "apierror"); - } - } - - // if the pad exists, we should abort, unless forced. - let exists = await padManager.doesPadExist(destinationID); - - if (exists) { - if (!force) { - console.error("erroring out without force"); - throw new customError("destinationID already exists", "apierror"); - } - - // exists and forcing - let pad = await padManager.getPad(destinationID); - await pad.remove(); + // if force is true and already exists a Pad with the same id, remove that Pad + await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); + } catch(err) { + throw err; } // copy the 'pad' entry @@ -427,10 +404,7 @@ Pad.prototype.copy = async function copy(destinationID, force) { promises.push(p); } - // add the new pad to all authors who contributed to the old one - this.getAllAuthors().forEach(authorID => { - authorManager.addPad(authorID, destinationID); - }); + this.copyAuthorInfoToDestinationPad(destinationID); // wait for the above to complete await Promise.all(promises); @@ -452,6 +426,110 @@ Pad.prototype.copy = async function copy(destinationID, force) { return { padID: destinationID }; } +Pad.prototype.checkIfGroupExistAndReturnIt = async function checkIfGroupExistAndReturnIt(destinationID) { + let destGroupID = false; + + if (destinationID.indexOf("$") >= 0) { + + destGroupID = destinationID.split("$")[0] + let groupExists = await groupManager.doesGroupExist(destGroupID); + + // group does not exist + if (!groupExists) { + throw new customError("groupID does not exist for destinationID", "apierror"); + } + } + return destGroupID; +} + +Pad.prototype.removePadIfForceIsTrueAndAlreadyExist = async function removePadIfForceIsTrueAndAlreadyExist(destinationID, force) { + // if the pad exists, we should abort, unless forced. + let exists = await padManager.doesPadExist(destinationID); + + // allow force to be a string + if (typeof force === "string") { + force = (force.toLowerCase() === "true"); + } else { + force = !!force; + } + + if (exists) { + if (!force) { + console.error("erroring out without force"); + throw new customError("destinationID already exists", "apierror"); + } + + // exists and forcing + let pad = await padManager.getPad(destinationID); + await pad.remove(); + } +} + +Pad.prototype.copyAuthorInfoToDestinationPad = function copyAuthorInfoToDestinationPad(destinationID) { + // add the new sourcePad to all authors who contributed to the old one + this.getAllAuthors().forEach(authorID => { + authorManager.addPad(authorID, destinationID); + }); +} + +Pad.prototype.copyPadWithoutHistory = async function copyPadWithoutHistory(destinationID, force) { + let destGroupID; + let sourceID = this.id; + + // flush the source pad + this.saveToDatabase(); + + try { + // if it's a group pad, let's make sure the group exists. + destGroupID = await this.checkIfGroupExistAndReturnIt(destinationID); + + // if force is true and already exists a Pad with the same id, remove that Pad + await this.removePadIfForceIsTrueAndAlreadyExist(destinationID, force); + } catch(err) { + throw err; + } + + let sourcePad = await padManager.getPad(sourceID); + + // add the new sourcePad to all authors who contributed to the old one + this.copyAuthorInfoToDestinationPad(destinationID); + + // Group pad? Add it to the group's list + if (destGroupID) { + await db.setSub("group:" + destGroupID, ["pads", destinationID], 1); + } + + // initialize the pad with a new line to avoid getting the defaultText + let newPad = await padManager.getPad(destinationID, '\n'); + + let oldAText = this.atext; + let newPool = newPad.pool; + newPool.fromJsonable(sourcePad.pool.toJsonable()); // copy that sourceId pool to the new pad + + // based on Changeset.makeSplice + let assem = Changeset.smartOpAssembler(); + assem.appendOpWithText('=', ''); + Changeset.appendATextToAssembler(oldAText, assem); + assem.endDocument(); + + // although we have instantiated the newPad with '\n', an additional '\n' is + // added internally, so the pad text on the revision 0 is "\n\n" + let oldLength = 2; + + let newLength = assem.getLengthChange(); + let newText = oldAText.text; + + // create a changeset that removes the previous text and add the newText with + // all atributes present on the source pad + let changeset = Changeset.pack(oldLength, newLength, assem.toString(), newText); + newPad.appendRevision(changeset); + + hooks.callAll('padCopy', { 'originalPad': this, 'destinationID': destinationID }); + + return { padID: destinationID }; +} + + Pad.prototype.remove = async function remove() { var padID = this.id; diff --git a/src/node/handler/APIHandler.js b/src/node/handler/APIHandler.js index cac8c15c..cb714460 100644 --- a/src/node/handler/APIHandler.js +++ b/src/node/handler/APIHandler.js @@ -142,8 +142,13 @@ version["1.2.14"] = Object.assign({}, version["1.2.13"], } ); +version["1.2.15"] = Object.assign({}, version["1.2.14"], + { "copyPadWithoutHistory" : ["sourceID", "destinationID", "force"] + } +); + // set the latest available API version here -exports.latestApiVersion = '1.2.14'; +exports.latestApiVersion = '1.2.15'; // exports the versions so it can be used by the new Swagger endpoint exports.version = version; diff --git a/tests/backend/specs/api/pad.js b/tests/backend/specs/api/pad.js index 95138c0b..6de05c8b 100644 --- a/tests/backend/specs/api/pad.js +++ b/tests/backend/specs/api/pad.js @@ -427,6 +427,7 @@ describe('deletePad', function(){ var originalPadId = testPadId; var newPadId = makeid(); +var copiedPadId = makeid(); describe('createPad', function(){ it('creates a new Pad with text', function(done) { @@ -681,12 +682,126 @@ describe('createPad', function(){ }); }) +describe('copyPad', function(){ + it('copies the content of a existent pad', function(done) { + api.get(endPoint('copyPad')+"&sourceID="+testPadId+"&destinationID="+copiedPadId+"&force=true") + .expect(function(res){ + if(res.body.code !== 0) throw new Error("Copy Pad Failed") + }) + .expect('Content-Type', /json/) + .expect(200, done) + }); +}) + +describe('copyPadWithoutHistory', function(){ + var sourcePadId = makeid(); + var newPad; + + before(function(done) { + createNewPadWithHtml(sourcePadId, ulHtml, done); + }); + + beforeEach(function() { + newPad = makeid(); + }) + + it('returns a successful response', function(done) { + api.get(endPoint('copyPadWithoutHistory')+"&sourceID="+sourcePadId+"&destinationID="+newPad+"&force=false") + .expect(function(res){ + if(res.body.code !== 0) throw new Error("Copy Pad Without History Failed") + }) + .expect('Content-Type', /json/) + .expect(200, done) + }); + + // this test validates if the source pad's text and attributes are kept + it('creates a new pad with the same content as the source pad', function(done) { + api.get(endPoint('copyPadWithoutHistory')+"&sourceID="+sourcePadId+"&destinationID="+newPad+"&force=false") + .expect(function(res){ + if(res.body.code !== 0) throw new Error("Copy Pad Without History Failed") + }) + .end(function() { + api.get(endPoint('getHTML')+"&padID="+newPad) + .expect(function(res){ + var receivedHtml = res.body.data.html.replace("

", "").toLowerCase(); + + if (receivedHtml !== expectedHtml) { + throw new Error(`HTML received from export is not the one we were expecting. + Received: + ${receivedHtml} + + Expected: + ${expectedHtml} + + Which is a slightly modified version of the originally imported one: + ${ulHtml}`); + } + }) + .expect(200, done); + }); + }); + + context('when try copy a pad with a group that does not exist', function() { + var padId = makeid(); + var padWithNonExistentGroup = `notExistentGroup$${padId}` + it('throws an error', function(done) { + api.get(endPoint('copyPadWithoutHistory')+"&sourceID="+sourcePadId+"&destinationID="+padWithNonExistentGroup+"&force=true") + .expect(function(res){ + // code 1, it means an error has happened + if(res.body.code !== 1) throw new Error("It should report an error") + }) + .expect(200, done); + }) + }); + + context('when try copy a pad and destination pad already exist', function() { + var padIdExistent = makeid(); + + before(function(done) { + createNewPadWithHtml(padIdExistent, ulHtml, done); + }); + + context('and force is false', function() { + it('throws an error', function(done) { + api.get(endPoint('copyPadWithoutHistory')+"&sourceID="+sourcePadId+"&destinationID="+padIdExistent+"&force=false") + .expect(function(res){ + // code 1, it means an error has happened + if(res.body.code !== 1) throw new Error("It should report an error") + }) + .expect(200, done); + }); + }); + + context('and force is true', function() { + it('returns a successful response', function(done) { + api.get(endPoint('copyPadWithoutHistory')+"&sourceID="+sourcePadId+"&destinationID="+padIdExistent+"&force=true") + .expect(function(res){ + // code 1, it means an error has happened + if(res.body.code !== 0) throw new Error("Copy pad without history with force true failed") + }) + .expect(200, done); + }); + }); + }) +}) /* -> movePadForce Test */ +var createNewPadWithHtml = function(padId, html, cb) { + api.get(endPoint('createPad')+"&padID="+padId) + .end(function() { + api.post(endPoint('setHTML')) + .send({ + "padID": padId, + "html": html, + }) + .end(cb); + }) +} + var endPoint = function(point, version){ version = version || apiVersion; return '/api/'+version+'/'+point+'?apikey='+apiKey;