commit 69ab4b0b1d5038f4361656e157612b90032d9627
parent 117ce8408bba674dec48aece1c9404779f79c88a
Author: Adomas Ven <adomas.ven@gmail.com>
Date: Tue, 29 Nov 2016 21:59:58 +0200
Add server_connector endpoint to import styles and import translatable resources (#1120)
Diffstat:
6 files changed, 196 insertions(+), 22 deletions(-)
diff --git a/chrome/content/zotero/xpcom/connector/connector.js b/chrome/content/zotero/xpcom/connector/connector.js
@@ -139,16 +139,32 @@ Zotero.Connector = new function() {
/**
* Sends the XHR to execute an RPC call.
*
- * @param {String} method RPC method. See documentation above.
+ * @param {Object} options
+ * method - method name
+ * queryString - a querystring to pass on the HTTP call
+ * httpMethod - GET|POST
+ * httpHeaders - an object of HTTP headers to send
* @param {Object} data RPC data. See documentation above.
* @param {Function} callback Function to be called when requests complete.
*/
- this.callMethod = function(method, data, callback, tab) {
+ this.callMethod = function(options, data, callback, tab) {
// Don't bother trying if not online in bookmarklet
if(Zotero.isBookmarklet && this.isOnline === false) {
callback(false, 0);
return;
}
+ if (typeof options == 'string') {
+ Zotero.debug('Zotero.Connector.callMethod() now takes an object instead of a string for method. Update your code.');
+ options = {method: options};
+ }
+ var method = options.method;
+ var sendRequest = options.httpMethod == 'GET' ? Zotero.HTTP.doGet : Zotero.HTTP.doPost;
+ var httpHeaders = Object.assign({
+ "Content-Type":"application/json",
+ "X-Zotero-Version":Zotero.version,
+ "X-Zotero-Connector-API-Version":CONNECTOR_API_VERSION
+ }, options.httpHeaders);
+ var queryString = options.queryString;
var newCallback = function(req) {
try {
@@ -203,13 +219,11 @@ Zotero.Connector = new function() {
callback(false, 0);
}
} else { // Other browsers can use plain doPost
- var uri = CONNECTOR_URI+"connector/"+method;
- Zotero.HTTP.doPost(uri, JSON.stringify(data),
- newCallback, {
- "Content-Type":"application/json",
- "X-Zotero-Version":Zotero.version,
- "X-Zotero-Connector-API-Version":CONNECTOR_API_VERSION
- });
+ var uri = CONNECTOR_URI+"connector/" + method + '?' + queryString;
+ if (httpHeaders["Content-Type"] == 'application/json') {
+ data = JSON.stringify(data);
+ }
+ sendRequest(uri, data, newCallback, httpHeaders);
}
},
diff --git a/chrome/content/zotero/xpcom/http.js b/chrome/content/zotero/xpcom/http.js
@@ -192,6 +192,10 @@ Zotero.HTTP = new function() {
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
+ else if (headers["Content-Type"] == 'multipart/form-data') {
+ // Allow XHR to set Content-Type with boundary for multipart/form-data
+ delete headers["Content-Type"];
+ }
if (options.compressBody && this.isWriteMethod(method)) {
headers['Content-Encoding'] = 'gzip';
diff --git a/chrome/content/zotero/xpcom/server.js b/chrome/content/zotero/xpcom/server.js
@@ -384,21 +384,34 @@ Zotero.Server.DataListener.prototype._processEndpoint = function(method, postDat
if(postData && this.contentType) {
// check that endpoint supports contentType
var supportedDataTypes = endpoint.supportedDataTypes;
- if(supportedDataTypes && supportedDataTypes.indexOf(this.contentType) === -1) {
+ if(supportedDataTypes && supportedDataTypes != '*'
+ && supportedDataTypes.indexOf(this.contentType) === -1) {
+
this._requestFinished(this._generateResponse(400, "text/plain", "Endpoint does not support content-type\n"));
return;
}
- // decode JSON or urlencoded post data, and pass through anything else
- if(supportedDataTypes && this.contentType === "application/json") {
+ // decode content-type post data
+ if(this.contentType === "application/json") {
try {
decodedData = JSON.parse(postData);
} catch(e) {
this._requestFinished(this._generateResponse(400, "text/plain", "Invalid JSON provided\n"));
return;
}
- } else if(supportedDataTypes && this.contentType === "application/x-www-form-urlencoded") {
+ } else if(this.contentType === "application/x-www-form-urlencoded") {
decodedData = Zotero.Server.decodeQueryString(postData);
+ } else if(this.contentType === "multipart/form-data") {
+ let boundary = /boundary=([^\s]*)/i.exec(this.header);
+ if (!boundary) {
+ return this._requestFinished(this._generateResponse(400, "text/plain", "Invalid multipart/form-data provided\n"));
+ }
+ boundary = '--' + boundary[1];
+ try {
+ decodedData = this._decodeMultipartData(postData, boundary);
+ } catch(e) {
+ return this._requestFinished(this._generateResponse(400, "text/plain", "Invalid multipart/form-data provided\n"));
+ }
} else {
decodedData = postData;
}
@@ -460,6 +473,40 @@ Zotero.Server.DataListener.prototype._requestFinished = function(response) {
}
}
+Zotero.Server.DataListener.prototype._decodeMultipartData = function(data, boundary) {
+ var contentDispositionRe = /^Content-Disposition:\s*(.*)$/i;
+ var results = [];
+ data = data.split(boundary);
+ // Ignore pre first boundary and post last boundary
+ data = data.slice(1, data.length-1);
+ for (let field of data) {
+ let fieldData = {};
+ field = field.trim();
+ // Split header and body
+ let unixHeaderBoundary = field.indexOf("\n\n");
+ let windowsHeaderBoundary = field.indexOf("\r\n\r\n");
+ if (unixHeaderBoundary < windowsHeaderBoundary && unixHeaderBoundary != -1) {
+ fieldData.header = field.slice(0, unixHeaderBoundary);
+ fieldData.body = field.slice(unixHeaderBoundary+2);
+ } else if (windowsHeaderBoundary != -1) {
+ fieldData.header = field.slice(0, windowsHeaderBoundary);
+ fieldData.body = field.slice(windowsHeaderBoundary+4);
+ } else {
+ throw new Error('Malformed multipart/form-data body');
+ }
+
+ let contentDisposition = contentDispositionRe.exec(fieldData.header);
+ if (contentDisposition) {
+ for (let nameVal of contentDisposition[1].split(';')) {
+ nameVal.split('=');
+ fieldData[nameVal[0]] = nameVal.length > 1 ? nameVal[1] : null;
+ }
+ }
+ results.push(fieldData);
+ }
+ return results;
+};
+
/**
* Endpoints for the HTTP server
diff --git a/chrome/content/zotero/xpcom/server_connector.js b/chrome/content/zotero/xpcom/server_connector.js
@@ -587,6 +587,53 @@ Zotero.Server.Connector.Progress.prototype = {
};
/**
+ * Translates resources using import translators
+ *
+ * Returns:
+ * - Object[Item] an array of imported items
+ */
+
+Zotero.Server.Connector.Import = function() {};
+Zotero.Server.Endpoints["/connector/import"] = Zotero.Server.Connector.Import;
+Zotero.Server.Connector.Import.prototype = {
+ supportedMethods: ["POST"],
+ supportedDataTypes: '*',
+ permitBookmarklet: false,
+
+ init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback){
+ let translate = new Zotero.Translate.Import();
+ translate.setString(data);
+ let translators = yield translate.getTranslators();
+ if (!translators || !translators.length) {
+ return sendResponseCallback(404);
+ }
+ translate.setTranslator(translators[0]);
+ let items = yield translate.translate();
+ return sendResponseCallback(201, "application/json", JSON.stringify(items));
+ })
+}
+
+/**
+ * Install CSL styles
+ *
+ * Returns:
+ * - {name: styleName}
+ */
+
+Zotero.Server.Connector.InstallStyle = function() {};
+Zotero.Server.Endpoints["/connector/installStyle"] = Zotero.Server.Connector.InstallStyle;
+Zotero.Server.Connector.InstallStyle.prototype = {
+ supportedMethods: ["POST"],
+ supportedDataTypes: '*',
+ permitBookmarklet: false,
+
+ init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback){
+ let styleName = yield Zotero.Styles.install(data, url.query.origin || null, true);
+ sendResponseCallback(201, "application/json", JSON.stringify({name: styleName}));
+ })
+};
+
+/**
* Get code for a translator
*
* Accepts:
diff --git a/chrome/content/zotero/xpcom/style.js b/chrome/content/zotero/xpcom/style.js
@@ -242,21 +242,22 @@ Zotero.Styles = new function() {
* containing the style data
* @param {String} origin The origin of the style, either a filename or URL, to be
* displayed in dialogs referencing the style
+ * @param {Boolean} [noPrompt=false] Skip the confirmation prompt
*/
- this.install = Zotero.Promise.coroutine(function* (style, origin) {
- var styleInstalled;
+ this.install = Zotero.Promise.coroutine(function* (style, origin, noPrompt=false) {
+ var styleTitle;
try {
if (style instanceof Components.interfaces.nsIFile) {
// handle nsIFiles
var url = Services.io.newFileURI(style);
var xmlhttp = yield Zotero.HTTP.request("GET", url.spec);
- styleInstalled = yield _install(xmlhttp.responseText, style.leafName);
+ styleTitle = yield _install(xmlhttp.responseText, style.leafName, false, noPrompt);
} else {
- styleInstalled = yield _install(style, origin);
+ styleTitle = yield _install(style, origin, false, noPrompt);
}
}
- catch (e) {
+ catch (error) {
// Unless user cancelled, show an alert with the error
if(typeof error === "object" && error instanceof Zotero.Exception.UserCancelled) return;
if(typeof error === "object" && error instanceof Zotero.Exception.Alert) {
@@ -268,6 +269,7 @@ Zotero.Styles = new function() {
origin, "styles.install.title", error)).present();
}
}
+ return styleTitle;
});
/**
@@ -276,19 +278,20 @@ Zotero.Styles = new function() {
* @param {String} origin The origin of the style, either a filename or URL, to be
* displayed in dialogs referencing the style
* @param {Boolean} [hidden] Whether style is to be hidden.
+ * @param {Boolean} [noPrompt=false] Skip the confirmation prompt
* @return {Promise}
*/
- var _install = Zotero.Promise.coroutine(function* (style, origin, hidden) {
+ var _install = Zotero.Promise.coroutine(function* (style, origin, hidden, noPrompt=false) {
if (!_initialized) yield Zotero.Styles.init();
- var existingFile, destFile, source, styleID
+ var existingFile, destFile, source;
// First, parse style and make sure it's valid XML
var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
.createInstance(Components.interfaces.nsIDOMParser),
doc = parser.parseFromString(style, "application/xml");
- styleID = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:id[1]',
+ var styleID = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:id[1]',
Zotero.Styles.ns),
// Get file name from URL
m = /[^\/]+$/.exec(styleID),
@@ -361,7 +364,7 @@ Zotero.Styles = new function() {
// display a dialog to tell the user we're about to install the style
if(hidden) {
destFile = destFileHidden;
- } else {
+ } else if (!noPrompt) {
if(existingTitle) {
var text = Zotero.getString('styles.updateStyle', [existingTitle, title, origin]);
} else {
@@ -448,6 +451,7 @@ Zotero.Styles = new function() {
yield win.Zotero_Preferences.Cite.refreshStylesList(styleID);
}
}
+ return existingTitle || title;
});
/**
diff --git a/test/tests/server_connectorTest.js b/test/tests/server_connectorTest.js
@@ -313,4 +313,62 @@ describe("Connector Server", function () {
);
});
});
+
+ describe('/connector/importStyle', function() {
+ var endpoint;
+
+ before(function() {
+ endpoint = connectorServerPath + "/connector/importStyle";
+ });
+
+ it('should reject application/json requests', function* () {
+ try {
+ var response = yield Zotero.HTTP.request(
+ 'POST',
+ endpoint,
+ {
+ headers: { "Content-Type": "application/json" },
+ body: '{}'
+ }
+ );
+ } catch(e) {
+ assert.instanceOf(e, Zotero.HTTP.UnexpectedStatusException);
+ assert.equal(e.xmlhttp.status, 400);
+ }
+ });
+
+ it('should import a style with text/x-csl content-type', function* () {
+ sinon.stub(Zotero.Styles, 'install', function(style) {
+ var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
+ .createInstance(Components.interfaces.nsIDOMParser),
+ doc = parser.parseFromString(style, "application/xml");
+
+ return Zotero.Promise.resolve(
+ Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:title[1]',
+ Zotero.Styles.ns)
+ );
+ });
+
+ var style = `<?xml version="1.0" encoding="utf-8"?>
+<style xmlns="http://purl.org/net/xbiblio/csl" version="1.0" default-locale="de-DE">
+ <info>
+ <title>Test1</title>
+ <id>http://www.example.com/test2</id>
+ <link href="http://www.zotero.org/styles/cell" rel="independent-parent"/>
+ </info>
+</style>
+`;
+ var response = yield Zotero.HTTP.request(
+ 'POST',
+ endpoint,
+ {
+ headers: { "Content-Type": "text/x-csl" },
+ body: style
+ }
+ );
+ assert.equal(response.status, 201);
+ assert.equal(response.response, JSON.stringify({name: 'Test1'}));
+ Zotero.Styles.install.restore();
+ });
+ });
});