Add server_connector endpoint to import styles and import translatable resources (#1120)
This commit is contained in:
parent
117ce8408b
commit
69ab4b0b1d
6 changed files with 196 additions and 22 deletions
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue