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.
|
* 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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
*
|
*
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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