www

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | Submodules | README | LICENSE

commit 9bcdf021dd80b3b762e10d1da0105a19102dbd1a
parent f00e5501e9ec678ef1b92b3a1e217fba359d286a
Author: Dan Stillman <dstillman@zotero.org>
Date:   Mon, 16 Jun 2008 05:46:10 +0000

- Fixes tag editing
- Adds tag syncing
- Fixes a few other things

No tag CR yet
Requires new 1.0 DB upgrade



Diffstat:
Mchrome/content/zotero/bindings/tagsbox.xml | 429+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mchrome/content/zotero/bindings/tagselector.xml | 4++--
Mchrome/content/zotero/itemPane.js | 39+++++++++++++--------------------------
Mchrome/content/zotero/itemPane.xul | 74+++++++++++++++++++++++++++++++++++++++-----------------------------------
Mchrome/content/zotero/overlay.js | 9+++++----
Mchrome/content/zotero/xpcom/data/collection.js | 13++++++++-----
Mchrome/content/zotero/xpcom/data/creator.js | 1+
Mchrome/content/zotero/xpcom/data/creators.js | 6+++---
Mchrome/content/zotero/xpcom/data/item.js | 117+++++++++++++++++++++++++++++++++++++++++---------------------------------------
Achrome/content/zotero/xpcom/data/tag.js | 544+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mchrome/content/zotero/xpcom/data/tags.js | 312+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mchrome/content/zotero/xpcom/id.js | 2+-
Mchrome/content/zotero/xpcom/schema.js | 21+++++++++++++++++++++
Mchrome/content/zotero/xpcom/search.js | 2+-
Mchrome/content/zotero/xpcom/sync.js | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcomponents/zotero-autocomplete.js | 10++++++++--
Mcomponents/zotero-service.js | 13+++++++------
Msystem.sql | 1+
Muserdata.sql | 8+++++---
19 files changed, 1353 insertions(+), 332 deletions(-)

