Add server_connector endpoint to import styles and import translatable resources (#1120)

This commit is contained in:
Adomas Ven 2016-11-29 21:59:58 +02:00 committed by Dan Stillman
parent 117ce8408b
commit 69ab4b0b1d
6 changed files with 196 additions and 22 deletions

View file

@ -139,16 +139,32 @@ Zotero.Connector = new function() {
/** /**
* Sends the XHR to execute an RPC call. * 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 {Object} data RPC data. See documentation above.
* @param {Function} callback Function to be called when requests complete. * @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 // Don't bother trying if not online in bookmarklet
if(Zotero.isBookmarklet && this.isOnline === false) { if(Zotero.isBookmarklet && this.isOnline === false) {
callback(false, 0); callback(false, 0);
return; 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) { var newCallback = function(req) {
try { try {
@ -203,13 +219,11 @@ Zotero.Connector = new function() {
callback(false, 0); callback(false, 0);
} }
} else { // Other browsers can use plain doPost } else { // Other browsers can use plain doPost
var uri = CONNECTOR_URI+"connector/"+method; var uri = CONNECTOR_URI+"connector/" + method + '?' + queryString;
Zotero.HTTP.doPost(uri, JSON.stringify(data), if (httpHeaders["Content-Type"] == 'application/json') {
newCallback, { data = JSON.stringify(data);
"Content-Type":"application/json", }
"X-Zotero-Version":Zotero.version, sendRequest(uri, data, newCallback, httpHeaders);
"X-Zotero-Connector-API-Version":CONNECTOR_API_VERSION
});
} }
}, },

View file

@ -192,6 +192,10 @@ Zotero.HTTP = new function() {
if (!headers["Content-Type"]) { if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/x-www-form-urlencoded"; 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)) { if (options.compressBody && this.isWriteMethod(method)) {
headers['Content-Encoding'] = 'gzip'; headers['Content-Encoding'] = 'gzip';

View file

@ -384,21 +384,34 @@ Zotero.Server.DataListener.prototype._processEndpoint = function(method, postDat
if(postData && this.contentType) { if(postData && this.contentType) {
// check that endpoint supports contentType // check that endpoint supports contentType
var supportedDataTypes = endpoint.supportedDataTypes; 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")); this._requestFinished(this._generateResponse(400, "text/plain", "Endpoint does not support content-type\n"));
return; return;
} }
// decode JSON or urlencoded post data, and pass through anything else // decode content-type post data
if(supportedDataTypes && this.contentType === "application/json") { if(this.contentType === "application/json") {
try { try {
decodedData = JSON.parse(postData); decodedData = JSON.parse(postData);
} catch(e) { } catch(e) {
this._requestFinished(this._generateResponse(400, "text/plain", "Invalid JSON provided\n")); this._requestFinished(this._generateResponse(400, "text/plain", "Invalid JSON provided\n"));
return; 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); 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 { } else {
decodedData = postData; 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 * Endpoints for the HTTP server

View file

@ -586,6 +586,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 * Get code for a translator
* *

View file

@ -242,21 +242,22 @@ Zotero.Styles = new function() {
* containing the style data * containing the style data
* @param {String} origin The origin of the style, either a filename or URL, to be * @param {String} origin The origin of the style, either a filename or URL, to be
* displayed in dialogs referencing the style * displayed in dialogs referencing the style
* @param {Boolean} [noPrompt=false] Skip the confirmation prompt
*/ */
this.install = Zotero.Promise.coroutine(function* (style, origin) { this.install = Zotero.Promise.coroutine(function* (style, origin, noPrompt=false) {
var styleInstalled; var styleTitle;
try { try {
if (style instanceof Components.interfaces.nsIFile) { if (style instanceof Components.interfaces.nsIFile) {
// handle nsIFiles // handle nsIFiles
var url = Services.io.newFileURI(style); var url = Services.io.newFileURI(style);
var xmlhttp = yield Zotero.HTTP.request("GET", url.spec); 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 { } 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 // 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.UserCancelled) return;
if(typeof error === "object" && error instanceof Zotero.Exception.Alert) { if(typeof error === "object" && error instanceof Zotero.Exception.Alert) {
@ -268,6 +269,7 @@ Zotero.Styles = new function() {
origin, "styles.install.title", error)).present(); 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 * @param {String} origin The origin of the style, either a filename or URL, to be
* displayed in dialogs referencing the style * displayed in dialogs referencing the style
* @param {Boolean} [hidden] Whether style is to be hidden. * @param {Boolean} [hidden] Whether style is to be hidden.
* @param {Boolean} [noPrompt=false] Skip the confirmation prompt
* @return {Promise} * @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(); 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 // First, parse style and make sure it's valid XML
var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] var parser = Components.classes["@mozilla.org/xmlextras/domparser;1"]
.createInstance(Components.interfaces.nsIDOMParser), .createInstance(Components.interfaces.nsIDOMParser),
doc = parser.parseFromString(style, "application/xml"); 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), Zotero.Styles.ns),
// Get file name from URL // Get file name from URL
m = /[^\/]+$/.exec(styleID), 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 // display a dialog to tell the user we're about to install the style
if(hidden) { if(hidden) {
destFile = destFileHidden; destFile = destFileHidden;
} else { } else if (!noPrompt) {
if(existingTitle) { if(existingTitle) {
var text = Zotero.getString('styles.updateStyle', [existingTitle, title, origin]); var text = Zotero.getString('styles.updateStyle', [existingTitle, title, origin]);
} else { } else {
@ -448,6 +451,7 @@ Zotero.Styles = new function() {
yield win.Zotero_Preferences.Cite.refreshStylesList(styleID); yield win.Zotero_Preferences.Cite.refreshStylesList(styleID);
} }
} }
return existingTitle || title;
}); });
/** /**

View file

@ -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();
});
});
}); });