From 00c2b14d6c1b9b3af8c086a0421d393684fe5992 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Tue, 3 Jun 2008 05:26:30 +0000 Subject: [PATCH] Adds rudimentary Zeroconf support to Zotero (a.k.a. "Z(ot)eroconf") - Inspired by Dan Chudnov's Python/MODS-based Zeroconf demo at THATcamp - Enabled by extensions.zotero.zeroconf.enabled (off by default) - Currently supports only OS X (tested on Leopard, not sure about earlier versions) - Uses Apple's dns-sd and mDNS command-client clients, but should be able to be extended to other clients, though a native library would be far superior - Discovery is on-demand for now via Actions menu ("Search for Shared Libraries") - Includes rudimentary web server (code copied from integration.js) that serves items as sync XML -- no authentication yet! - Only supports top-level items - Remote libraries show up in left pane (under remote computer name, for now) - Items can be dragged into collections (but not the library yet, for some reason) - On first run, might cause a long pause and the "This file was downloaded from the Internet" message on Leopard -- can't manage to get around the quarantine for the script file that we need to access stdout from Firefox - Needs a lot of work, and without a real JS (or otherwise Mozilla-native) Zeroconf library we can't do proper discovery without intermittent polling - But it works, at least for me Also includes some data/sync-layer changes that I needed along the way (and that we'll need for shared collections of any type) --- chrome/content/zotero/itemPane.js | 15 +- chrome/content/zotero/overlay.js | 25 +- chrome/content/zotero/overlay.xul | 2 + .../zotero/xpcom/collectionTreeView.js | 69 +++- chrome/content/zotero/xpcom/data/item.js | 12 +- chrome/content/zotero/xpcom/data/tags.js | 3 +- chrome/content/zotero/xpcom/dataServer.js | 271 ++++++++++++++ chrome/content/zotero/xpcom/itemTreeView.js | 41 ++- chrome/content/zotero/xpcom/notifier.js | 2 +- chrome/content/zotero/xpcom/sync.js | 74 ++-- chrome/content/zotero/xpcom/zeroconf.js | 348 ++++++++++++++++++ chrome/content/zotero/xpcom/zotero.js | 4 + .../skin/default/zotero/treesource-share.png | Bin 0 -> 712 bytes components/zotero-service.js | 4 +- defaults/preferences/zotero.js | 3 + scripts/zoteroconf.sh | 42 +++ 16 files changed, 859 insertions(+), 56 deletions(-) create mode 100644 chrome/content/zotero/xpcom/dataServer.js create mode 100644 chrome/content/zotero/xpcom/zeroconf.js create mode 100644 chrome/skin/default/zotero/treesource-share.png create mode 100755 scripts/zoteroconf.sh diff --git a/chrome/content/zotero/itemPane.js b/chrome/content/zotero/itemPane.js index 0ecdb3322..c90a0b966 100644 --- a/chrome/content/zotero/itemPane.js +++ b/chrome/content/zotero/itemPane.js @@ -70,7 +70,7 @@ var ZoteroItemPane = new function() { /* * Loads an item */ - function viewItem(thisItem) { + function viewItem(thisItem, mode) { //Zotero.debug('Viewing item'); // Force blur() when clicking off a textbox to another item in middle @@ -100,11 +100,11 @@ var ZoteroItemPane = new function() { _itemBeingEdited = thisItem; _loaded = {}; - loadPane(_tabs.selectedIndex); + loadPane(_tabs.selectedIndex, mode); } - function loadPane(index) { + function loadPane(index, mode) { //Zotero.debug('Loading item pane ' + index); // Clear the tab index when switching panes @@ -121,7 +121,14 @@ var ZoteroItemPane = new function() { // Info pane if (index == 0) { var itembox = document.getElementById('zotero-editpane-item-box'); - itembox.mode = 'edit'; + // Hack to allow read-only mode in right pane -- probably a better + // way to allow access to this + if (mode) { + itembox.mode = mode; + } + else { + itembox.mode = 'edit'; + } itembox.item = _itemBeingEdited; } diff --git a/chrome/content/zotero/overlay.js b/chrome/content/zotero/overlay.js index 9184bca61..54bbf0776 100644 --- a/chrome/content/zotero/overlay.js +++ b/chrome/content/zotero/overlay.js @@ -788,7 +788,12 @@ var ZoteroPane = new function() if(item.ref.isNote()) { var noteEditor = document.getElementById('zotero-note-editor'); - noteEditor.mode = 'edit'; + if (this.itemsView.readOnly) { + noteEditor.mode = 'view'; + } + else { + noteEditor.mode = 'edit'; + } // If loading new or different note, disable undo while we repopulate the text field // so Undo doesn't end up clearing the field. This also ensures that Undo doesn't @@ -953,7 +958,7 @@ var ZoteroPane = new function() } else { - ZoteroItemPane.viewItem(item.ref); + ZoteroItemPane.viewItem(item.ref, this.itemsView.readOnly ? 'view' : false); document.getElementById('zotero-item-pane-content').selectedIndex = 1; } } @@ -1056,7 +1061,8 @@ var ZoteroPane = new function() var noPrompt = true; } // Do nothing in search view - else if (this.itemsView._itemGroup.isSearch()) { + else if (this.itemsView._itemGroup.isSearch() || + this.itemsView._itemGroup.isShare()) { return; } } @@ -1465,7 +1471,14 @@ var ZoteroPane = new function() var enable = [], disable = [], show = [], hide = [], multiple = ''; - if (this.itemsView && this.itemsView.selection.count > 0) { + // TODO: implement menu for remote items + if (this.itemsView.readOnly) { + for each(var pos in m) { + disable.push(pos); + } + } + + else if (this.itemsView && this.itemsView.selection.count > 0) { enable.push(m.showInLibrary, m.addNote, m.attachSnapshot, m.attachLink, m.sep2, m.duplicateItem, m.deleteItem, m.deleteFromLibrary, m.exportItems, m.createBib, m.loadReport); @@ -1607,6 +1620,10 @@ var ZoteroPane = new function() } } else if (tree.id == 'zotero-items-tree') { + if (this.itemsView.readOnly) { + return; + } + if (this.itemsView && this.itemsView.selection.currentIndex > -1) { var item = this.getSelectedItems()[0]; if (item && item.isNote()) { diff --git a/chrome/content/zotero/overlay.xul b/chrome/content/zotero/overlay.xul index 42dd8c450..aa7ee0479 100644 --- a/chrome/content/zotero/overlay.xul +++ b/chrome/content/zotero/overlay.xul @@ -124,6 +124,8 @@ + diff --git a/chrome/content/zotero/xpcom/collectionTreeView.js b/chrome/content/zotero/xpcom/collectionTreeView.js index 0deb40b3a..07c4a620a 100644 --- a/chrome/content/zotero/xpcom/collectionTreeView.js +++ b/chrome/content/zotero/xpcom/collectionTreeView.js @@ -36,7 +36,7 @@ Zotero.CollectionTreeView = function() this._treebox = null; this.itemToSelect = null; this._highlightedRows = {}; - this._unregisterID = Zotero.Notifier.registerObserver(this, ['collection', 'search']); + this._unregisterID = Zotero.Notifier.registerObserver(this, ['collection', 'search', 'share']); } /* @@ -107,6 +107,13 @@ Zotero.CollectionTreeView.prototype.refresh = function() } } + var shares = Zotero.Zeroconf.instances; + if (shares) { + for each(var share in shares) { + this._showItem(new Zotero.ItemGroup('share', share), 0, this._dataItems.length); //itemgroup ref, level, beforeRow + } + } + this._refreshHashMap(); // Update the treebox's row count @@ -162,8 +169,16 @@ Zotero.CollectionTreeView.prototype.notify = function(action, type, ids) var madeChanges = false; - if(action == 'delete') - { + if (action == 'refresh') { + switch (type) { + case 'share': + this.reload(); + this.rememberSelection(savedSelection); + break; + } + } + + else if(action == 'delete') { //Since a delete involves shifting of rows, we have to do it in order //sort the ids by row @@ -672,6 +687,16 @@ Zotero.CollectionTreeView.prototype.canDrop = function(row, orient) } return false; } + else if (dataType == 'zotero/item-xml') { + var xml = new XML(data.data); + for each(var xmlNode in xml.items.item) { + var item = Zotero.Sync.Server.Data.xmlToItem(xmlNode); + if (item.isRegularItem() || !item.getSource()) { + return true; + } + } + return false; + } else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') { if (this._getItemAtRow(row).isSearch()) { @@ -733,6 +758,25 @@ Zotero.CollectionTreeView.prototype.drop = function(row, orient) this._getItemAtRow(row).ref.addItems(toAdd); } } + else if (dataType == 'zotero/item-xml') { + Zotero.DB.beginTransaction(); + var xml = new XML(data.data); + var toAdd = []; + for each(var xmlNode in xml.items.item) { + var item = Zotero.Sync.Server.Data.xmlToItem(xmlNode, false, true); + if (item.isRegularItem() || !item.getSource()) { + var id = item.save(); + toAdd.push(id); + } + } + if (toAdd.length > 0) { + this._getItemAtRow(row).ref.addItems(toAdd); + } + + Zotero.DB.commitTransaction(); + + return; + } else if (dataType == 'text/x-moz-url' || dataType == 'application/x-moz-file') { if (this._getItemAtRow(row).isCollection()) { var parentCollectionID = this._getItemAtRow(row).ref.id; @@ -820,6 +864,7 @@ Zotero.CollectionTreeView.prototype.getSupportedFlavours = function () var flavors = new FlavourSet(); flavors.appendFlavour("zotero/collection"); flavors.appendFlavour("zotero/item"); + flavors.appendFlavour("zotero/item-xml"); flavors.appendFlavour("text/x-moz-url"); flavors.appendFlavour("application/x-moz-file", "nsIFile"); return flavors; @@ -884,6 +929,11 @@ Zotero.ItemGroup.prototype.isSearch = function() return this.type == 'search'; } +Zotero.ItemGroup.prototype.isShare = function() +{ + return this.type == 'share'; +} + Zotero.ItemGroup.prototype.getName = function() { if (this.isCollection()) { @@ -895,6 +945,9 @@ Zotero.ItemGroup.prototype.getName = function() else if (this.isSearch()) { return this.ref.name; } + else if (this.isShare()) { + return this.ref.name; + } else { return ""; } @@ -902,6 +955,11 @@ Zotero.ItemGroup.prototype.getName = function() Zotero.ItemGroup.prototype.getChildItems = function() { + // Fake results if this is a shared library + if (this.isShare()) { + return this.ref.getAll(); + } + var s = this.getSearchObject(); try { var ids = s.search(); @@ -970,6 +1028,11 @@ Zotero.ItemGroup.prototype.getSearchObject = function() { * Returns all the tags used by items in the current view */ Zotero.ItemGroup.prototype.getChildTags = function() { + // TODO: implement? + if (this.isShare()) { + return false; + } + var s = this.getSearchObject(); return Zotero.Tags.getAllWithinSearch(s); } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index 3707682e5..15cc4f46c 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -1765,6 +1765,10 @@ Zotero.Item.prototype.getNoteTitle = function() { return this._noteTitle; } + if (!this.id) { + return ''; + } + var sql = "SELECT title FROM itemNotes WHERE itemID=?"; var title = Zotero.DB.valueQuery(sql, this.id); @@ -1782,10 +1786,6 @@ Zotero.Item.prototype.getNote = function() { throw ("getNote() can only be called on notes and attachments"); } - if (!this.id) { - return ''; - } - // Store access time for later garbage collection this._noteAccessTime = new Date(); @@ -1793,6 +1793,10 @@ Zotero.Item.prototype.getNote = function() { return this._noteText; } + if (!this.id) { + return ''; + } + var sql = "SELECT note FROM itemNotes WHERE itemID=" + this.id; var note = Zotero.DB.valueQuery(sql); diff --git a/chrome/content/zotero/xpcom/data/tags.js b/chrome/content/zotero/xpcom/data/tags.js index 82e809bfd..2e3d6b2a6 100644 --- a/chrome/content/zotero/xpcom/data/tags.js +++ b/chrome/content/zotero/xpcom/data/tags.js @@ -163,7 +163,8 @@ Zotero.Tags = new function() { var tmpTable = search.search(true); } catch (e) { - if (e.match(/Saved search [0-9]+ does not exist/)) { + if (typeof e == 'string' + && e.match(/Saved search [0-9]+ does not exist/)) { Zotero.DB.rollbackTransaction(); Zotero.debug(e, 2); } diff --git a/chrome/content/zotero/xpcom/dataServer.js b/chrome/content/zotero/xpcom/dataServer.js new file mode 100644 index 000000000..054abb412 --- /dev/null +++ b/chrome/content/zotero/xpcom/dataServer.js @@ -0,0 +1,271 @@ +Zotero.DataServer = new function () { + this.init = init; + this.handleHeader = handleHeader; + + // TODO: assign dynamically + this.__defineGetter__('port', function () { + return 22030; + }); + + var _onlineObserverRegistered; + + + /* + * initializes a very rudimentary web server used for SOAP RPC + */ + function init() { + // Use Zeroconf pref for now + if (!Zotero.Prefs.get("zeroconf.server.enabled")) { + Zotero.debug("Not initializing data HTTP server"); + return; + } + + if (Zotero.Utilities.HTTP.browserIsOffline()) { + Zotero.debug('Browser is offline -- not initializing data HTTP server'); + _registerOnlineObserver() + return; + } + + // start listening on socket + var serv = Components.classes["@mozilla.org/network/server-socket;1"] + .createInstance(Components.interfaces.nsIServerSocket); + try { + serv.init(this.port, false, -1); + serv.asyncListen(Zotero.DataServer.SocketListener); + + Zotero.debug("Data HTTP server listening on 127.0.0.1:" + serv.port); + } + catch(e) { + Zotero.debug("Not initializing data HTTP server"); + } + + _registerOnlineObserver() + } + + /* + * handles an HTTP request + */ + function handleHeader(header) { + // get first line of request (all we care about for now) + var method = header.substr(0, header.indexOf(" ")); + + if (!method) { + return _generateResponse("400 Bad Request"); + } + + if (method != "POST") { + return _generateResponse("501 Method Not Implemented"); + } + + // Parse request URI + var matches = header.match("^[A-Z]+ (\/.*) HTTP/1.[01]"); + if (!matches) { + return _generateResponse("400 Bad Request"); + } + + var response = _handleRequest(matches[1]); + + // return OK + return _generateResponse("200 OK", 'text/xml; charset="UTF-8"', response); + } + + + function _handleRequest(uri) { + var s = new Zotero.Search(); + s.addCondition('noChildren', 'true'); + var ids = s.search(); + + if (!ids) { + ids = []; + } + + var uploadIDs = { + updated: { + items: ids + }, + /* TODO: fix buildUploadXML to ignore missing */ + deleted: {} + }; + return Zotero.Sync.Server.Data.buildUploadXML(uploadIDs); + } + + + /* + * generates the response to an HTTP request + */ + function _generateResponse(status, contentType, body) { + var response = "HTTP/1.0 "+status+"\r\n"; + + if(body) { + if(contentType) { + response += "Content-Type: "+contentType+"\r\n"; + } + response += "\r\n"+body; + } else { + response += "Content-Length: 0\r\n\r\n" + } + + return response; + } + + + function _registerOnlineObserver() { + if (_onlineObserverRegistered) { + return; + } + + // Observer to enable the integration when we go online + var observer = { + observe: function(subject, topic, data) { + if (data == 'online') { + Zotero.Integration.init(); + } + } + }; + + var observerService = + Components.classes["@mozilla.org/observer-service;1"] + .getService(Components.interfaces.nsIObserverService); + observerService.addObserver(observer, "network:offline-status-changed", false); + + _onlineObserverRegistered = true; + } +} + + +Zotero.DataServer.SocketListener = new function() { + this.onSocketAccepted = onSocketAccepted; + + /* + * called when a socket is opened + */ + function onSocketAccepted(socket, transport) { + // get an input stream + var iStream = transport.openInputStream(0, 0, 0); + var oStream = transport.openOutputStream(0, 0, 0); + + var dataListener = new Zotero.DataServer.DataListener(iStream, oStream); + var pump = Components.classes["@mozilla.org/network/input-stream-pump;1"] + .createInstance(Components.interfaces.nsIInputStreamPump); + pump.init(iStream, -1, -1, 0, 0, false); + pump.asyncRead(dataListener, null); + } +} + +/* + * handles the actual acquisition of data + */ +Zotero.DataServer.DataListener = function(iStream, oStream) { + this.header = ""; + this.headerFinished = false; + + this.body = ""; + this.bodyLength = 0; + + this.iStream = iStream; + this.oStream = oStream; + this.sStream = Components.classes["@mozilla.org/scriptableinputstream;1"] + .createInstance(Components.interfaces.nsIScriptableInputStream); + this.sStream.init(iStream); + + this.foundReturn = false; +} + +/* + * called when a request begins (although the request should have begun before + * the DataListener was generated) + */ +Zotero.DataServer.DataListener.prototype.onStartRequest = function(request, context) {} + +/* + * called when a request stops + */ +Zotero.DataServer.DataListener.prototype.onStopRequest = function(request, context, status) { + this.iStream.close(); + this.oStream.close(); +} + +/* + * called when new data is available + */ +Zotero.DataServer.DataListener.prototype.onDataAvailable = function(request, context, + inputStream, offset, count) { + var readData = this.sStream.read(count); + + // Read header + if (!this.headerFinished) { + // see if there's a magic double return + var lineBreakIndex = readData.indexOf("\r\n\r\n"); + if (lineBreakIndex != -1) { + if (lineBreakIndex != 0) { + this.header += readData.substr(0, lineBreakIndex+4); + } + + this._headerFinished(); + return; + } + + var lineBreakIndex = readData.indexOf("\n\n"); + if (lineBreakIndex != -1) { + if (lineBreakIndex != 0) { + this.header += readData.substr(0, lineBreakIndex+2); + } + + this._headerFinished(); + return; + } + + if (this.header && this.header[this.header.length-1] == "\n" && + (readData[0] == "\n" || readData[0] == "\r")) { + if (readData.length > 1 && readData[1] == "\n") { + this.header += readData.substr(0, 2); + } + else { + this.header += readData[0]; + } + + this._headerFinished(); + return; + } + + this.header += readData; + } +} + +/* + * processes an HTTP header and decides what to do + */ +Zotero.DataServer.DataListener.prototype._headerFinished = function() { + this.headerFinished = true; + var output = Zotero.DataServer.handleHeader(this.header); + this._requestFinished(output); +} + +/* + * returns HTTP data from a request + */ +Zotero.DataServer.DataListener.prototype._requestFinished = function(response) { + // close input stream + this.iStream.close(); + + // open UTF-8 converter for output stream + var intlStream = Components.classes["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Components.interfaces.nsIConverterOutputStream); + + // write + try { + intlStream.init(this.oStream, "UTF-8", 1024, "?".charCodeAt(0)); + + Zotero.debug('Writing response to stream:\n\n' + response); + + // write response + intlStream.writeString(response); + } catch(e) { + Zotero.debug("An error occurred."); + Zotero.debug(e); + } finally { + Zotero.debug('Closing stream'); + intlStream.close(); + } +} + diff --git a/chrome/content/zotero/xpcom/itemTreeView.js b/chrome/content/zotero/xpcom/itemTreeView.js index 5b64b5f57..ff9ca4f6b 100644 --- a/chrome/content/zotero/xpcom/itemTreeView.js +++ b/chrome/content/zotero/xpcom/itemTreeView.js @@ -47,7 +47,7 @@ Zotero.ItemTreeView = function(itemGroup, sourcesOnly) this._dataItems = []; this.rowCount = 0; - this._unregisterID = Zotero.Notifier.registerObserver(this, ['item', 'collection-item']); + this._unregisterID = Zotero.Notifier.registerObserver(this, ['item', 'collection-item', 'share-items']); } @@ -229,6 +229,13 @@ Zotero.ItemTreeView.prototype.refresh = function() } +Zotero.ItemTreeView.prototype.__defineGetter__('readOnly', function () { + if (this._itemGroup.isShare()) { + return true; + } + return false; +}); + /* * Called by Zotero.Notifier on any changes to items in the data layer */ @@ -251,7 +258,12 @@ Zotero.ItemTreeView.prototype.notify = function(action, type, ids, extraData) // If refreshing a single item, just unselect and reselect it if (action == 'refresh') { - if (savedSelection.length == 1 && savedSelection[0] == ids[0]) { + if (type == 'share-items') { + if (this._itemGroup.isShare()) { + this.refresh(); + } + } + else if (savedSelection.length == 1 && savedSelection[0] == ids[0]) { this.selection.clearSelection(); this.rememberSelection(savedSelection); } @@ -259,6 +271,10 @@ Zotero.ItemTreeView.prototype.notify = function(action, type, ids, extraData) return; } + if (this._itemGroup.isShare()) { + return; + } + this.selection.selectEventsSuppressed = true; // See if we're in the active window @@ -1502,7 +1518,22 @@ Zotero.ItemTreeCommandController.prototype.onEvent = function(evt) * Begin a drag */ Zotero.ItemTreeView.prototype.onDragStart = function (evt,transferData,action) -{ +{ + // Quick implementation of dragging of XML item format + if (this.readOnly) { + var items = this.getSelectedItems(); + + var xml = ; + for (var i=0; iPx%f=NU{R5;6}lRa--RS-bW-1pwDZDrTl*z6ixd_+kTl322gkdk!BqChlA(72$c zq2v$HnIdjz_#27Rq<~PRbdh9HM5KUMYnv$E_1AjeyL0ayidBe(+%eKneN+`yeG3TcdC@!{LBhF2LjhpBBe7uGR_F7&RpXPwGEowB(_ z(-5P1ABqs-OpJliuD9r?B>FlrI8w?mDUd9ZG?A<7-Dgjqtf*=cLYS(msv{zo+wJxh z5yzkX0~@^7Q%j~XCTGbg^17ta{xipDPtHEZ=H})nX6c*e?8V#Huisp0He3Aq%R_p- zzrNkr`0?lSo+&#!tgWqmzqGjY-K|@F?~g7z3&SfTN>B>h*egp20s2IfU;IK{@&W0000 /tmp/zoteroconf_instances & + +elif [ $1 = "kill_find_instances" ]; then + PIDs=`ps x | grep "dns-sd -B" | grep _zotero._tcp | sed -E 's/ *([0-9]+).*/\1/' | xargs` + if [ "$PIDs" ]; then + kill $PIDs + fi + +elif [ $1 = "get_info" ]; then + if [ ! "$2" ]; then + echo "Service name not specified" + exit 1 + fi + + if [ ! "$3" ]; then + echo "Temp file path not specified" + exit 1 + fi + + #dns-sd -L "$2" _zotero._tcp local. > $3 & + mDNS -L "$2" _zotero._tcp local. > $3 & + +elif [ $1 = "kill_get_info" ]; then + #PIDs=`ps x | grep "dns-sd -L" | grep _zotero._tcp | sed -E 's/ *([0-9]+).*/\1/' | xargs` + PIDs=`ps x | grep "mDNS -L" | grep _zotero._tcp | sed -E 's/ *([0-9]+).*/\1/' | xargs` + if [ "$PIDs" ]; then + kill $PIDs + fi + +elif [ $1 = "kill_service" ]; then + PIDs=`ps x | grep dns-sd | grep '_zotero._tcp' | sed -E 's/ *([0-9]+).*/\1/' | xargs` + if [ "$PIDs" ]; then + kill $PIDs + fi +fi