diff --git a/chrome/content/zotero/bindings/tagsbox.xml b/chrome/content/zotero/bindings/tagsbox.xml @@ -28,16 +28,49 @@ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> <binding id="tags-box"> <implementation> - <field name="itemRef"/> - <property name="item" onget="return this.itemRef;"> + <field name="clickHandler"/> + + <!-- Modes are predefined settings groups for particular tasks --> + <field name="_mode">"view"</field> + <property name="mode" onget="return this._mode;"> + <setter> + <![CDATA[ + this.clickable = false; + this.editable = false; + + switch (val) { + case 'view': + break; + + case 'edit': + this.clickable = true; + this.editable = true; + this.clickHandler = this.showEditor; + this.blurHandler = this.hideEditor; + break; + + default: + throw ("Invalid mode '" + val + "' in tagsbox.xml"); + } + + this._mode = val; + document.getAnonymousNodes(this)[0].setAttribute('mode', val); + ]]> + </setter> + </property> + + <field name="_item"/> + <property name="item" onget="return this._item;"> <setter> <![CDATA[ - this.itemRef = val; + this._item = val; this.reload(); ]]> </setter> </property> + <property name="count"/> + <property name="summary"> <getter> <![CDATA[ @@ -50,7 +83,7 @@ { for(var i = 0; i < tags.length; i++) { - r = r + tags[i].tag + ", "; + r = r + tags[i].name + ", "; } r = r.substr(0,r.length-2); } @@ -60,10 +93,13 @@ ]]> </getter> </property> + + + <method name="reload"> <body> <![CDATA[ - //Zotero.debug('Reloading tags'); + Zotero.debug('Reloading tags'); var rows = this.id('tagRows'); while(rows.hasChildNodes()) @@ -88,6 +124,8 @@ ]]> </body> </method> + + <method name="addDynamicRow"> <parameter name="tagObj"/> <parameter name="tabindex"/> @@ -95,11 +133,11 @@ <![CDATA[ if (tagObj) { var tagID = tagObj.id; - var tag = tagObj.tag; + var name = tagObj.name; var type = tagObj.type; } - if (!tag) { - tag = ''; + if (!name) { + name = ''; } if (!tabindex) @@ -128,7 +166,7 @@ // DEBUG: Why won't just this.nextSibling.blur() work? icon.setAttribute('onclick','if (this.nextSibling.inputField){ this.nextSibling.inputField.blur() }'); - var label = ZoteroItemPane.createValueElement(tag, 'tag', tabindex); + var label = this.createValueElement(name, tabindex); var remove = document.createElement("label"); remove.setAttribute('value','-'); @@ -159,6 +197,284 @@ ]]> </body> </method> + + + <method name="createValueElement"> + <parameter name="valueText"/> + <parameter name="tabindex"/> + <body> + <![CDATA[ + var valueElement = document.createElement("label"); + valueElement.setAttribute('fieldname', 'tag'); + valueElement.setAttribute('flex', 1); + + if (this.clickable) { + valueElement.setAttribute('ztabindex', tabindex); + valueElement.addEventListener('click', function (event) { + /* Skip right-click on Windows */ + if (event.button) { + return; + } + document.getBindingParent(this).clickHandler(this); + }, false); + valueElement.className = 'zotero-clicky'; + } + + this._tabIndexMaxTagsFields = Math.max(this._tabIndexMaxTagsFields, tabindex); + + var firstSpace; + if (typeof valueText == 'string') { + firstSpace = valueText.indexOf(" "); + } + + // 29 == arbitrary length at which to chop uninterrupted text + if ((firstSpace == -1 && valueText.length > 29 ) || firstSpace > 29) { + valueElement.setAttribute('crop', 'end'); + valueElement.setAttribute('value',valueText); + } + else { + // Wrap to multiple lines + valueElement.appendChild(document.createTextNode(valueText)); + } + + return valueElement; + ]]> + </body> + </method> + + + <method name="showEditor"> + <parameter name="elem"/> + <body> + <![CDATA[ + // Blur any active fields + /* + if (this._dynamicFields) { + this._dynamicFields.focus(); + } + */ + + Zotero.debug('Showing editor'); + + var fieldName = 'tag'; + var tabindex = elem.getAttribute('ztabindex'); + + var tagID = elem.parentNode.getAttribute('id').split('-')[1]; + var value = tagID ? Zotero.Tags.getName(tagID) : ''; + var itemID = Zotero.getAncestorByTagName(elem, 'tagsbox').item.id; + + var t = document.createElement("textbox"); + t.setAttribute('value', value); + t.setAttribute('fieldname', fieldName); + t.setAttribute('ztabindex', tabindex); + t.setAttribute('flex', '1'); + + // Add auto-complete + t.setAttribute('type', 'autocomplete'); + t.setAttribute('autocompletesearch', 'zotero'); + var suffix = itemID ? itemID : ''; + t.setAttribute('autocompletesearchparam', fieldName + '/' + suffix); + + var box = elem.parentNode; + box.replaceChild(t, elem); + + // Prevent error when clicking between a changed field + // and another -- there's probably a better way + if (!t.select) { + return; + } + + t.select(); + + t.addEventListener('blur', function () { + document.getBindingParent(this).blurHandler(this); + }, false); + t.setAttribute('onkeypress', "return document.getBindingParent(this).handleKeyPress(event)"); + + this._tabDirection = false; + this._lastTabIndex = tabindex; + + return t; + ]]> + </body> + </method> + + + <method name="handleKeyPress"> + <parameter name="event"/> + <body> + <![CDATA[ + var target = event.target; + var focused = document.commandDispatcher.focusedElement; + + switch (event.keyCode) { + case event.DOM_VK_RETURN: + var fieldname = 'tag'; + + // Prevent blur on containing textbox + // DEBUG: what happens if this isn't present? + event.preventDefault(); + + // If last tag row, create new one + var row = target.parentNode.parentNode; + if (row == row.parentNode.lastChild) { + this._tabDirection = 1; + var lastTag = true; + } + focused.blur(); + + // Return focus to items pane + if (!lastTag) { + var tree = document.getElementById('zotero-items-tree'); + if (tree) { + tree.focus(); + } + } + + return false; + + case event.DOM_VK_ESCAPE: + // Reset field to original value + target.value = target.getAttribute('value'); + + var tagsbox = Zotero.getAncestorByTagName(focused, 'tagsbox'); + + focused.blur(); + + if (tagsbox) { + tagsbox.closePopup(); + } + + // Return focus to items pane + var tree = document.getElementById('zotero-items-tree'); + if (tree) { + tree.focus(); + } + + return false; + + case event.DOM_VK_TAB: + this._tabDirection = event.shiftKey ? -1 : 1; + // Blur the old manually -- not sure why this is necessary, + // but it prevents an immediate blur() on the next tag + focused.blur(); + return false; + } + + return true; + ]]> + </body> + </method> + + + <method name="hideEditor"> + <parameter name="textbox"/> + <body> + <![CDATA[ + Zotero.debug('Hiding editor'); + /* + var textbox = Zotero.getAncestorByTagName(t, 'textbox'); + if (!textbox){ + Zotero.debug('Textbox not found in hideEditor'); + return; + } + */ + + // TODO: get rid of this? + //var saveChanges = this.saveOnEdit; + var saveChanges = true; + + var fieldName = 'tag'; + var tabindex = textbox.getAttribute('ztabindex'); + + //var value = t.value; + var value = textbox.value; + + var elem; + + var tagsbox = Zotero.getAncestorByTagName(textbox, 'tagsbox'); + if (!tagsbox) + { + Zotero.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) { + // If trying to replace with another existing tag + // (which causes a delete of the row), + // clear the tab direction so we don't advance + // when the notifier kicks in + var existing = Zotero.Tags.getID(value, 0); + if (existing && id != existing) { + this._tabDirection = false; + } + var changed = tagsbox.replace(id, value); + if (changed) { + return; + } + } + else { + tagsbox.remove(id); + return; + } + } + // New tag + else { + // If this is an existing automatic tag, it's going to be + // deleted and the number of rows will stay the same, + // so we have to compensate + var existingTypes = Zotero.Tags.getTypes(value); + if (existingTypes && existingTypes.indexOf(1) != -1) { + this._lastTabIndex--; + } + var id = tagsbox.add(value); + } + } + + if (id) { + elem = this.createValueElement( + value, + 'tag', + tabindex + ); + } + else { + // Just remove the row + // + // If there's an open popup, this throws NODE CANNOT BE FOUND + try { + var row = rows.removeChild(row); + } + catch (e) {} + tagsbox.fixPopup(); + tagsbox.closePopup(); + + this._tabDirection = false; + return; + } + + var focusMode = 'tags'; + var focusBox = tagsbox; + + var box = textbox.parentNode; + box.replaceChild(elem,textbox); + + if (this._tabDirection) { + this._focusNextField(focusBox, this._lastTabIndex, this._tabDirection == -1); + } + ]]> + </body> + </method> + + <method name="new"> <body> <![CDATA[ @@ -167,18 +483,21 @@ ]]> </body> </method> + + <method name="add"> <parameter name="value"/> <body> <![CDATA[ - if (value) - { + if (value) { return this.item.addTag(value); } return false; ]]> </body> </method> + + <method name="replace"> <parameter name="oldTagID"/> <parameter name="newTag"/> @@ -196,6 +515,8 @@ ]]> </body> </method> + + <method name="remove"> <parameter name="id"/> <body> @@ -204,6 +525,8 @@ ]]> </body> </method> + + <method name="updateCount"> <parameter name="count"/> <body> @@ -235,14 +558,8 @@ ]]> </body> </method> - <method name="id"> - <parameter name="id"/> - <body> - <![CDATA[ - return document.getAnonymousNodes(this)[0].getElementsByAttribute('id',id)[0]; - ]]> - </body> - </method> + + <method name="fixPopup"> <body> <![CDATA[ @@ -262,6 +579,8 @@ ]]> </body> </method> + + <method name="closePopup"> <body> <![CDATA[ @@ -271,10 +590,78 @@ ]]> </body> </method> - <method name="getScrollBox"> + + + <!-- + Advance the field focus forward or backward + + Note: We're basically replicating the built-in tabindex functionality, + which doesn't work well with the weird label/textbox stuff we're doing. + (The textbox being tabbed away from is deleted before the blur() + completes, so it doesn't know where it's supposed to go next.) + --> + <method name="_focusNextField"> + <parameter name="box"/> + <parameter name="tabindex"/> + <parameter name="back"/> + <body> + <![CDATA[ + tabindex = parseInt(tabindex); + if (back) { + switch (tabindex) { + case 1: + return false; + + default: + var nextIndex = tabindex - 1; + } + } + else { + switch (tabindex) { + case this._tabIndexMaxTagsFields: + // In tags box, keep going to create new row + var nextIndex = tabindex + 1; + break; + + default: + var nextIndex = tabindex + 1; + } + } + + Zotero.debug('Looking for tabindex ' + nextIndex, 4); + + var next = document.getAnonymousNodes(box)[0]. + getElementsByAttribute('ztabindex', nextIndex); + if (!next[0]) { + next[0] = box.addDynamicRow(); + } + + next[0].click(); + this.ensureElementIsVisible(next[0]); + return true; + ]]> + </body> + </method> + + + <method name="ensureElementIsVisible"> + <parameter name="elem"/> + <body> + <![CDATA[ + var scrollbox = document.getAnonymousNodes(this)[0]; + var sbo = scrollbox.boxObject; + sbo.QueryInterface(Components.interfaces.nsIScrollBoxObject); + sbo.ensureElementIsVisible(elem); + ]]> + </body> + </method> + + + <method name="id"> + <parameter name="id"/> <body> <![CDATA[ - return document.getAnonymousNodes(this)[0]; + return document.getAnonymousNodes(this)[0].getElementsByAttribute('id',id)[0]; ]]> </body> </method> diff --git a/chrome/content/zotero/bindings/tagselector.xml b/chrome/content/zotero/bindings/tagselector.xml @@ -181,7 +181,7 @@ for (var tagID in this._tags) { // If the last tag was the same, add this tagID and tagType to it if (tagsToggleBox.lastChild && - tagsToggleBox.lastChild.getAttribute('value') == this._tags[tagID].tag) { + tagsToggleBox.lastChild.getAttribute('value') == this._tags[tagID].name) { tagsToggleBox.lastChild.setAttribute('tagID', tagsToggleBox.lastChild.getAttribute('tagID') + '-' + tagID); tagsToggleBox.lastChild.setAttribute('tagType', tagsToggleBox.lastChild.getAttribute('tagType') + '-' + this._tags[tagID].type); continue; @@ -190,7 +190,7 @@ var label = document.createElement('label'); label.setAttribute('onclick', "this.parentNode.parentNode.parentNode.handleTagClick(event, this)"); label.className = 'zotero-clicky'; - label.setAttribute('value', this._tags[tagID].tag); + label.setAttribute('value', this._tags[tagID].name); label.setAttribute('tagID', tagID); label.setAttribute('tagType', this._tags[tagID].type); label.setAttribute('context', 'tag-menu'); diff --git a/chrome/content/zotero/itemPane.js b/chrome/content/zotero/itemPane.js @@ -120,16 +120,15 @@ var ZoteroItemPane = new function() { // Info pane if (index == 0) { - var itembox = document.getElementById('zotero-editpane-item-box'); // Hack to allow read-only mode in right pane -- probably a better // way to allow access to this if (mode) { - itembox.mode = mode; + _itemBox.mode = mode; } else { - itembox.mode = 'edit'; + _itemBox.mode = 'edit'; } - itembox.item = _itemBeingEdited; + _itemBox.item = _itemBeingEdited; } // Notes pane @@ -147,7 +146,9 @@ var ZoteroItemPane = new function() { icon.setAttribute('src','chrome://zotero/skin/treeitem-note.png'); var label = document.createElement('label'); - label.setAttribute('value',_noteToTitle(notes[i].getNote())); + var title = Zotero.Notes.noteToTitle(notes[i].getNote()); + title = title ? title : Zotero.getString('pane.item.notes.untitled'); + label.setAttribute('value', title); label.setAttribute('flex','1'); //so that the long names will flex smaller label.setAttribute('crop','end'); @@ -236,6 +237,13 @@ var ZoteroItemPane = new function() { // Tags pane else if(index == 3) { + if (mode) { + _tagsBox.mode = mode; + } + else { + _tagsBox.mode = 'edit'; + } + var focusMode = 'tags'; var focusBox = _tagsBox; _tagsBox.item = _itemBeingEdited; @@ -269,27 +277,6 @@ var ZoteroItemPane = new function() { ZoteroPane.openNoteWindow(null, null, _itemBeingEdited.id); } - function _noteToTitle(text) - { - var MAX_LENGTH = 100; - - var t = text.substring(0, MAX_LENGTH); - var ln = t.indexOf("\n"); - if (ln>-1 && ln<MAX_LENGTH) - { - t = t.substring(0, ln); - } - - if(t == "") - { - return Zotero.getString('pane.item.notes.untitled'); - } - else - { - return t; - } - } - function _updateNoteCount() { var c = _notesList.childNodes.length; diff --git a/chrome/content/zotero/itemPane.xul b/chrome/content/zotero/itemPane.xul @@ -28,45 +28,49 @@ <overlay xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="include.js"/> <script src="itemPane.js"/> <deck id="zotero-view-item" flex="1" onselect="if (this.selectedIndex !== '') { ZoteroItemPane.loadPane(this.selectedIndex); }"> <zoteroitembox id="zotero-editpane-item-box" flex="1"/> - <vbox flex="1"> - <hbox align="center"> - <label id="zotero-editpane-notes-label"/> - <button label="&zotero.item.add;" oncommand="ZoteroItemPane.addNote();"/> - </hbox> - <grid flex="1"> - <columns> - <column flex="1"/> - <column/> - </columns> - <rows id="zotero-editpane-dynamic-notes" flex="1"/> - </grid> - </vbox> - <vbox flex="1"> - <hbox align="center"> - <label id="zotero-editpane-attachments-label"/> - <button id="zotero-tb-item-attachments-add" type="menu" label="&zotero.item.add;"> - <menupopup> - <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-link" label="&zotero.toolbar.attachment.linked;" oncommand="ZoteroItemPane.addAttachmentFromDialog(true);"/> - <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-file" label="&zotero.toolbar.attachment.add;" oncommand="ZoteroItemPane.addAttachmentFromDialog();"/> - <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-web-link" label="&zotero.toolbar.attachment.weblink;" oncommand="ZoteroItemPane.addAttachmentFromPage(true);"/> - <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-snapshot" label="&zotero.toolbar.attachment.snapshot;" oncommand="ZoteroItemPane.addAttachmentFromPage();"/> - </menupopup> - </button> - </hbox> - <grid flex="1"> - <columns> - <column flex="1"/> - <column/> - </columns> - <rows id="zotero-editpane-dynamic-attachments" flex="1"/> - </grid> - </vbox> - <tagsbox id="zotero-editpane-tags" flex="1"/> - <seealsobox id="zotero-editpane-related" flex="1"/> + <vbox flex="1"> + <hbox align="center"> + <label id="zotero-editpane-notes-label"/> + <button label="&zotero.item.add;" oncommand="ZoteroItemPane.addNote();"/> + </hbox> + <grid flex="1"> + <columns> + <column flex="1"/> + <column/> + </columns> + <rows id="zotero-editpane-dynamic-notes" flex="1"/> + </grid> + </vbox> + + <vbox flex="1"> + <hbox align="center"> + <label id="zotero-editpane-attachments-label"/> + <button id="zotero-tb-item-attachments-add" type="menu" label="&zotero.item.add;"> + <menupopup> + <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-link" label="&zotero.toolbar.attachment.linked;" oncommand="ZoteroItemPane.addAttachmentFromDialog(true);"/> + <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-file" label="&zotero.toolbar.attachment.add;" oncommand="ZoteroItemPane.addAttachmentFromDialog();"/> + <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-web-link" label="&zotero.toolbar.attachment.weblink;" oncommand="ZoteroItemPane.addAttachmentFromPage(true);"/> + <menuitem class="menuitem-iconic" id="zotero-tb-item-attachments-snapshot" label="&zotero.toolbar.attachment.snapshot;" oncommand="ZoteroItemPane.addAttachmentFromPage();"/> + </menupopup> + </button> + </hbox> + <grid flex="1"> + <columns> + <column flex="1"/> + <column/> + </columns> + <rows id="zotero-editpane-dynamic-attachments" flex="1"/> + </grid> + </vbox> + + <tagsbox id="zotero-editpane-tags" flex="1"/> + + <seealsobox id="zotero-editpane-related" flex="1"/> </deck> </overlay> \ No newline at end of file diff --git a/chrome/content/zotero/overlay.js b/chrome/content/zotero/overlay.js @@ -785,8 +785,7 @@ var ZoteroPane = new function() { var item = this.itemsView._getItemAtRow(this.itemsView.selection.currentIndex); - if(item.ref.isNote()) - { + if(item.ref.isNote()) { var noteEditor = document.getElementById('zotero-note-editor'); if (this.itemsView.readOnly) { noteEditor.mode = 'view'; @@ -817,8 +816,8 @@ var ZoteroPane = new function() } document.getElementById('zotero-item-pane-content').selectedIndex = 2; } - else if(item.ref.isAttachment()) - { + + else if(item.ref.isAttachment()) { // DEBUG: this is annoying -- we really want to use an abstracted // version of createValueElement() from itemPane.js // (ideally in an XBL binding) @@ -956,6 +955,8 @@ var ZoteroPane = new function() document.getElementById('zotero-item-pane-content').selectedIndex = 3; } + + // Regular item else { ZoteroItemPane.viewItem(item.ref, this.itemsView.readOnly ? 'view' : false); diff --git a/chrome/content/zotero/xpcom/data/collection.js b/chrome/content/zotero/xpcom/data/collection.js @@ -551,11 +551,14 @@ Zotero.Collection.prototype.addItems = function(itemIDs) { * Remove an item from the collection (does not delete item from library) **/ Zotero.Collection.prototype.removeItem = function(itemID) { - var index = this.getChildItems(true).indexOf(itemID); - if (index == -1) { - Zotero.debug("Item " + itemID + " not a child of collection " - + this.id + " in Zotero.Collection.removeItem()"); - return false; + var childItems = this.getChildItems(true); + if (childItems) { + var index = childItems.indexOf(itemID); + if (index == -1) { + Zotero.debug("Item " + itemID + " not a child of collection " + + this.id + " in Zotero.Collection.removeItem()"); + return false; + } } Zotero.DB.beginTransaction(); diff --git a/chrome/content/zotero/xpcom/data/creator.js b/chrome/content/zotero/xpcom/data/creator.js @@ -353,6 +353,7 @@ Zotero.Creator.prototype.erase = function () { Zotero.debug("Deleting creator " + this.id); + // TODO: notifier var changedItems = []; var changedItemsNotifierData = {}; diff --git a/chrome/content/zotero/xpcom/data/creators.js b/chrome/content/zotero/xpcom/data/creators.js @@ -50,14 +50,14 @@ Zotero.Creators = new function() { return _creatorsByID[creatorID]; } - var sql = 'SELECT * FROM creators WHERE creatorID=?'; - var result = Zotero.DB.rowQuery(sql, creatorID); + var sql = 'SELECT COUNT(*) FROM creators WHERE creatorID=?'; + var result = Zotero.DB.valueQuery(sql, creatorID); if (!result) { return false; } - _creatorsByID[creatorID] = new Zotero.Creator(result.creatorID); + _creatorsByID[creatorID] = new Zotero.Creator(creatorID); return _creatorsByID[creatorID]; } diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js @@ -2364,12 +2364,12 @@ Zotero.Item.prototype.getBestSnapshot = function() { // // save() is not required for tag functions // -Zotero.Item.prototype.addTag = function(tag, type) { +Zotero.Item.prototype.addTag = function(name, type) { if (!this.id) { throw ('Cannot add tag to unsaved item in Item.addTag()'); } - if (!tag) { + if (!name) { Zotero.debug('Not saving empty tag in Item.addTag()', 2); return false; } @@ -2378,18 +2378,13 @@ Zotero.Item.prototype.addTag = function(tag, type) { type = 0; } - if (type !=0 && type !=1) { - throw ('Invalid tag type in Item.addTag()'); - } - Zotero.DB.beginTransaction(); - var tagID = Zotero.Tags.getID(tag, type); - var existingTypes = Zotero.Tags.getTypes(tag); + var existingTypes = Zotero.Tags.getTypes(name); if (existingTypes) { // If existing automatic and adding identical user, remove automatic if (type == 0 && existingTypes.indexOf(1) != -1) { - this.removeTag(Zotero.Tags.getID(tag, 1)); + this.removeTag(Zotero.Tags.getID(name, 1)); } // If existing user and adding automatic, skip else if (type == 1 && existingTypes.indexOf(0) != -1) { @@ -2399,8 +2394,12 @@ Zotero.Item.prototype.addTag = function(tag, type) { } } + var tagID = Zotero.Tags.getID(name, type); if (!tagID) { - var tagID = Zotero.Tags.add(tag, type); + var tag = new Zotero.Tag; + tag.name = name; + tag.type = type; + var tagID = tag.save(); } try { @@ -2433,38 +2432,20 @@ Zotero.Item.prototype.addTags = function (tags, type) { Zotero.Item.prototype.addTagByID = function(tagID) { if (!this.id) { - throw ('Cannot add tag to unsaved item in Item.addTagByID()'); + throw ('Cannot add tag to unsaved item in Zotero.Item.addTagByID()'); } if (!tagID) { - Zotero.debug('Not saving nonexistent tag in Item.addTagByID()', 2); - return false; - } - - var sql = "SELECT COUNT(*) FROM tags WHERE tagID = ?"; - var count = !!Zotero.DB.valueQuery(sql, tagID); - - if (!count) { - throw ('Cannot add invalid tag id ' + tagID + ' in Item.addTagByID()'); + throw ('tagID not provided in Zotero.Item.addTagByID()'); } - Zotero.DB.beginTransaction(); - - // If INSERT OR IGNORE gave us affected rows, we wouldn't need this... - if (this.hasTag(tagID)) { - Zotero.debug('Item ' + this.id + ' already has tag ' + tagID + ' in Item.addTagByID()'); - Zotero.DB.commitTransaction(); - return false; + var tag = Zotero.Tags.get(tagID); + if (!tag) { + throw ('Cannot add invalid tag ' + tagID + ' in Zotero.Item.addTagByID()'); } - var sql = "INSERT INTO itemTags VALUES (?,?)"; - Zotero.DB.query(sql, [this.id, tagID]); - - Zotero.DB.commitTransaction(); - Zotero.Notifier.trigger('modify', 'item', this.id); - Zotero.Notifier.trigger('add', 'item-tag', this.id + '-' + tagID); - - return true; + tag.addItem(this.id); + tag.save(); } Zotero.Item.prototype.hasTag = function(tagID) { @@ -2484,28 +2465,38 @@ Zotero.Item.prototype.hasTags = function(tagIDs) { return !!Zotero.DB.valueQuery(sql, [this.id].concat(tagIDs)); } +/** + * Returns all tags assigned to an item + * + * @return array Array of Zotero.Tag objects + */ Zotero.Item.prototype.getTags = function() { if (!this.id) { return false; } - var sql = "SELECT tagID AS id, tag, tagType AS type FROM tags WHERE tagID IN " - + "(SELECT tagID FROM itemTags WHERE itemID=" + this.id + ")"; - - var tags = Zotero.DB.query(sql); + var sql = "SELECT tagID, name FROM tags WHERE tagID IN " + + "(SELECT tagID FROM itemTags WHERE itemID=?)"; + var tags = Zotero.DB.query(sql, this.id); if (!tags) { return false; } var collation = Zotero.getLocaleCollation(); tags.sort(function(a, b) { - return collation.compareString(1, a.tag, b.tag); + return collation.compareString(1, a.name, b.name); }); - return tags; + + var tagObjs = []; + for (var i=0; i<tags.length; i++) { + var tag = Zotero.Tags.get(tags[i].tagID, true); + tagObjs.push(tag); + } + return tagObjs; } Zotero.Item.prototype.getTagIDs = function() { - var sql = "SELECT tagID FROM itemTags WHERE itemID=" + this.id; - return Zotero.DB.columnQuery(sql); + var sql = "SELECT tagID FROM itemTags WHERE itemID=?"; + return Zotero.DB.columnQuery(sql, this.id); } Zotero.Item.prototype.replaceTag = function(oldTagID, newTag) { @@ -2537,16 +2528,20 @@ Zotero.Item.prototype.replaceTag = function(oldTagID, newTag) { Zotero.Item.prototype.removeTag = function(tagID) { if (!this.id) { - throw ('Cannot remove tag on unsaved item'); + throw ('Cannot remove tag on unsaved item in Zotero.Item.removeTag()'); } - Zotero.DB.beginTransaction(); - var sql = "DELETE FROM itemTags WHERE itemID=? AND tagID=?"; - Zotero.DB.query(sql, [this.id, { int: tagID }]); - Zotero.Tags.purge(); - Zotero.DB.commitTransaction(); - Zotero.Notifier.trigger('modify', 'item', this.id); - Zotero.Notifier.trigger('remove', 'item-tag', this.id + '-' + tagID); + if (!tagID) { + throw ('tagID not provided in Zotero.Item.removeTag()'); + } + + var tag = Zotero.Tags.get(tagID); + if (!tag) { + throw ('Cannot remove invalid tag ' + tagID + ' in Zotero.Item.removeTag()'); + } + + tag.removeItem(this.id); + tag.save(); } Zotero.Item.prototype.removeAllTags = function() { @@ -3188,10 +3183,14 @@ Zotero.Item.prototype.toArray = function (mode) { } } - arr.tags = this.getTags(); - if (!arr.tags) { - arr.tags = []; + arr.tags = []; + var tags = this.getTags(); + if (tags) { + for (var i=0; i<tags.length; i++) { + arr.tags.push(tags[i].serialize()); + } } + arr.related = this.getSeeAlso(); if (!arr.related) { arr.related = []; @@ -3312,10 +3311,14 @@ Zotero.Item.prototype.serialize = function(mode) { } } - arr.tags = this.getTags(); - if (!arr.tags) { - arr.tags = []; + arr.tags = []; + var tags = this.getTags(); + if (tags) { + for (var i=0; i<tags.length; i++) { + arr.tags.push(tags[i].serialize()); + } } + arr.related = this.getSeeAlso(); if (!arr.related) { arr.related = []; diff --git a/chrome/content/zotero/xpcom/data/tag.js b/chrome/content/zotero/xpcom/data/tag.js @@ -0,0 +1,544 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright (c) 2006 Center for History and New Media + George Mason University, Fairfax, Virginia, USA + http://chnm.gmu.edu + + Licensed under the Educational Community License, Version 1.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.opensource.org/licenses/ecl1.php + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + ***** END LICENSE BLOCK ***** +*/ + + +Zotero.Tag = function(tagID) { + this._tagID = tagID ? tagID : null; + this._init(); +} + +Zotero.Tag.prototype._init = function () { + // Public members for access by public methods -- do not access directly + this._name = null; + this._type = null; + this._dateModified = null; + this._key = null; + + this._loaded = false; + this._changed = false; + this._previousData = false; + + this._linkedItemsLoaded = false; + this._linkedItems = []; +} + + +Zotero.Tag.prototype.__defineGetter__('id', function () { return this._tagID; }); + +Zotero.Tag.prototype.__defineSetter__('tagID', function (val) { this._set('tagID', val); }); +Zotero.Tag.prototype.__defineGetter__('name', function () { return this._get('name'); }); +Zotero.Tag.prototype.__defineSetter__('name', function (val) { this._set('name', val); }); +Zotero.Tag.prototype.__defineGetter__('type', function () { return this._get('type'); }); +Zotero.Tag.prototype.__defineSetter__('type', function (val) { this._set('type', val); }); +Zotero.Tag.prototype.__defineGetter__('dateModified', function () { return this._get('dateModified'); }); +Zotero.Tag.prototype.__defineSetter__('dateModified', function (val) { this._set('dateModified', val); }); +Zotero.Tag.prototype.__defineGetter__('key', function () { return this._get('key'); }); +Zotero.Tag.prototype.__defineSetter__('key', function (val) { this._set('key', val); }); + +Zotero.Tag.prototype.__defineSetter__('linkedItems', function (arr) { this._setLinkedItems(arr); }); + + +Zotero.Tag.prototype._get = function (field) { + if (this.id && !this._loaded) { + this.load(); + } + return this['_' + field]; +} + + +Zotero.Tag.prototype._set = function (field, val) { + switch (field) { + case 'id': // set using constructor + //case 'tagID': // set using constructor + throw ("Invalid field '" + field + "' in Zotero.Tag.set()"); + } + + if (this.id) { + if (!this._loaded) { + this.load(); + } + } + else { + this._loaded = true; + } + + if (this['_' + field] != val) { + this._prepFieldChange(field); + + switch (field) { + default: + this['_' + field] = val; + } + } +} + + +/** + * Check if tag exists in the database + * + * @return bool TRUE if the tag exists, FALSE if not + */ +Zotero.Tag.prototype.exists = function() { + if (!this.id) { + throw ('tagID not set in Zotero.Tag.exists()'); + } + + var sql = "SELECT COUNT(*) FROM tags WHERE tagID=?"; + return !!Zotero.DB.valueQuery(sql, this.id); +} + + +/* + * Build tag from database + */ +Zotero.Tag.prototype.load = function() { + Zotero.debug("Loading data for tag " + this.id + " in Zotero.Tag.load()"); + + if (!this.id) { + throw ("tagID not set in Zotero.Tag.load()"); + } + + var sql = "SELECT name, type, dateModified, key FROM tags WHERE tagID=?"; + var data = Zotero.DB.rowQuery(sql, this.id); + + this._init(); + this._loaded = true; + + if (!data) { + return; + } + + for (var key in data) { + this['_' + key] = data[key]; + } +} + + +/** + * Returns items linked to this tag + * + * @param bool asIDs Return as itemIDs + * @return array Array of Zotero.Item instances or itemIDs, + * or FALSE if none + */ +Zotero.Tag.prototype.getLinkedItems = function (asIDs) { + if (!this._linkedItemsLoaded) { + this._loadLinkedItems(); + } + + if (this._linkedItems.length == 0) { + return false; + } + + // Return itemIDs + if (asIDs) { + var ids = []; + for each(var item in this._linkedItems) { + ids.push(item.id); + } + return ids; + } + + // Return Zotero.Item objects + var objs = []; + for each(var item in this._linkedItems) { + objs.push(item); + } + return objs; +} + + +Zotero.Tag.prototype._setLinkedItems = function (itemIDs) { + if (!this._linkedItemsLoaded) { + this._loadLinkedItems(); + } + + if (itemIDs.constructor.name != 'Array') { + throw ('ids must be an array in Zotero.Tag._setLinkedItems()'); + } + + var currentIDs = this.getLinkedItems(true); + if (!currentIDs) { + currentIDs = []; + } + var oldIDs = []; // children being kept + var newIDs = []; // new children + + if (itemIDs.length == 0) { + if (currentIDs.length == 0) { + Zotero.debug('No linked items added', 4); + return false; + } + } + else { + for (var i in itemIDs) { + var id = parseInt(itemIDs[i]); + if (isNaN(id)) { + throw ("Invalid itemID '" + itemIDs[i] + + "' in Zotero.Tag._setLinkedItems()"); + } + + if (currentIDs.indexOf(id) != -1) { + Zotero.debug("Item " + itemIDs[i] + + " is already linked to tag " + this.id); + oldIDs.push(id); + continue; + } + + newIDs.push(id); + } + } + + // Mark as changed if new or removed ids + if (newIDs.length > 0 || oldIDs.length != currentIDs.length) { + this._prepFieldChange('linkedItems'); + } + else { + Zotero.debug('Linked items not changed in Zotero.Tag._setLinkedItems()', 4); + return false; + } + + newIDs = oldIDs.concat(newIDs); + + var items = Zotero.Items.get(itemIDs); + this._linkedItems = items ? items : []; + return true; +} + + +Zotero.Tag.prototype.addItem = function (itemID) { + var current = this.getLinkedItems(true); + if (current && current.indexOf(itemID) != -1) { + Zotero.debug("Item " + itemID + " already has tag " + + this.id + " in Zotero.Tag.addItem()"); + return false; + } + + this._prepFieldChange('linkedItems'); + var item = Zotero.Items.get(itemID); + if (!item) { + throw ("Can't link invalid item " + itemID + " to tag " + this.id + + " in Zotero.Tag.addItem()"); + } + this._linkedItems.push(item); + return true; +} + + +Zotero.Tag.prototype.removeItem = function (itemID) { + var current = this.getLinkedItems(true); + if (current) { + var index = current.indexOf(itemID); + } + + if (!current || index == -1) { + Zotero.debug("Item " + itemID + " doesn't have tag " + + this.id + " in Zotero.Tag.removeItem()"); + return false; + } + + this._prepFieldChange('linkedItems'); + this._linkedItems.splice(index, 1); + return true; +} + + +Zotero.Tag.prototype.save = function () { + // Default to manual tag + if (!this.type) { + this.type = 0; + } + + if (this.type != 0 && this.type != 1) { + throw ('Invalid tag type ' + this.type + ' in Zotero.Tag.save()'); + } + + if (!this.name) { + throw ('Tag name is empty in Zotero.Tag.save()'); + } + + if (!this._changed) { + Zotero.debug("Tag " + this.id + " has not changed"); + return false; + } + + + Zotero.DB.beginTransaction(); + + // ID change + if (this._changed.tagID) { + var oldID = this._previousData.primary.tagID; + var params = [this.id, oldID]; + + Zotero.debug("Changing tagID " + oldID + " to " + this.id); + + var row = Zotero.DB.rowQuery("SELECT * FROM tags WHERE tagID=?", oldID); + + // Set type on old row to -1, since there's a UNIQUE on name/type + Zotero.DB.query("UPDATE tags SET type=-1 WHERE tagID=?", oldID); + + // Add a new row so we can update the old rows despite FK checks + // Use temp key due to UNIQUE constraint on key column + Zotero.DB.query("INSERT INTO tags VALUES (?, ?, ?, ?, ?)", + [this.id, row.name, row.type, row.dateModified, 'TEMPKEY']); + + Zotero.DB.query("UPDATE itemTags SET tagID=? WHERE tagID=?", params); + + Zotero.DB.query("DELETE FROM tags WHERE tagID=?", oldID); + + Zotero.DB.query("UPDATE tags SET key=? WHERE tagID=?", [row.key, this.id]); + + Zotero.Tags.unload([{ oldID: { name: row.name, type: row.type } }]); + Zotero.Notifier.trigger('id-change', 'tag', oldID + '-' + this.id); + + // update caches + } + + var isNew = !this.id || !this.exists(); + + try { + // how to know if date modified changed (in server code too?) + + var tagID = this.id ? this.id : Zotero.ID.get('tags'); + + Zotero.debug("Saving tag " + this.id); + + var key = this.key ? this.key : this._generateKey(); + + var columns = [ + 'tagID', 'name', 'type', 'dateModified', 'key' + ]; + var placeholders = ['?', '?', '?', '?', '?']; + var sqlValues = [ + tagID ? { int: tagID } : null, + { string: this.name }, + { int: this.type }, + // If date modified hasn't changed, use current timestamp + this._changed.dateModified ? + this.dateModified : Zotero.DB.transactionDateTime, + key + ]; + + var sql = "REPLACE INTO tags (" + columns.join(', ') + ") VALUES (" + + placeholders.join(', ') + ")"; + var insertID = Zotero.DB.query(sql, sqlValues); + if (!tagID) { + tagID = insertID; + } + + // Linked items + if (this._changed.linkedItems) { + var removed = []; + var newids = []; + var currentIDs = this.getLinkedItems(true); + if (!currentIDs) { + currentIDs = []; + } + + if (this._previousData.linkedItems) { + for each(var id in this._previousData.linkedItems) { + if (currentIDs.indexOf(id) == -1) { + removed.push(id); + } + } + } + for each(var id in currentIDs) { + if (this._previousData.linkedItems && + this._previousData.linkedItems.indexOf(id) != -1) { + continue; + } + newids.push(id); + } + + if (removed.length) { + var sql = "DELETE FROM itemTags WHERE tagID=? " + + "AND itemID IN (" + + removed.map(function () '?').join() + + ")"; + Zotero.DB.query(sql, [tagID].concat(removed)); + } + + if (newids.length) { + var sql = "INSERT INTO itemTags (itemID, tagID) VALUES (?,?)"; + var insertStatement = Zotero.DB.getStatement(sql); + + for each(var itemID in newids) { + insertStatement.bindInt32Parameter(0, itemID); + insertStatement.bindInt32Parameter(1, tagID); + + try { + insertStatement.execute(); + } + catch (e) { + throw (e + ' [ERROR: ' + Zotero.DB.getLastErrorString() + ']'); + } + } + } + + //Zotero.Notifier.trigger('add', 'tag-item', this.id + '-' + itemID); + + // TODO: notify linked items of name changes? + // Zotero.Notifier.trigger('modify', 'item', itemIDs); + } + + Zotero.DB.commitTransaction(); + } + catch (e) { + Zotero.DB.rollbackTransaction(); + throw (e); + } + + // If successful, set values in object + if (!this.id) { + this._tagID = tagID; + } + + if (!this.key) { + this._key = key; + } + + Zotero.Tags.reload(this.id); + + if (isNew) { + Zotero.Notifier.trigger('add', 'tag', this.id); + } + else { + Zotero.Notifier.trigger('modify', 'tag', this.id, this._previousData); + } + + return this.id; +} + + +Zotero.Tag.prototype.serialize = function () { + var obj = { + primary: { + tagID: this.id, + dateModified: this.dateModified, + key: this.key + }, + name: this.name, + type: this.type, + linkedItems: this.getLinkedItems(true), + }; + return obj; +} + + + +/** + * Remove tag from all linked items + * + * Tags.erase() should be used externally instead of this + * + * Actual deletion of tag occurs in Zotero.Tags.purge(), + * which is called by Tags.erase() + */ +Zotero.Tag.prototype.erase = function () { + Zotero.debug('Deleting tag ' + this.id); + + if (!this.id) { + return; + } + + var linkedItems = []; + var linkedItemsNotifierData = {}; + + Zotero.DB.beginTransaction(); + + var deletedTagNotifierData = {}; + deletedTagNotifierData[this.id] = { old: this.serialize() }; + + var sql = "SELECT itemID FROM itemTags WHERE tagID=?"; + var linkedItemIDs = Zotero.DB.columnQuery(sql, this.id); + + if (!linkedItemIDs) { + Zotero.DB.commitTransaction(); + return; + } + + var sql = "DELETE FROM itemTags WHERE tagID=?"; + Zotero.DB.query(sql, this.id); + + var itemTags = []; + for each(var itemID in linkedItemIDs) { + var item = Zotero.Items.get(itemID) + if (!item) { + throw ('Linked item not found in Zotero.Tag.erase()'); + } + linkedItems.push(itemID); + linkedItemsNotifierData[itemID] = { old: item.serialize() }; + + itemTags.push(itemID + '-' + this.id); + } + Zotero.Notifier.trigger('remove', 'item-tag', itemTags); + + // Send notification of linked items + if (linkedItems.length) { + Zotero.Notifier.trigger('modify', 'item', linkedItems, linkedItemsNotifierData); + } + + Zotero.Notifier.trigger('delete', 'tag', this.id, deletedTagNotifierData); + + Zotero.DB.commitTransaction(); + return; +} + + +Zotero.Tag.prototype._loadLinkedItems = function() { + if (!this._loaded) { + this.load(); + } + + var sql = "SELECT itemID FROM itemTags WHERE tagID=?"; + var ids = Zotero.DB.columnQuery(sql, this.id); + + this._linkedItems = []; + + if (ids) { + for each(var id in ids) { + this._linkedItems.push(Zotero.Items.get(id)); + } + } + + this._linkedItemsLoaded = true; +} + + +Zotero.Tag.prototype._prepFieldChange = function (field) { + if (!this._changed) { + this._changed = {}; + } + this._changed[field] = true; + + // Save a copy of the data before changing + // TODO: only save previous data if tag exists + if (this.id && this.exists() && !this._previousData) { + this._previousData = this.serialize(); + } +} + + +Zotero.Tag.prototype._generateKey = function () { + return Zotero.ID.getKey(); +} + diff --git a/chrome/content/zotero/xpcom/data/tags.js b/chrome/content/zotero/xpcom/data/tags.js @@ -33,37 +33,37 @@ Zotero.Tags = new function() { this.getID = getID; this.getIDs = getIDs; this.getTypes = getTypes; + this.getUpdated = getUpdated; this.getAll = getAll; this.getAllWithinSearch = getAllWithinSearch; this.getTagItems = getTagItems; this.search = search; - this.add = add; this.rename = rename; - this.remove = remove; + this.reload = reload; + this.erase = erase; this.purge = purge; - this.toArray = toArray; + this.unload = unload; /* * Returns a tag and type for a given tagID */ - function get(tagID) { + function get(tagID, skipCheck) { if (_tagsByID[tagID]) { return _tagsByID[tagID]; } - var sql = 'SELECT tag, tagType FROM tags WHERE tagID=?'; - var result = Zotero.DB.rowQuery(sql, tagID); - - if (!result) { - return false; + if (!skipCheck) { + var sql = 'SELECT COUNT(*) FROM tags WHERE tagID=?'; + var result = Zotero.DB.valueQuery(sql, tagID); + + if (!result) { + return false; + } } - _tagsByID[tagID] = { - tag: result.tag, - type: result.tagType - }; - return result; + _tagsByID[tagID] = new Zotero.Tag(tagID); + return _tagsByID[tagID]; } @@ -72,31 +72,31 @@ Zotero.Tags = new function() { */ function getName(tagID) { if (_tagsByID[tagID]) { - return _tagsByID[tagID].tag; + return _tagsByID[tagID].name; } var tag = this.get(tagID); - return _tagsByID[tagID] ? _tagsByID[tagID].tag : false; + return _tagsByID[tagID] ? _tagsByID[tagID].name : false; } /* * Returns the tagID matching given tag and type */ - function getID(tag, type) { - if (_tags[type] && _tags[type]['_' + tag]) { - return _tags[type]['_' + tag]; + function getID(name, type) { + if (_tags[type] && _tags[type]['_' + name]) { + return _tags[type]['_' + name]; } - var sql = 'SELECT tagID FROM tags WHERE tag=? AND tagType=?'; - var tagID = Zotero.DB.valueQuery(sql, [tag, type]); + var sql = 'SELECT tagID FROM tags WHERE name=? AND type=?'; + var tagID = Zotero.DB.valueQuery(sql, [name, type]); if (tagID) { if (!_tags[type]) { _tags[type] = []; } - _tags[type]['_' + tag] = tagID; + _tags[type]['_' + name] = tagID; } return tagID; @@ -106,30 +106,40 @@ Zotero.Tags = new function() { /* * Returns all tagIDs for this tag (of all types) */ - function getIDs(tag) { - var sql = 'SELECT tagID FROM tags WHERE tag=?'; - return Zotero.DB.columnQuery(sql, [tag]); + function getIDs(name) { + var sql = 'SELECT tagID FROM tags WHERE name=?'; + return Zotero.DB.columnQuery(sql, [name]); } /* - * Returns an array of tagTypes for tags matching given tag + * Returns an array of tag types for tags matching given tag */ - function getTypes(tag) { - var sql = 'SELECT tagType FROM tags WHERE tag=?'; - return Zotero.DB.columnQuery(sql, [tag]); + function getTypes(name) { + var sql = 'SELECT type FROM tags WHERE name=?'; + return Zotero.DB.columnQuery(sql, [name]); + } + + + function getUpdated(date) { + var sql = "SELECT tagID FROM tags"; + if (date) { + sql += " WHERE dateModified>?"; + return Zotero.DB.columnQuery(sql, Zotero.Date.dateToSQL(date, true)); + } + return Zotero.DB.columnQuery(sql); } /** * Get all tags indexed by tagID * - * _types_ is an optional array of tagTypes to fetch + * _types_ is an optional array of tag types to fetch */ function getAll(types) { - var sql = "SELECT tagID, tag, tagType FROM tags "; + var sql = "SELECT tagID, name FROM tags "; if (types) { - sql += "WHERE tagType IN (" + types.join() + ") "; + sql += "WHERE type IN (" + types.join() + ") "; } var tags = Zotero.DB.query(sql); if (!tags) { @@ -138,15 +148,13 @@ Zotero.Tags = new function() { var collation = Zotero.getLocaleCollation(); tags.sort(function(a, b) { - return collation.compareString(1, a.tag, b.tag); + return collation.compareString(1, a.name, b.name); }); var indexed = {}; for (var i=0; i<tags.length; i++) { - indexed[tags[i].tagID] = { - tag: tags[i].tag, - type: tags[i].tagType - }; + var tag = this.get(tags[i].tagID, true); + indexed[tags[i].tagID] = tag; } return indexed; } @@ -155,7 +163,7 @@ Zotero.Tags = new function() { /* * Get all tags within the items of a Zotero.Search object * - * _types_ is an optional array of tagTypes to fetch + * _types_ is an optional array of tag types to fetch */ function getAllWithinSearch(search, types) { // Save search results to temporary table @@ -176,11 +184,11 @@ Zotero.Tags = new function() { return {}; } - var sql = "SELECT DISTINCT tagID, tag, tagType FROM itemTags " + var sql = "SELECT DISTINCT tagID, name, type FROM itemTags " + "NATURAL JOIN tags WHERE itemID IN " + "(SELECT itemID FROM " + tmpTable + ") "; if (types) { - sql += "AND tagType IN (" + types.join() + ") "; + sql += "AND type IN (" + types.join() + ") "; } var tags = Zotero.DB.query(sql); @@ -192,15 +200,13 @@ Zotero.Tags = new function() { var collation = Zotero.getLocaleCollation(); tags.sort(function(a, b) { - return collation.compareString(1, a.tag, b.tag); + return collation.compareString(1, a.name, b.name); }); var indexed = {}; for (var i=0; i<tags.length; i++) { - indexed[tags[i].tagID] = { - tag: tags[i].tag, - type: tags[i].tagType - }; + var tag = this.get(tags[i].tagID, true); + indexed[tags[i].tagID] = tag; } return indexed; } @@ -213,76 +219,49 @@ Zotero.Tags = new function() { function search(str) { - var sql = 'SELECT tagID, tag, tagType FROM tags'; + var sql = 'SELECT tagID, name, type FROM tags'; if (str) { - sql += ' WHERE tag LIKE ?'; + sql += ' WHERE name LIKE ?'; } - sql += ' ORDER BY tag COLLATE NOCASE'; var tags = Zotero.DB.query(sql, str ? '%' + str + '%' : undefined); - var indexed = {}; - for each(var tag in tags) { - indexed[tag.tagID] = { - tag: tag.tag, - type: tag.tagType - }; - } - return indexed; - } - - - /* - * Add a new tag to the database - * - * Returns new tagID - */ - function add(tag, type) { - if (type != 0 && type != 1) { - throw ('Invalid tag type ' + type + ' in Tags.add()'); - } - if (!type) { - type = 0; + if (!tags) { + return {}; } - Zotero.debug('Adding new tag of type ' + type, 4); - - Zotero.DB.beginTransaction(); - - var sql = 'INSERT INTO tags VALUES (?,?,?)'; - var rnd = Zotero.ID.get('tags'); - Zotero.DB.query(sql, [{int: rnd}, {string: tag}, {int: type}]); + var collation = Zotero.getLocaleCollation(); + tags.sort(function(a, b) { + return collation.compareString(1, a.name, b.name); + }); - Zotero.DB.commitTransaction(); - Zotero.Notifier.trigger('add', 'tag', rnd); - return rnd; + var indexed = {}; + for (var i=0; i<tags.length; i++) { + var tag = this.get(tags[i].tagID, true); + indexed[tags[i].tagID] = tag; + } + return indexed; } - function rename(tagID, tag) { + function rename(tagID, name) { Zotero.debug('Renaming tag', 4); Zotero.DB.beginTransaction(); var tagObj = this.get(tagID); - var oldName = tagObj.tag; + var oldName = tagObj.name; var oldType = tagObj.type; var notifierData = {}; - notifierData[this.id] = { old: this.toArray() }; + notifierData[tagID] = { old: tag.serialize() }; - if (oldName == tag) { - // Convert unchanged automatic tags to manual - if (oldType != 0) { - var sql = "UPDATE tags SET tagType=0 WHERE tagID=?"; - Zotero.DB.query(sql, tagID); - Zotero.Notifier.trigger('modify', 'tag', tagID, notifierData); - } + if (oldName == name) { Zotero.DB.commitTransaction(); return; } // Check if the new tag already exists - var sql = "SELECT tagID FROM tags WHERE tag=? AND tagType=0"; - var existingTagID = Zotero.DB.valueQuery(sql, tag); + var sql = "SELECT tagID FROM tags WHERE name=? AND type=0"; + var existingTagID = Zotero.DB.valueQuery(sql, name); if (existingTagID) { var itemIDs = this.getTagItems(tagID); var existingItemIDs = this.getTagItems(existingTagID); @@ -316,54 +295,44 @@ Zotero.Tags = new function() { } } Zotero.Notifier.trigger('add', 'item-tag', itemTags); + // TODO: notify linked items? + //Zotero.Notifier.trigger('modify', 'item', itemIDs); - Zotero.Notifier.trigger('modify', 'item', itemIDs); Zotero.DB.commitTransaction(); return; } - // 0 == user tag -- we set all renamed tags to 0 - var sql = "UPDATE tags SET tag=?, tagType=0 WHERE tagID=?"; - Zotero.DB.query(sql, [{string: tag}, tagID]); - - var itemIDs = this.getTagItems(tagID); - - if (_tags[oldType]) { - delete _tags[oldType]['_' + oldName]; - } - delete _tagsByID[tagID]; + tagObj.name = name; + // Set all renamed tags to manual + tagObj.type = 0; + tagObj.save(); Zotero.DB.commitTransaction(); - - Zotero.Notifier.trigger('modify', 'item', itemIDs); - Zotero.Notifier.trigger('modify', 'tag', tagID, notifierData); } - function remove(tagID) { - Zotero.DB.beginTransaction(); + function reload(ids) { + this.unload(ids); + } + + + function erase(ids) { + ids = Zotero.flattenArguments(ids); - var sql = "SELECT itemID FROM itemTags WHERE tagID=?"; - var itemIDs = Zotero.DB.columnQuery(sql, tagID); + var erasedTags = {}; - if (!itemIDs) { - Zotero.DB.commitTransaction(); - return; + Zotero.DB.beginTransaction(); + for each(var id in ids) { + var tag = this.get(id); + if (tag) { + erasedTags[id] = tag.serialize(); + tag.erase(); + } } - var sql = "DELETE FROM itemTags WHERE tagID=?"; - Zotero.DB.query(sql, tagID); - - Zotero.Notifier.trigger('modify', 'item', itemIDs) - var itemTags = []; - for (var i in itemIDs) { - itemTags.push(itemIDs[i] + '-' + tagID); - } - Zotero.Notifier.trigger('remove', 'item-tag', itemTags); + this.unload(ids); - this.purge(); Zotero.DB.commitTransaction(); - return; } @@ -373,47 +342,72 @@ Zotero.Tags = new function() { * Returns removed tagIDs on success */ function purge() { - Zotero.DB.beginTransaction(); - - var sql = 'SELECT tagID, tag, tagType FROM tags WHERE tagID ' - + 'NOT IN (SELECT tagID FROM itemTags);'; - var toDelete = Zotero.DB.query(sql); - - if (!toDelete) { - Zotero.DB.commitTransaction(); - return false; - } - - var purged = []; - var notifierData = {}; - - // Clear tag entries in internal array - for each(var tag in toDelete) { - notifierData[tag.tagID] = { old: Zotero.Tags.toArray(tag.tagID) } + Zotero.UnresponsiveScriptIndicator.disable(); + try { + Zotero.DB.beginTransaction(); + + var sql = "CREATE TEMPORARY TABLE tagDelete AS " + + "SELECT tagID FROM tags WHERE tagID " + + "NOT IN (SELECT tagID FROM itemTags);"; + Zotero.DB.query(sql); + + sql = "CREATE INDEX tagDelete_tagID ON tagDelete(tagID)"; + Zotero.DB.query(sql); + + sql = "SELECT * FROM tagDelete"; + var toDelete = Zotero.DB.columnQuery(sql); + + if (!toDelete) { + Zotero.DB.rollbackTransaction(); + return; + } - purged.push(tag.tagID); - if (_tags[tag.tagType]) { - delete _tags[tag.tagType]['_' + tag.tag]; + var notifierData = {}; + + for each(var tagID in toDelete) { + var tag = Zotero.Tags.get(tagID); + Zotero.debug(tag); + notifierData[tagID] = { old: tag.serialize() } } - delete _tagsByID[tag.tagID]; + + this.unload(toDelete); + + sql = "DELETE FROM tags WHERE tagID IN " + + "(SELECT tagID FROM tagDelete);"; + Zotero.DB.query(sql); + + sql = "DROP TABLE tagDelete"; + Zotero.DB.query(sql); + + Zotero.DB.commitTransaction(); + + Zotero.Notifier.trigger('delete', 'tag', toDelete, notifierData); + } + catch (e) { + Zotero.DB.rollbackTransaction(); + throw (e); + } + finally { + Zotero.UnresponsiveScriptIndicator.enable(); } - - sql = 'DELETE FROM tags WHERE tagID NOT IN ' - + '(SELECT tagID FROM itemTags);'; - var result = Zotero.DB.query(sql); - - Zotero.DB.commitTransaction(); - - Zotero.Notifier.trigger('delete', 'tag', purged, notifierData); - - return toDelete; } - function toArray(tagID) { - var obj = this.get(tagID); - obj.id = tagID; - return obj; + /** + * Unload tags from caches + * + * @param int|array ids One or more tagIDs + */ + function unload() { + var ids = Zotero.flattenArguments(arguments); + + for each(var id in ids) { + var tag = _tagsByID[id]; + delete _tagsByID[id]; + if (tag && _tags[tag.type]) { + delete _tags[tag.type]['_' + tag.name]; + } + } } } diff --git a/chrome/content/zotero/xpcom/id.js b/chrome/content/zotero/xpcom/id.js @@ -47,6 +47,7 @@ Zotero.ID = new function () { case 'creatorData': case 'collections': case 'savedSearches': + case 'tags': var id = _getNextAvailable(table, skip); if (!id && notNull) { return _getNext(table, skip); @@ -57,7 +58,6 @@ Zotero.ID = new function () { // // TODO: use autoincrement instead where available in 1.5 case 'itemDataValues': - case 'tags': var id = _getNextAvailable(table, skip); if (!id) { // If we can't find an empty id quickly, just use MAX() + 1 diff --git a/chrome/content/zotero/xpcom/schema.js b/chrome/content/zotero/xpcom/schema.js @@ -1447,6 +1447,27 @@ Zotero.Schema = new function(){ } } statement.reset(); + + // Tags + var tags = Zotero.DB.query("SELECT * FROM tags"); + Zotero.DB.query("DROP TABLE tags"); + Zotero.DB.query("CREATE TABLE tags (\n tagID INTEGER PRIMARY KEY,\n name TEXT,\n type INT,\n dateModified DEFAULT CURRENT_TIMESTAMP NOT NULL,\n key TEXT NOT NULL UNIQUE,\n UNIQUE (name, type)\n)"); + var statement = Zotero.DB.getStatement("INSERT INTO tags (tagID, name, type, key) VALUES (?,?,?,?)"); + for (var j=0, len=searches.length; j<len; j++) { + statement.bindInt32Parameter(0, tags[j].tagID); + statement.bindUTF8StringParameter(1, tags[j].tag); + statement.bindInt32Parameter(2, tags[j].tagType); + var key = Zotero.ID.getKey(); + statement.bindStringParameter(3, key); + + try { + statement.execute(); + } + catch (e) { + throw (Zotero.DB.getLastErrorString()); + } + } + statement.reset(); } } diff --git a/chrome/content/zotero/xpcom/search.js b/chrome/content/zotero/xpcom/search.js @@ -1765,7 +1765,7 @@ Zotero.SearchConditions = new function(){ doesNotContain: true }, table: 'itemTags', - field: 'tag' + field: 'name' }, { diff --git a/chrome/content/zotero/xpcom/sync.js b/chrome/content/zotero/xpcom/sync.js @@ -27,7 +27,12 @@ Zotero.Sync = new function() { search: { singular: 'Search', plural: 'Searches' + }, + tag: { + singular: 'Tag', + plural: 'Tags' } + }; }); @@ -1068,6 +1073,8 @@ Zotero.Sync.Server.Data = new function() { this.xmlToCreator = xmlToCreator; this.searchToXML = searchToXML; this.xmlToSearch = xmlToSearch; + this.tagToXML = tagToXML; + this.xmlToTag = xmlToTag; var _noMergeTypes = ['search']; @@ -1208,7 +1215,7 @@ Zotero.Sync.Server.Data = new function() { // Update id in local updates array var index = uploadIDs.updated[types].indexOf(oldID); if (index == -1) { - _error("Local " + type + " " + oldID + " not in " + throw ("Local " + type + " " + oldID + " not in " + "update array when changing id"); } uploadIDs.updated[types][index] = newID; @@ -1256,7 +1263,7 @@ Zotero.Sync.Server.Data = new function() { if (type != 'item') { alert('Delete reconciliation unimplemented for ' + types); - _error('Delete reconciliation unimplemented for ' + types); + throw ('Delete reconciliation unimplemented for ' + types); } var remoteObj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode); @@ -1653,7 +1660,7 @@ Zotero.Sync.Server.Data = new function() { } } else if (skipPrimary) { - _error("Cannot use skipPrimary with existing item in " + throw ("Cannot use skipPrimary with existing item in " + "Zotero.Sync.Server.Data.xmlToItem()"); } @@ -1699,7 +1706,7 @@ Zotero.Sync.Server.Data = new function() { for each(var creator in xmlItem.creator) { var pos = parseInt(creator.@index); if (pos != i) { - _error('No creator in position ' + i); + throw ('No creator in position ' + i); } item.setCreator( @@ -1799,7 +1806,7 @@ Zotero.Sync.Server.Data = new function() { } } else if (skipPrimary) { - _error("Cannot use skipPrimary with existing collection in " + throw ("Cannot use skipPrimary with existing collection in " + "Zotero.Sync.Server.Data.xmlToCollection()"); } @@ -1877,7 +1884,7 @@ Zotero.Sync.Server.Data = new function() { } } else if (skipPrimary) { - _error("Cannot use skipPrimary with existing creator in " + throw ("Cannot use skipPrimary with existing creator in " + "Zotero.Sync.Server.Data.xmlToCreator()"); } @@ -1960,7 +1967,7 @@ Zotero.Sync.Server.Data = new function() { } } else if (skipPrimary) { - _error("Cannot use new id with existing search in " + throw ("Cannot use new id with existing search in " + "Zotero.Sync.Server.Data.xmlToSearch()"); } @@ -2010,4 +2017,63 @@ Zotero.Sync.Server.Data = new function() { return search; } + + + function tagToXML(tag) { + var xml = <tag/>; + + xml.@id = tag.id; + xml.@name = tag.name; + if (tag.type) { + xml.@type = tag.type; + } + xml.@dateModified = tag.dateModified; + xml.@key = tag.key; + var linkedItems = tag.getLinkedItems(true); + if (linkedItems) { + xml.items = linkedItems.join(' '); + } + return xml; + } + + + /** + * Convert E4X <tag> object into an unsaved Zotero.Tag + * + * @param object xmlTag E4X XML node with tag data + * @param object tag (Optional) Existing Zotero.Tag to update + * @param bool skipPrimary (Optional) Ignore passed primary fields + */ + function xmlToTag(xmlTag, tag, skipPrimary) { + if (!tag) { + if (skipPrimary) { + tag = new Zotero.Tag; + } + else { + tag = new Zotero.Tag(parseInt(xmlTag.@id)); + /* + if (tag.exists()) { + throw ("Tag specified in XML node already exists " + + "in Zotero.Sync.Server.Data.xmlToTag()"); + } + */ + } + } + else if (skipPrimary) { + throw ("Cannot use new id with existing tag in " + + "Zotero.Sync.Server.Data.xmlToTag()"); + } + + tag.name = xmlTag.@name.toString(); + tag.type = parseInt(xmlTag.@type); + if (!skipPrimary) { + tag.dateModified = xmlTag.@dateModified.toString(); + tag.key = xmlTag.@key.toString(); + } + + var str = xmlTag.items ? xmlTag.items.toString() : false; + tag.linkedItems = str ? str.split(' ') : []; + + return tag; + } } diff --git a/components/zotero-autocomplete.js b/components/zotero-autocomplete.js @@ -134,15 +134,21 @@ ZoteroAutoComplete.prototype.startSearch = function(searchString, searchParam, break; case 'tag': - var sql = "SELECT tag FROM tags WHERE tag LIKE ?"; + var sql = "SELECT name FROM tags WHERE name LIKE ?"; var sqlParams = [searchString + '%']; if (extra){ sql += " AND tagID NOT IN (SELECT tagID FROM itemTags WHERE " + "itemID = ?)"; sqlParams.push(extra); } - sql += " ORDER BY tag"; var results = this._zotero.DB.columnQuery(sql, sqlParams); + if (results) { + var collation = Zotero.getLocaleCollation(); + results.sort(function(a, b) { + return collation.compareString(1, a, b); + }); + } + break; case 'creator': diff --git a/components/zotero-service.js b/components/zotero-service.js @@ -14,13 +14,14 @@ var ZoteroWrapped = this; * Include the core objects to be stored within XPCOM *********************************************************************/ -var xpcomFiles = [ 'zotero', +var xpcomFiles = ['zotero', 'annotate', 'attachments', 'cite', 'cite_compat', 'collectionTreeView', - 'dataServer', 'data_access', 'data/item', 'data/items', 'data/collection', 'data/collections', - 'data/cachedTypes', 'data/creator', 'data/creators', 'data/itemFields', - 'data/notes', 'data/tags', 'db', 'file', 'fulltext', 'id', 'ingester', 'integration', - 'itemTreeView', 'mime', 'notifier', 'progressWindow', 'quickCopy', 'report', - 'schema', 'search', 'sync', 'timeline', 'translate', 'utilities', 'zeroconf']; + 'dataServer', 'data_access', 'data/item', 'data/items', 'data/collection', + 'data/collections', 'data/cachedTypes', 'data/creator', 'data/creators', + 'data/itemFields', 'data/notes', 'data/tag', 'data/tags', 'db', 'file', + 'fulltext', 'id', 'ingester', 'integration', 'itemTreeView', 'mime', + 'notifier', 'progressWindow', 'quickCopy', 'report', 'schema', 'search', + 'sync', 'timeline', 'translate', 'utilities', 'zeroconf']; for (var i=0; i<xpcomFiles.length; i++) { Cc["@mozilla.org/moz/jssubscript-loader;1"] diff --git a/system.sql b/system.sql @@ -1249,3 +1249,4 @@ INSERT INTO "syncObjectTypes" VALUES(1, 'collection'); INSERT INTO "syncObjectTypes" VALUES(2, 'creator'); INSERT INTO "syncObjectTypes" VALUES(3, 'item'); INSERT INTO "syncObjectTypes" VALUES(4, 'search'); +INSERT INTO "syncObjectTypes" VALUES(5, 'tag'); diff --git a/userdata.sql b/userdata.sql @@ -71,9 +71,11 @@ CREATE INDEX itemAttachments_mimeType ON itemAttachments(mimeType); -- Individual entries for each tag CREATE TABLE tags ( tagID INTEGER PRIMARY KEY, - tag TEXT, - tagType INT, - UNIQUE (tag, tagType) + name TEXT, + type INT, + dateModified DEFAULT CURRENT_TIMESTAMP NOT NULL, + key TEXT NOT NULL UNIQUE, + UNIQUE (name, type) ); -- Associates items with keywords