Validate CSL styles on installation, and restructure Zotero.Styles.install() to use Q.

Closes https://www.zotero.org/trac/ticket/1681, automatic CSL validation
This commit is contained in:
Simon Kornblith 2012-07-10 02:46:57 -04:00
parent bf4c5c1158
commit 06825c4767
3 changed files with 210 additions and 166 deletions

View file

@ -30,7 +30,10 @@
*/
Zotero.Styles = new function() {
var _initialized = false;
var _styles, _visibleStyles;
var _styles, _visibleStyles, _cacheTranslatorData;
Components.utils.import("resource://zotero/q.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");
this.xsltProcessor = null;
this.ios = Components.classes["@mozilla.org/network/io-service;1"].
@ -39,6 +42,7 @@ Zotero.Styles = new function() {
this.ns = {
"csl":"http://purl.org/net/xbiblio/csl"
};
/**
* Initializes styles cache, loading metadata for styles into memory
@ -50,7 +54,7 @@ Zotero.Styles = new function() {
_styles = {};
_visibleStyles = [];
this.cacheTranslatorData = Zotero.Prefs.get("cacheTranslatorData");
_cacheTranslatorData = Zotero.Prefs.get("cacheTranslatorData");
this.lastCSL = null;
// main dir
@ -134,7 +138,7 @@ Zotero.Styles = new function() {
* @return {Zotero.Style[]} An array of Zotero.Style objects
*/
this.getVisible = function() {
if(!_initialized || !this.cacheTranslatorData) this.init();
if(!_initialized || !_cacheTranslatorData) this.init();
return _visibleStyles.slice(0);
}
@ -143,198 +147,234 @@ Zotero.Styles = new function() {
* @return {Object} An object whose keys are style IDs, and whose values are Zotero.Style objects
*/
this.getAll = function() {
if(!_initialized || !this.cacheTranslatorData) this.init();
if(!_initialized || !_cacheTranslatorData) this.init();
return _styles;
}
/**
* Installs a style file
* @param {String|nsIFile} style An nsIFile representing a style on disk, or a string containing
* the style data
* @param {String} loadURI The URI this style file was loaded from
* @param {Boolean} hidden Whether style is to be hidden. If this parameter is true, UI alerts
* are silenced as well
* Validates a style
* @param {String} style The style, as a string
* @return {Promise} A promise representing the style file. This promise is rejected
* with the validation error if validation fails, or resolved if it is not.
*/
this.install = function(style, loadURI, hidden) {
const pathRe = /[^\/]+$/;
if(!_initialized || !this.cacheTranslatorData) this.init();
// handle nsIFiles
var styleFile = null;
this.validate = function(style) {
var deferred = Q.defer(),
worker = new Worker("resource://zotero/csl-validator.js");
worker.onmessage = function(event) {
if(event.data) {
deferred.reject(event.data);
} else {
deferred.resolve();
}
};
worker.postMessage(style);
return deferred.promise;
}
/**
* Installs a style file, getting the contents of an nsIFile and showing appropriate
* error messages
* @param {String|nsIFile} style An nsIFile representing a style on disk, or a string
* 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
*/
this.install = function(style, origin) {
var styleFile = null, styleInstalled;
if(style instanceof Components.interfaces.nsIFile) {
styleFile = style;
loadURI = style.leafName;
style = Zotero.File.getContents(styleFile);
// handle nsIFiles
origin = style.leafName;
styleInstalled = Zotero.File.getContentsAsync(styleFile).when(function(style) {
return _install(style, origin);
});
} else {
styleInstalled = _install(style, origin);
}
var error = false;
try {
// CSL
styleInstalled.fail(function(error) {
// Unless user cancelled, show an alert with the error
if(error instanceof Zotero.Exception.UserCancelled) return;
if(error instanceof Zotero.Exception.Alert) {
error.present();
error.log();
} else {
Zotero.logError(error);
(new Zotero.Exception.Alert("styles.install.unexpectedError",
origin, "styles.install.title", error)).present();
}
});
}
/**
* Installs a style
* @param {String} style The style as a string
* @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.
* @return {Promise}
*/
function _install(style, origin, hidden) {
if(!_initialized || !_cacheTranslatorData) Zotero.Styles.init();
var existingFile, destFile, source;
return Q.fcall(function() {
// 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");
if(doc.documentElement.localName === "parsererror") {
throw new Error("File is not valid XML");
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),
fileName = Zotero.File.getValidFileName(m ? m[0] : styleID),
title = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:title[1]',
Zotero.Styles.ns);
if(!styleID || !title) {
// If it's not valid XML, we'll return a promise that immediately resolves
// to an error
throw new Zotero.Exception.Alert("styles.installError", origin,
"styles.install.title", "Style is not valid XML, or the styleID or title is missing");
}
} catch(e) {
error = e;
}
if(!doc || error) {
if(!hidden) alert(Zotero.getString('styles.installError', loadURI));
if(error) throw error;
return false;
}
var styleID = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:id[1]',
Zotero.Styles.ns);
// get file name from URL
var m = pathRe.exec(styleID);
var fileName = Zotero.File.getValidFileName(m ? m[0] : styleID);
var title = Zotero.Utilities.xpathText(doc, '/csl:style/csl:info[1]/csl:title[1]',
Zotero.Styles.ns);
// look for a parent
var source = Zotero.Utilities.xpathText(doc,
'/csl:style/csl:info[1]/csl:link[@rel="source" or @rel="independent-parent"][1]/@href',
Zotero.Styles.ns);
if(source == styleID) {
if(!hidden) alert(Zotero.getString('styles.installError', loadURI));
throw "Style with ID "+styleID+" references itself as source";
}
// ensure csl extension
if(fileName.substr(-4).toLowerCase() != ".csl") fileName += ".csl";
var destFile = Zotero.getStylesDirectory();
var destFileHidden = destFile.clone();
destFile.append(fileName);
destFileHidden.append("hidden");
if(hidden) Zotero.File.createDirectoryIfMissing(destFileHidden);
destFileHidden.append(fileName);
// look for an existing style with the same styleID or filename
var existingFile = null;
var existingTitle = null;
if(_styles[styleID]) {
existingFile = _styles[styleID].file;
existingTitle = _styles[styleID].title;
} else {
if(destFile.exists()) {
existingFile = destFile;
} else if(destFileHidden.exists()) {
existingFile = destFileHidden;
// look for a parent
source = Zotero.Utilities.xpathText(doc,
'/csl:style/csl:info[1]/csl:link[@rel="source" or @rel="independent-parent"][1]/@href',
Zotero.Styles.ns);
if(source == styleID) {
throw Zotero.Exception.Alert("styles.installError", origin,
"styles.install.title", "Style references itself as source");
}
if(existingFile) {
// find associated style
for each(var existingStyle in this._styles) {
if(destFile.equals(existingStyle.file)) {
// ensure csl extension
if(fileName.substr(-4).toLowerCase() != ".csl") fileName += ".csl";
destFile = Zotero.getStylesDirectory();
var destFileHidden = destFile.clone();
destFile.append(fileName);
destFileHidden.append("hidden");
if(hidden) Zotero.File.createDirectoryIfMissing(destFileHidden);
destFileHidden.append(fileName);
// look for an existing style with the same styleID or filename
var existingTitle;
if(_styles[styleID]) {
existingFile = _styles[styleID].file;
existingTitle = _styles[styleID].title;
} else {
if(destFile.exists()) {
existingFile = destFile;
} else if(destFileHidden.exists()) {
existingFile = destFileHidden;
}
if(existingFile) {
// find associated style
for each(var existingStyle in _styles) {
if(destFile.equals(existingStyle.file)) {
existingTitle = existingStyle.title;
break;
}
}
}
}
// also look for an existing style with the same title
if(!existingFile) {
for each(var existingStyle in Zotero.Styles.getAll()) {
if(title === existingStyle.title) {
existingFile = existingStyle.file;
existingTitle = existingStyle.title;
break;
}
}
}
}
// also look for an existing style with the same title
if(!existingFile) {
for each(var existingStyle in this.getAll()) {
if(title === existingStyle.title) {
existingFile = existingStyle.file;
existingTitle = existingStyle.title;
break;
// display a dialog to tell the user we're about to install the style
if(hidden) {
destFile = destFileHidden;
} else {
if(existingTitle) {
var text = Zotero.getString('styles.updateStyle', [existingTitle, title, origin]);
} else {
var text = Zotero.getString('styles.installStyle', [title, origin]);
}
var index = Services.prompt.confirmEx(null, Zotero.getString('styles.install.title'),
text,
((Services.prompt.BUTTON_POS_0) * (Services.prompt.BUTTON_TITLE_IS_STRING)
+ (Services.prompt.BUTTON_POS_1) * (Services.prompt.BUTTON_TITLE_CANCEL)),
Zotero.getString('general.install'), null, null, null, {}
);
if(index !== 0) {
throw new Zotero.Exception.UserCancelled("style installation");
}
}
}
// display a dialog to tell the user we're about to install the style
if(hidden) {
destFile = destFileHidden;
} else {
var ps = Components.classes["@mozilla.org/embedcomp/prompt-service;1"]
.getService(Components.interfaces.nsIPromptService);
if(existingTitle) {
var text = Zotero.getString('styles.updateStyle', [existingTitle, title, loadURI]);
} else {
var text = Zotero.getString('styles.installStyle', [title, loadURI]);
}
var index = ps.confirmEx(null, '',
text,
((ps.BUTTON_POS_0) * (ps.BUTTON_TITLE_IS_STRING)
+ (ps.BUTTON_POS_1) * (ps.BUTTON_TITLE_CANCEL)),
Zotero.getString('general.install'), null, null, null, {}
);
}
if(hidden || index == 0) {
// user wants to install/update
return Zotero.Styles.validate(style).fail(function(validationErrors) {
Zotero.logError("Style from "+origin+" failed to validate:\n\n"+validationErrors);
// If validation fails on the parent of a dependent style, ignore it (for now)
if(hidden) return;
// If validation fails on a different style, we ask the user if s/he really
// wants to install it
Components.utils.import("resource://gre/modules/Services.jsm");
var shouldInstall = Services.prompt.confirmEx(null,
Zotero.getString('styles.install.title'),
Zotero.getString('styles.validationWarning', origin),
(Services.prompt.BUTTON_POS_0) * (Services.prompt.BUTTON_TITLE_OK)
+ (Services.prompt.BUTTON_POS_1) * (Services.prompt.BUTTON_TITLE_CANCEL)
+ Services.prompt.BUTTON_POS_1_DEFAULT + Services.prompt.BUTTON_DELAY_ENABLE,
null, null, null, null, {}
);
if(shouldInstall !== 0) {
throw new Zotero.Exception.UserCancelled("style installation");
}
});
}).then(function() {
// User wants to install/update
if(source && !_styles[source]) {
// need to fetch source
if(source.substr(0, 7) == "http://" || source.substr(0, 8) == "https://") {
Zotero.HTTP.doGet(source, function(xmlhttp) {
var success = false;
var error = null;
try {
var success = Zotero.Styles.install(xmlhttp.responseText, loadURI, true);
} catch(e) {
error = e;
}
if(success) {
_completeInstall(style, styleID, destFile, existingFile, styleFile);
// Need to fetch source
if(source.substr(0, 7) === "http://" || source.substr(0, 8) === "https://") {
return Zotero.HTTP.promise("GET", source).then(function(xmlhttp) {
return _install(xmlhttp.responseText, origin, true);
}).fail(function(error) {
if(error instanceof Zotero.Exception) {
throw new Zotero.Exception.Alert("styles.installSourceError", [origin, source],
"styles.install.title", error);
} else {
if(!hidden) alert(Zotero.getString('styles.installSourceError', [loadURI, source]));
throw error;
}
});
} else {
if(!hidden) alert(Zotero.getString('styles.installSourceError', [loadURI, source]));
throw "Source CSL URI is invalid";
throw new Zotero.Exception.Alert("styles.installSourceError", [origin, source],
"styles.install.title", "Source CSL URI is invalid");
}
} else {
_completeInstall(style, styleID, destFile, existingFile, styleFile);
}
return styleID;
}
return false;
}
/**
* Finishes installing a style, copying the file, reloading the style cache, and refreshing the
* styles list in any open windows
* @param {String} style The style string
* @param {String} styleID The style ID
* @param {nsIFile} destFile The destination for the style
* @param {nsIFile} [existingFile] The existing file to delete before copying this one
* @param {nsIFile} [styleFile] The file that contains the style to be installed
* @private
*/
function _completeInstall(style, styleID, destFile, existingFile, styleFile) {
// remove any existing file with a different name
if(existingFile) existingFile.remove(false);
if(styleFile) {
styleFile.copyToFollowingLinks(destFile.parent, destFile.leafName);
} else {
Zotero.File.putContents(destFile, style);
}
// recache
Zotero.Styles.init();
// refresh preferences windows
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"].
getService(Components.interfaces.nsIWindowMediator);
var enumerator = wm.getEnumerator("zotero:pref");
while(enumerator.hasMoreElements()) {
var win = enumerator.getNext();
win.refreshStylesList(styleID);
}
}).then(function() {
// Dependent style has been retrieved if there was one, so we're ready to
// continue
// Remove any existing file with a different name
if(existingFile) existingFile.remove(false);
return Zotero.File.putContentsAsync(destFile, style);
}).then(function() {
// Cache
Zotero.Styles.init();
// Refresh preferences windows
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"].
getService(Components.interfaces.nsIWindowMediator);
var enumerator = wm.getEnumerator("zotero:pref");
while(enumerator.hasMoreElements()) {
var win = enumerator.getNext();
win.refreshStylesList(styleID);
}
});
}
}

View file

@ -638,10 +638,13 @@ integration.citationChanged = You have modified this citation since Zotero ge
integration.citationChanged.description = Clicking "Yes" will prevent Zotero from updating this citation if you add additional citations, switch styles, or modify the item to which it refers. Clicking "No" will erase your changes.
integration.citationChanged.edit = You have modified this citation since Zotero generated it. Editing will clear your modifications. Do you want to continue?
styles.install.title = Install Style
styles.install.unexpectedError = An unexpected error occurred while installing "%1$S"
styles.installStyle = Install style "%1$S" from %2$S?
styles.updateStyle = Update existing style "%1$S" with "%2$S" from %3$S?
styles.installed = The style "%S" was installed successfully.
styles.installError = %S does not appear to be a valid style file.
styles.installError = "%S" is not a valid style file.
styles.validationWarning = "%S" is not valid CSL 1.0, and may not work properly with Zotero.\n\nAre you sure you want to continue?
styles.installSourceError = %1$S references an invalid or non-existent CSL file at %2$S as its source.
styles.deleteStyle = Are you sure you want to delete the style "%1$S"?
styles.deleteStyles = Are you sure you want to delete the selected styles?

File diff suppressed because one or more lines are too long