Closes #259, auto-complete of tags

Addresses #260, Add auto-complete to search window

- New XPCOM autocomplete component for Zotero data -- can be used by setting the autocompletesearch attribute of a textbox to 'zotero' and passing a search scope with the autocompletesearchparam attribute. Additional parameters can be passed by appending them to the autocompletesearchparam value with a '/', e.g. 'tag/2732' (to exclude tags that show up in item 2732)

- Tag entry now uses more or less the same interface as metadata -- no more popup window -- note that tab isn't working properly yet, and there's no way to quickly enter multiple tags (though it's now considerably quicker than it was before)

- Autocomplete for tags, excluding any tags already set for the current item

- Standalone note windows now register with the Notifier (since tags needed item modification notifications to work properly), which will help with #282, "Notes opened in separate windows need item notification"

- Tags are now retrieved in alphabetical order

- Scholar.Item.replaceTag(oldTagID, newTag), with a single notify

- Scholar.getAncestorByTagName(elem, tagName) -- walk up the DOM tree from an element until an element with the specified tag name is found (also checks with 'xul:' prefix, for use in XBL), or false if not found -- probably shouldn't be used too widely, since it's doing string comparisons, but better than specifying, say, nine '.parentNode' properties, and makes for more resilient code


A few notes:

- Autocomplete in Minefield seems to self-destruct after using it in the same field a few times, taking down saving of the field with it -- this may or may not be my fault, but it makes Zotero more or less unusable in 3.0 at the moment. Sorry. (I use 3.0 myself for development, so I'll work on it.)

- This would have been much, much easier if having an autocomplete textbox (which uses an XBL-generated popup for the suggestions) within a popup (as it is in the independent note edit panes) didn't introduce all sorts of crazy bugs that had to be defeated with annoying hackery -- one side effect of this is that at the moment you can't close the tags popup with the Escape key

- Independent note windows now need to pull in itemPane.js to function properly, which is a bit messy and not ideal, but less messy and more ideal than duplicating all the dual-state editor and tabindex logic would be

- Hitting tab in a tag field not only doesn't work but also breaks things until the next window refresh.

- There are undoubtedly other bugs.
This commit is contained in:
Dan Stillman 2006-09-07 08:07:48 +00:00
parent e9ba093c15
commit 14b24f3638
9 changed files with 495 additions and 66 deletions

View file

@ -112,17 +112,15 @@
<method name="tagsClick">
<body>
<![CDATA[
var tagsList = this.item.getTags();
if(tagsList && tagsList.length > 0)
this.id('tagsPopup').showPopup(this.id('tagsLabel'),-1,-1,-1,'popup',0,0);
else
this.id('tags').add();
this.id('tags').reload();
this.id('tagsPopup').showPopup(this.id('tagsLabel'),-1,-1,'popup');
]]>
</body>
</method>
<method name="updateTagsSummary">
<body>
<![CDATA[
// TODO: localize
var v = this.id('tags').summary;
if(!v || v == "")
@ -172,7 +170,18 @@
<xul:popup id="seeAlsoPopup" width="300" onpopupshowing="this.firstChild.reload();">
<xul:seealsobox id="seeAlso" flex="1"/>
</xul:popup>
<xul:popup id="tagsPopup" width="300" onpopupshowing="this.firstChild.reload();">
<!-- The onpopup* stuff is an ugly hack to keep track of when the
popup is open (and not the descendent autocomplete popup, which also
seems to get triggered by these events for reasons that are less than
clear) so that we can manually refresh the popup if it's open after
autocomplete is used to prevent it from becoming unresponsive
Note: Code in tagsbox.xml is dependent on the DOM path between the
tagsbox and tagsLabel above, so be sure to update fixPopup() if it changes
-->
<xul:popup id="tagsPopup" ignorekeys="true" width="300"
onpopupshown="if (!document.commandDispatcher.focusedElement || document.commandDispatcher.focusedElement.tagName=='xul:label'){ /* DEBUG: it would be nice to make this work -- if (this.firstChild.count==0){ this.firstChild.new(); } */ this.setAttribute('showing', 'true'); }"
onpopuphidden="if (!document.commandDispatcher.focusedElement || document.commandDispatcher.focusedElement.tagName=='xul:label'){ this.setAttribute('showing', 'false'); }">
<xul:tagsbox id="tags" flex="1"/>
</xul:popup>
</xul:popupset>

View file

@ -18,6 +18,7 @@
]]>
</setter>
</property>
<property name="count"/>
<property name="summary">
<getter>
<![CDATA[
@ -43,58 +44,109 @@
<method name="reload">
<body>
<![CDATA[
//Scholar.debug('Reloading tags');
var rows = this.id('tagRows');
while(rows.hasChildNodes())
rows.removeChild(rows.firstChild);
if(this.item)
var tags = this.item.getTags();
if(tags)
{
var tags = this.item.getTags();
if(tags)
for(var i = 0; i < tags.length; i++)
{
for(var i = 0; i < tags.length; i++)
{
var id = Scholar.Tags.getID(tags[i]);
var icon= document.createElement("image");
icon.setAttribute('src','chrome://scholar/skin/tag.png');
var label = document.createElement("label");
label.setAttribute('value', tags[i]);
label.setAttribute('crop','end');
var remove = document.createElement("label");
remove.setAttribute('value','-');
remove.setAttribute('onclick',"this.parentNode.parentNode.parentNode.parentNode.parentNode.remove('"+id+"');");
remove.setAttribute('class','clicky');
var row = document.createElement("row");
row.appendChild(icon);
row.appendChild(label);
row.appendChild(remove);
row.setAttribute('id','tag-'+id);
rows.appendChild(row);
}
this.updateCount(tags.length);
}
else
{
this.updateCount();
this.addDynamicRow(tags[i], i);
}
this.updateCount(tags.length);
this.fixPopup();
return tags.length;
}
this.updateCount();
return 0;
]]>
</body>
</method>
<method name="addDynamicRow">
<parameter name="tag"/>
<parameter name="tabindex"/>
<body>
<![CDATA[
var id = tag ? Scholar.Tags.getID(tag) : null;
tag = tag ? tag : '';
tabindex = tabindex ? tabindex : null;
var icon= document.createElement("image");
icon.setAttribute('src','chrome://scholar/skin/tag.png');
// DEBUG: Why won't just this.nextSibling.blur() work?
icon.setAttribute('onclick','if (this.nextSibling.inputField){ this.nextSibling.inputField.blur() }');
var label = ScholarItemPane.createValueElement(tag, 'tag', tabindex);
var remove = document.createElement("label");
remove.setAttribute('value','-');
if (id)
{
remove.setAttribute('onclick',"this.parentNode.parentNode.parentNode.parentNode.parentNode.remove('"+id+"');");
remove.setAttribute('class','clicky');
}
else
{
remove.setAttribute('disabled', true);
remove.setAttribute('class', 'unclicky');
}
var row = document.createElement("row");
row.appendChild(icon);
row.appendChild(label);
row.appendChild(remove);
if (id)
{
row.setAttribute('id','tag-'+id);
}
this.id('tagRows').appendChild(row);
return row;
]]>
</body>
</method>
<method name="new">
<body>
<![CDATA[
var row = this.addDynamicRow();
row.firstChild.nextSibling.click();
]]>
</body>
</method>
<method name="add">
<parameter name="value"/>
<body>
<![CDATA[
var t = prompt('Add Tag:');
if(t && this.item)
if (value)
{
this.item.addTag(t);
return this.item.addTag(value);
}
return false;
]]>
</body>
</method>
<method name="replace">
<parameter name="oldTagID"/>
<parameter name="newTag"/>
<body>
<![CDATA[
if(oldTagID && newTag)
{
var oldTag = Scholar.Tags.getName(oldTagID);
if (oldTag!=newTag)
{
return this.item.replaceTag(oldTagID, newTag);
}
}
return false;
]]>
</body>
</method>
@ -102,13 +154,7 @@
<parameter name="id"/>
<body>
<![CDATA[
if(id)
{
this.item.removeTag(id);
var rows = this.id('tagRows');
rows.removeChild(this.id('tag-'+id));
this.updateCount();
}
this.item.removeTag(id);
]]>
</body>
</method>
@ -118,7 +164,7 @@
<![CDATA[
if(count == null)
{
tags = this.item.getTags();
var tags = this.item.getTags();
if(tags)
count = tags.length;
else
@ -126,6 +172,7 @@
}
this.id('tagsNum').value = count + ' tags:';
this.count = count;
]]>
</body>
</method>
@ -137,12 +184,31 @@
]]>
</body>
</method>
<method name="fixPopup">
<body>
<![CDATA[
// Hack to fix popup close problems after using
// autocomplete -- something to do with the popup used
// in the XBL autocomplete binding?
//
// We reset the popup manually if it's showing
if (this.parentNode.getAttribute('showing')=='true'){
//Scholar.debug('Fixing popup');
// The target element is 'tagsLabel', so change the
// path if the XUL DOM in the note editor XBL changes
this.parentNode.showPopup(
this.parentNode.parentNode.previousSibling,
-1, -1, 'popup');
}
]]>
</body>
</method>
</implementation>
<content>
<xul:vbox xbl:inherits="flex">
<xul:hbox align="center">
<xul:label id="tagsNum"/>
<xul:button label="Add" oncommand="this.parentNode.parentNode.parentNode.add();"/>
<xul:button label="Add" oncommand="this.parentNode.parentNode.parentNode.new();"/>
</xul:hbox>
<xul:grid>
<xul:columns>

View file

@ -33,11 +33,11 @@ var ScholarItemPane = new function()
this.onOpenURLClick = onOpenURLClick;
this.addCreatorRow = addCreatorRow;
this.disableButton = disableButton;
this.createValueElement = createValueElement;
this.removeCreator = removeCreator;
this.showEditor = showEditor;
this.handleKeyPress = handleKeyPress;
this.hideEditor = hideEditor;
this.modifyField = modifyField;
this.getCreatorFields = getCreatorFields;
this.modifyCreator = modifyCreator;
this.removeNote = removeNote;
@ -50,6 +50,13 @@ var ScholarItemPane = new function()
function onLoad()
{
_tabs = document.getElementById('scholar-view-tabs');
// Not in item pane, so skip the introductions
if (!_tabs)
{
return false;
}
_dynamicFields = document.getElementById('editpane-dynamic-fields');
_itemTypeMenu = document.getElementById('editpane-type-menu');
_creatorTypeMenu = document.getElementById('creatorTypeMenu');
@ -88,7 +95,19 @@ var ScholarItemPane = new function()
// pane, since for some reason it's not being called automatically
if (_itemBeingEdited && _itemBeingEdited!=thisItem)
{
var boxes = _dynamicFields.getElementsByTagName('textbox');
switch (_tabs.selectedIndex)
{
// Info
case 0:
var boxes = _dynamicFields.getElementsByTagName('textbox');
break;
// Tags
case 3:
var boxes = document.getAnonymousNodes(_tagsBox)[0].getElementsByTagName('textbox');
break;
}
if (boxes.length==1)
{
boxes[0].inputField.blur();
@ -459,25 +478,33 @@ var ScholarItemPane = new function()
var fieldName = elem.getAttribute('fieldname');
var tabindex = elem.getAttribute('tabindex');
var value = '';
var creatorFields = fieldName.split('-');
if(creatorFields[0] == 'creator')
{
var c = _itemBeingEdited.getCreator(creatorFields[1]);
if(c)
value = c[creatorFields[2]];
var value = c ? c[creatorFields[2]] : '';
var itemID = _itemBeingEdited.getID();
}
else if (fieldName=='tag')
{
var tagID = elem.parentNode.getAttribute('id').split('-')[1];
var value = tagID ? Scholar.Tags.getName(tagID) : '';
var itemID = Scholar.getAncestorByTagName(elem, 'tagsbox').item.getID();
}
else
{
value = _itemBeingEdited.getField(fieldName);
var value = _itemBeingEdited.getField(fieldName);
var itemID = _itemBeingEdited.getID();
}
var t = document.createElement("textbox");
t.setAttribute('type', 'autocomplete');
t.setAttribute('autocompletesearch', 'zotero');
t.setAttribute('autocompletesearchparam', fieldName + (itemID ? '/' + itemID : ''));
t.setAttribute('value',value);
t.setAttribute('fieldname',fieldName);
t.setAttribute('fieldname', fieldName);
t.setAttribute('tabindex', tabindex);
t.setAttribute('flex','1');
t.className = 'fieldeditor';
var box = elem.parentNode;
box.replaceChild(t,elem);
@ -514,18 +541,24 @@ var ScholarItemPane = new function()
function hideEditor(t, saveChanges)
{
//Scholar.debug('Hiding editor');
var textbox = t.parentNode.parentNode;
var textbox = Scholar.getAncestorByTagName(t, 'textbox');
if (!textbox){
Scholar.debug('Textbox not found in hideEditor');
return;
}
var fieldName = textbox.getAttribute('fieldname');
var tabindex = textbox.getAttribute('tabindex');
var value = t.value;
var elem;
var creatorFields = fieldName.split('-');
// Creator fields
if(creatorFields[0] == 'creator')
{
if (saveChanges){
var otherFields =
this.getCreatorFields(textbox.parentNode.parentNode.parentNode);
getCreatorFields(textbox.parentNode.parentNode.parentNode);
modifyCreator(creatorFields[1], creatorFields[2], value, otherFields);
}
@ -548,6 +581,58 @@ var ScholarItemPane = new function()
elem = createValueElement(val, fieldName, tabindex);
}
// Tags
else if (fieldName=='tag')
{
var tagsbox = Scholar.getAncestorByTagName(textbox, 'tagsbox');
if (!tagsbox)
{
Scholar.debug('Tagsbox not found', 1);
return;
}
var row = textbox.parentNode;
var rows = row.parentNode;
// Tag id encoded as 'tag-1234'
var id = row.getAttribute('id').split('-')[1];
if (saveChanges)
{
if (id)
{
if (value)
{
tagsbox.replace(id, value);
return;
}
else
{
tagsbox.remove(id);
return;
}
}
else
{
var id = tagsbox.add(value);
}
}
if (id)
{
elem = createValueElement(value, 'tag', tabindex);
}
else
{
// Just remove the row
var row = rows.removeChild(row);
tagsbox.fixPopup();
return;
}
}
// Fields
else
{
if(saveChanges)

View file

@ -5,6 +5,7 @@
*/
var noteEditor;
var notifierUnregisterID;
function onLoad()
{
@ -42,12 +43,22 @@ function onLoad()
if(collectionID && collectionID != '' && collectionID != 'undefined')
noteEditor.collection = Scholar.Collections.get(collectionID);
}
notifierUnregisterID = Scholar.Notifier.registerItemTree(NotifyCallback);
}
function onUnload()
{
if(noteEditor && noteEditor.value)
noteEditor.save();
Scholar.Notifier.unregisterItemTree(notifierUnregisterID);
}
var NotifyCallback = {
notify: function(){
noteEditor.id('links').id('tags').reload();
}
}
addEventListener("load", function(e) { onLoad(e); }, false);

View file

@ -15,6 +15,7 @@
xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
<script src="include.js"/>
<script src="itemPane.js"/>
<script src="note.js"/>
<keyset>

View file

@ -355,6 +355,10 @@ Scholar.Item.prototype.getField = function(field){
* Field can be passed as fieldID or fieldName
*/
Scholar.Item.prototype.setField = function(field, value, loadIn){
if (!field){
throw ("Field not specified in Item.setField()");
}
// Primary field
if (this.isPrimaryField(field)){
if (!this.isEditableField(field)){
@ -1217,6 +1221,11 @@ Scholar.Item.prototype.addTag = function(tag){
this.save();
}
if (!tag){
Scholar.debug('Not saving empty tag', 2);
return false;
}
Scholar.DB.beginTransaction();
var tagID = Scholar.Tags.getID(tag);
if (!tagID){
@ -1227,14 +1236,18 @@ Scholar.Item.prototype.addTag = function(tag){
Scholar.DB.query(sql, [this.getID(), tagID]);
Scholar.DB.commitTransaction();
Scholar.Notifier.trigger('modify', 'item', this.getID());
if (!Scholar.DB.transactionInProgress()){
Scholar.Notifier.trigger('modify', 'item', this.getID());
}
return tagID;
}
Scholar.Item.prototype.getTags = function(){
var sql = "SELECT tag FROM tags WHERE tagID IN "
+ "(SELECT tagID FROM itemTags WHERE itemID=" + this.getID() + ")";
+ "(SELECT tagID FROM itemTags WHERE itemID=" + this.getID() + ") "
+ "ORDER BY tag COLLATE NOCASE";
return Scholar.DB.columnQuery(sql);
}
@ -1243,6 +1256,31 @@ Scholar.Item.prototype.getTagIDs = function(){
return Scholar.DB.columnQuery(sql);
}
Scholar.Item.prototype.replaceTag = function(oldTagID, newTag){
if (!this.getID()){
throw ('Cannot replace tag on unsaved item');
}
if (!newTag){
Scholar.debug('Not replacing with empty tag', 2);
return false;
}
Scholar.DB.beginTransaction();
var oldTag = Scholar.Tags.getName(oldTagID);
if (oldTag==newTag){
Scholar.DB.commitTransaction();
return false;
}
this.removeTag(oldTagID);
var id = this.addTag(newTag);
Scholar.DB.commitTransaction();
Scholar.Notifier.trigger('modify', 'item', this.getID());
return id;
}
Scholar.Item.prototype.removeTag = function(tagID){
if (!this.getID()){
throw ('Cannot remove tag on unsaved item');
@ -1253,7 +1291,10 @@ Scholar.Item.prototype.removeTag = function(tagID){
Scholar.DB.query(sql, [this.getID(), tagID]);
Scholar.Tags.purge();
Scholar.DB.commitTransaction();
Scholar.Notifier.trigger('modify', 'item', this.getID());
if (!Scholar.DB.transactionInProgress()){
Scholar.Notifier.trigger('modify', 'item', this.getID());
}
}

View file

@ -29,6 +29,7 @@ var Scholar = new function(){
this.varDump = varDump;
this.getString = getString;
this.flattenArguments = flattenArguments;
this.getAncestorByTagName = getAncestorByTagName;
this.join = join;
this.inArray = inArray;
this.arraySearch = arraySearch;
@ -328,6 +329,17 @@ var Scholar = new function(){
}
function getAncestorByTagName(elem, tagName){
while (elem.parentNode){
elem = elem.parentNode;
if (elem.tagName==tagName || elem.tagName=='xul:' + tagName){
return elem;
}
}
return false;
}
/*
* A version of join() that operates externally for use on objects other
* than arrays (e.g. _arguments_)

View file

@ -58,6 +58,11 @@ tagsbox
-moz-binding: url('chrome://scholar/content/bindings/tagsbox.xml#tags-box');
}
tagsbox row
{
-moz-box-align:center;
}
seealsobox
{
-moz-binding: url('chrome://scholar/content/bindings/relatedbox.xml#seealso-box');
@ -79,12 +84,12 @@ searchcondition menulist[id="operatorsmenu"]
width:15em;
}
#editpane-dynamic-fields row
#editpane-dynamic-fields row, tagsbox row
{
margin:0 0 1px;
}
#editpane-dynamic-fields textbox
#editpane-dynamic-fields textbox, tagsbox textbox
{
margin-top:0;
margin-bottom:-1px;

View file

@ -0,0 +1,199 @@
const ZOTERO_AC_CONTRACTID = '@mozilla.org/autocomplete/search;1?name=zotero';
const ZOTERO_AC_CLASSNAME = 'Zotero AutoComplete';
const ZOTERO_AC_CID = Components.ID('{06a2ed11-d0a4-4ff0-a56f-a44545eee6ea}');
const ZOTERO_AC_IID = Components.interfaces.chnmIZoteroAutoComplete;
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
/*
* Implements nsIAutoCompleteResult
*/
function ZoteroAutoCompleteResult(searchString, searchResult, defaultIndex,
errorDescription, results, comments){
this._searchString = searchString;
this._searchResult = searchResult;
this._defaultIndex = defaultIndex;
this._errorDescription = errorDescription;
this._results = results;
this._comments = comments;
}
ZoteroAutoCompleteResult.prototype = {
_searchString: "",
_searchResult: 0,
_defaultIndex: 0,
_errorDescription: "",
_results: [],
_comments: [],
get searchString(){ return this._searchString; },
get searchResult(){ return this._searchResult; },
get defaultIndex(){ return this._defaultIndex; },
get errorDescription(){ return this._errorDescription; },
get matchCount(){ return this._results.length; }
}
ZoteroAutoCompleteResult.prototype.getCommentAt = function(index){
return this._comments[index];
}
ZoteroAutoCompleteResult.prototype.getStyleAt = function(index){
return null;
}
ZoteroAutoCompleteResult.prototype.getValueAt = function(index){
return this._results[index];
}
ZoteroAutoCompleteResult.prototype.removeValueAt = function(index){
this._results.splice(index, 1);
this._comments.splice(index, 1);
}
ZoteroAutoCompleteResult.prototype.QueryInterface = function(iid){
if (!iid.equals(Ci.nsIAutoCompleteResult) &&
!iid.equals(Ci.nsISupports)){
throw Cr.NS_ERROR_NO_INTERFACE;
}
return this;
}
/*
* Implements nsIAutoCompleteSearch
*/
function ZoteroAutoComplete(){
// Get the Zotero object
this._zotero = Components.classes["@chnm.gmu.edu/Zotero;1"]
.getService(Components.interfaces.nsISupports)
.wrappedJSObject;
}
ZoteroAutoComplete.prototype.startSearch = function(searchString, searchParam,
previousResult, listener){
this.stopSearch();
/*
this._zotero.debug("Starting autocomplete search of type '"
+ searchParam + "'" + " with string '" + searchString + "'");
*/
// Allow extra parameters to be passed in
var pos = searchParam.indexOf('/');
if (pos!=-1){
var extra = searchParam.substr(pos + 1);
var searchParam = searchParam.substr(0, pos);
}
switch (searchParam){
case 'tag':
var sql = "SELECT tag FROM tags WHERE tag LIKE ?";
var sqlParams = [searchString + '%'];
if (extra){
sql += " AND tagID NOT IN (SELECT tagID FROM itemTags WHERE "
+ "itemID = ?)";
sqlParams.push(extra);
}
var results = this._zotero.DB.columnQuery(sql, sqlParams);
var resultCode = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
break;
default:
this._zotero.debug("'" + searchParam + "' is not a valid autocomplete scope", 1);
var results = []
var resultCode = Ci.nsIAutoCompleteResult.RESULT_IGNORED;
}
if (results===false){
var results = [];
var resultCode = Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
}
var result = new ZoteroAutoCompleteResult(searchString,
resultCode, 0, "", results, []);
listener.onSearchResult(this, result);
}
ZoteroAutoComplete.prototype.stopSearch = function(){
//this._zotero.debug('Stopping autocomplete search');
}
ZoteroAutoComplete.prototype.QueryInterface = function(iid){
if (!iid.equals(Ci.nsIAutoCompleteSearch) &&
!iid.equals(Ci.nsIAutoCompleteObserver) &&
!iid.equals(Ci.nsISupports)){
throw Cr.NS_ERROR_NO_INTERFACE;
}
return this;
}
//
// XPCOM goop
//
var ZoteroAutoCompleteFactory = {
createInstance: function(outer, iid){
if (outer != null){
throw Components.results.NS_ERROR_NO_AGGREGATION;
}
return new ZoteroAutoComplete().QueryInterface(iid);
}
};
var ZoteroAutoCompleteModule = {
_firstTime: true,
registerSelf: function(compMgr, fileSpec, location, type){
if (!this._firstTime){
throw Components.results.NS_ERROR_FACTORY_REGISTER_AGAIN;
}
this._firstTime = false;
compMgr =
compMgr.QueryInterface(Components.interfaces.nsIComponentRegistrar);
compMgr.registerFactoryLocation(ZOTERO_AC_CID,
ZOTERO_AC_CLASSNAME,
ZOTERO_AC_CONTRACTID,
fileSpec,
location,
type);
},
unregisterSelf: function(compMgr, location, type){
compMgr =
compMgr.QueryInterface(Components.interfaces.nsIComponentRegistrar);
compMgr.unregisterFactoryLocation(ZOTERO_AC_CID, location);
},
getClassObject: function(compMgr, cid, iid){
if (!cid.equals(ZOTERO_AC_CID)){
throw Components.results.NS_ERROR_NO_INTERFACE;
}
if (!iid.equals(Components.interfaces.nsIFactory)){
throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
}
return ZoteroAutoCompleteFactory;
},
canUnload: function(compMgr){ return true; }
};
function NSGetModule(comMgr, fileSpec){ return ZoteroAutoCompleteModule; }