From 889c0a3e061037da103dc7b8161a65ea8c4c823a Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Mon, 2 Jun 2008 09:15:43 +0000 Subject: [PATCH] - Saved search syncing, with automatic latest-wins conflict resolution - Last sync time displayed in sync button tooltip - Various and sundry bug fixes DB must be re-upgraded from 1.0 --- chrome/content/zotero/fileInterface.js | 10 +- chrome/content/zotero/overlay.js | 3 +- chrome/content/zotero/overlay.xul | 13 +- .../zotero/xpcom/collectionTreeView.js | 10 +- .../content/zotero/xpcom/data/collection.js | 9 +- chrome/content/zotero/xpcom/id.js | 5 + chrome/content/zotero/xpcom/search.js | 459 +++++++++++++----- chrome/content/zotero/xpcom/sync.js | 295 ++++++++--- chrome/skin/default/zotero/overlay.css | 5 + system.sql | 4 +- triggers.sql | 28 +- 11 files changed, 611 insertions(+), 230 deletions(-) diff --git a/chrome/content/zotero/fileInterface.js b/chrome/content/zotero/fileInterface.js index b0edc4d86..864077686 100644 --- a/chrome/content/zotero/fileInterface.js +++ b/chrome/content/zotero/fileInterface.js @@ -145,9 +145,8 @@ var Zotero_File_Interface = new function() { // find name var searchRef = ZoteroPane.getSelectedSavedSearch(); if(searchRef) { - var search = new Zotero.Search(); - search.load(searchRef['id']); - exporter.name = search.getName(); + var search = new Zotero.Search(searchRef.id); + exporter.name = search.name; } } exporter.save(); @@ -285,9 +284,8 @@ var Zotero_File_Interface = new function() { } else { var searchRef = ZoteroPane.getSelectedSavedSearch(); if(searchRef) { - var search = new Zotero.Search(); - search.load(searchRef['id']); - name = search.getName(); + var search = new Zotero.Search(searchRef.id); + name = search.name; } } diff --git a/chrome/content/zotero/overlay.js b/chrome/content/zotero/overlay.js index 173458274..9184bca61 100644 --- a/chrome/content/zotero/overlay.js +++ b/chrome/content/zotero/overlay.js @@ -1133,8 +1133,7 @@ var ZoteroPane = new function() } } else { - var s = new Zotero.Search(); - s.load(row.ref.id); + var s = new Zotero.Search(row.ref.id); var io = {dataIn: {search: s, name: row.getName()}, dataOut: null}; window.openDialog('chrome://zotero/content/searchDialog.xul','','chrome,modal',io); if (io.dataOut) { diff --git a/chrome/content/zotero/overlay.xul b/chrome/content/zotero/overlay.xul index da22686af..42dd8c450 100644 --- a/chrome/content/zotero/overlay.xul +++ b/chrome/content/zotero/overlay.xul @@ -302,7 +302,18 @@ - + + + + diff --git a/chrome/content/zotero/xpcom/collectionTreeView.js b/chrome/content/zotero/xpcom/collectionTreeView.js index 3dbe1298d..0deb40b3a 100644 --- a/chrome/content/zotero/xpcom/collectionTreeView.js +++ b/chrome/content/zotero/xpcom/collectionTreeView.js @@ -244,9 +244,8 @@ Zotero.CollectionTreeView.prototype.notify = function(action, type, ids) break; case 'search': - var search = Zotero.Searches.get(ids); this.reload(); - this.selection.select(this._searchRowMap[search.id]); + this.selection.select(this._searchRowMap[ids]); break; } } @@ -930,7 +929,7 @@ Zotero.ItemGroup.prototype.getSearchObject = function() { var includeScopeChildren = false; // Create/load the inner search - var s = new Zotero.Search(); + var s = new Zotero.Search(this.isSearch() ? this.ref.id : null); if (this.isLibrary()) { s.addCondition('noChildren', 'true'); includeScopeChildren = true; @@ -943,10 +942,7 @@ Zotero.ItemGroup.prototype.getSearchObject = function() { } includeScopeChildren = true; } - else if (this.isSearch()){ - s.load(this.ref['id']); - } - else { + else if (!this.isSearch()) { throw ('Invalid search mode in Zotero.ItemGroup.getSearchObject()'); } diff --git a/chrome/content/zotero/xpcom/data/collection.js b/chrome/content/zotero/xpcom/data/collection.js index 8daf85e93..9387b78c2 100644 --- a/chrome/content/zotero/xpcom/data/collection.js +++ b/chrome/content/zotero/xpcom/data/collection.js @@ -26,13 +26,17 @@ Zotero.Collection = function(collectionID) { this._init(); } -Zotero.Collection.prototype._init = function (collectionID) { +Zotero.Collection.prototype._init = function () { // Public members for access by public methods -- do not access directly this._name = null; this._parent = null; this._dateModified = null; this._key = null; + this._loaded = false; + this._changed = false; + this._previousData = false; + this._hasChildCollections = false; this._childCollections = []; this._childCollectionsLoaded = false; @@ -40,8 +44,6 @@ Zotero.Collection.prototype._init = function (collectionID) { this._hasChildItems = false; this._childItems = []; this._childItemsLoaded = false; - - this._previousData = false; } @@ -122,7 +124,6 @@ Zotero.Collection.prototype.load = function() { + "(SELECT COUNT(*) FROM collectionItems WHERE " + "collectionID=C.collectionID)!=0 AS hasChildItems " + "FROM collections C WHERE collectionID=?"; - var data = Zotero.DB.rowQuery(sql, this.id); this._init(); diff --git a/chrome/content/zotero/xpcom/id.js b/chrome/content/zotero/xpcom/id.js index 8744fa19b..f683a58aa 100644 --- a/chrome/content/zotero/xpcom/id.js +++ b/chrome/content/zotero/xpcom/id.js @@ -32,6 +32,11 @@ Zotero.ID = new function () { * Gets an unused primary key id for a DB table */ function get(table, notNull, skip) { + // Used in sync.js + if (table == 'searches') { + table = 'savedSearches'; + } + switch (table) { // Autoincrement tables // diff --git a/chrome/content/zotero/xpcom/search.js b/chrome/content/zotero/xpcom/search.js index 64c4c7f52..19cb0a50f 100644 --- a/chrome/content/zotero/xpcom/search.js +++ b/chrome/content/zotero/xpcom/search.js @@ -20,62 +20,149 @@ ***** END LICENSE BLOCK ***** */ -Zotero.Search = function(savedSearchID){ +Zotero.Search = function(searchID) { + this._id = searchID ? searchID : null; + this._init(); +} + + +Zotero.Search.prototype._init = function () { + // Public members for access by public methods -- do not access directly + this._name = null; + this._dateModified = null; + this._key = null; + + this._loaded = false; + this._changed = false; + this._previousData = false; + this._scope = null; this._scopeIncludeChildren = null; this._sql = null; this._sqlParams = null; this._maxSearchConditionID = 0; this._conditions = []; - this._savedSearchID = null; - this._savedSearchName = null; this._hasPrimaryConditions = false; +} + + + +Zotero.Search.prototype.getID = function(){ + Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.id'); + return this._id; +} + +Zotero.Search.prototype.getName = function() { + Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.name'); + return this.name; +} + +Zotero.Search.prototype.setName = function(val) { + Zotero.debug('Zotero.Search.setName() is deprecated -- use Search.name'); + this.name = val; +} + + +Zotero.Search.prototype.__defineGetter__('id', function () { return this._id; }); + +Zotero.Search.prototype.__defineSetter__('id', function (val) { this._set('id', val); }); +Zotero.Search.prototype.__defineSetter__('searchID', function (val) { this._set('id', val); }); +Zotero.Search.prototype.__defineGetter__('name', function () { return this._get('name'); }); +Zotero.Search.prototype.__defineSetter__('name', function (val) { this._set('name', val); }); +Zotero.Search.prototype.__defineGetter__('dateModified', function () { return this._get('dateModified'); }); +Zotero.Search.prototype.__defineSetter__('dateModified', function (val) { this._set('dateModified', val); }); +Zotero.Search.prototype.__defineGetter__('key', function () { return this._get('key'); }); +Zotero.Search.prototype.__defineSetter__('key', function (val) { this._set('key', val); }); + +Zotero.Search.prototype.__defineGetter__('conditions', function (arr) { this.getSearchConditions(); }); + + +Zotero.Search.prototype._get = function (field) { + if (this.id && !this._loaded) { + this.load(); + } + return this['_' + field]; +} + + +Zotero.Search.prototype._set = function (field, val) { + switch (field) { + //case 'id': // set using constructor + case 'searchID': + throw ("Invalid field '" + field + "' in Zotero.Search.set()"); + } - if (savedSearchID) { - this.load(savedSearchID); + 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; + } } } -/* - * Set the name for the saved search +/** + * Check if saved search exists in the database * - * Must be called before save() for new searches + * @return bool TRUE if the search exists, FALSE if not */ -Zotero.Search.prototype.setName = function(name){ - if (!name){ - throw("Invalid saved search name '" + name + '"'); +Zotero.Search.prototype.exists = function() { + if (!this.id) { + throw ('searchID not set in Zotero.Search.exists()'); } - this._savedSearchName = name; + var sql = "SELECT COUNT(*) FROM savedSearches WHERE savedSearchID=?"; + return !!Zotero.DB.valueQuery(sql, this.id); } /* * Load a saved search from the DB */ -Zotero.Search.prototype.load = function(savedSearchID){ - var sql = "SELECT savedSearchName, MAX(searchConditionID) AS maxID " - + "FROM savedSearches LEFT JOIN savedSearchConditions " - + "USING (savedSearchID) WHERE savedSearchID=" + savedSearchID - + " GROUP BY savedSearchID"; - var row = Zotero.DB.rowQuery(sql); - - if (!row){ - throw('Saved search ' + savedSearchID + ' does not exist'); +Zotero.Search.prototype.load = function() { + // Changed in 1.5 + if (arguments[0]) { + throw ('Parameter no longer allowed in Zotero.Search.load()'); } - this._sql = null; - this._sqlParams = null; - this._maxSearchConditionID = row['maxID']; - this._conditions = []; - this._savedSearchID = savedSearchID; - this._savedSearchName = row['savedSearchName']; + var sql = "SELECT S.*, " + + "MAX(searchConditionID) AS maxID " + + "FROM savedSearches S LEFT JOIN savedSearchConditions " + + "USING (savedSearchID) WHERE savedSearchID=? " + + "GROUP BY savedSearchID"; + var data = Zotero.DB.rowQuery(sql, this.id); - var conditions = Zotero.DB.query("SELECT * FROM savedSearchConditions " - + "WHERE savedSearchID=" + savedSearchID + " ORDER BY searchConditionID"); + this._init(); + this._loaded = true; - for (var i in conditions){ + if (!data) { + return; + } + + this._changed = false; + this._previousData = false; + this._id = data.savedSearchID; + this._name = data.savedSearchName; + this._dateModified = data.dateModified; + this._key = data.key; + this._maxSearchConditionID = data.maxID; + + var sql = "SELECT * FROM savedSearchConditions " + + "WHERE savedSearchID=? ORDER BY searchConditionID"; + var conditions = Zotero.DB.query(sql, this.id); + + for (var i in conditions) { // Parse "condition[/mode]" var [condition, mode] = Zotero.SearchConditions.parseCondition(conditions[i]['condition']); @@ -98,21 +185,6 @@ Zotero.Search.prototype.load = function(savedSearchID){ } -Zotero.Search.prototype.getID = function(){ - Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.id'); - return this._savedSearchID; -} - -Zotero.Search.prototype.__defineGetter__('id', function () { return this._savedSearchID; }); - - -Zotero.Search.prototype.getName = function() { - Zotero.debug('Zotero.Search.getName() is deprecated -- use Search.name'); - return this._savedSearchName; -} - -Zotero.Search.prototype.__defineGetter__('name', function () { return this._savedSearchName; }); - /* * Save the search to the DB and return a savedSearchID * @@ -120,75 +192,134 @@ Zotero.Search.prototype.__defineGetter__('name', function () { return this._save * and the caller must dispose of the search or reload the condition ids, * which may change after the save. * - * For new searches, setName() must be called before saving + * For new searches, name must be set called before saving */ Zotero.Search.prototype.save = function(fixGaps) { - if (!this._savedSearchName){ + if (!this.name) { throw('Name not provided for saved search'); } Zotero.DB.beginTransaction(); - if (this._savedSearchID){ - var sql = "UPDATE savedSearches SET savedSearchName=? WHERE savedSearchID=?"; - Zotero.DB.query(sql, [this._savedSearchName, this._savedSearchID]); + // ID change + if (this._changed.id) { + var oldID = this._previousData.primary.id; + var params = [this.id, oldID]; - Zotero.DB.query("DELETE FROM savedSearchConditions " - + "WHERE savedSearchID=" + this._savedSearchID); + Zotero.debug("Changing search id " + oldID + " to " + this.id); + + var row = Zotero.DB.rowQuery("SELECT * FROM savedSearches WHERE savedSearchID=?", 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 savedSearches VALUES (?, ?, ?, ?)", + [this.id, row.savedSearchName, row.dateModified, 'TEMPKEY']); + + Zotero.DB.query("UPDATE savedSearchConditions SET savedSearchID=? WHERE savedSearchID=?", params); + + Zotero.DB.query("DELETE FROM savedSearches WHERE savedSearchID=?", oldID); + Zotero.DB.query("UPDATE savedSearches SET key=? WHERE savedSearchID=?", [row.key, this.id]); + + //Zotero.Searches.unload(oldID); + Zotero.Notifier.trigger('id-change', 'search', oldID + '-' + this.id); + + // update caches + } + + var isNew = !this.id || !this.exists(); + + try { + var searchID = this.id ? this.id : Zotero.ID.get('savedSearches'); + + Zotero.debug("Saving " + (isNew ? 'new ' : '') + "search " + this.id); + + var key = this.key ? this.key : this._generateKey(); + + var columns = [ + 'savedSearchID', 'savedSearchName', 'dateModified', 'key' + ]; + var placeholders = ['?', '?', '?', '?']; + var sqlValues = [ + searchID ? { int: searchID } : null, + { string: this.name }, + // If date modified hasn't changed, use current timestamp + this._changed.dateModified ? + this.dateModified : Zotero.DB.transactionDateTime, + key + ]; + + var sql = "REPLACE INTO savedSearches (" + columns.join(', ') + ") VALUES (" + + placeholders.join(', ') + ")"; + var insertID = Zotero.DB.query(sql, sqlValues); + if (!searchID) { + searchID = insertID; + } + + if (!isNew) { + var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?"; + Zotero.DB.query(sql, this.id); + } + + // Close gaps in savedSearchIDs + var saveConditions = {}; + var i = 1; + for (var id in this._conditions) { + if (!fixGaps && id != i) { + Zotero.DB.rollbackTransaction(); + throw ('searchConditionIDs not contiguous and |fixGaps| not set in save() of saved search ' + this._id); + } + saveConditions[i] = this._conditions[id]; + i++; + } + + this._conditions = saveConditions; + + // TODO: use proper bound parameters once DB class is updated + for (var i in this._conditions){ + var sql = "INSERT INTO savedSearchConditions (savedSearchID, " + + "searchConditionID, condition, operator, value, required) " + + "VALUES (?,?,?,?,?,?)"; + + // Convert condition and mode to "condition[/mode]" + var condition = this._conditions[i].mode ? + this._conditions[i].condition + '/' + this._conditions[i].mode : + this._conditions[i].condition + + var sqlParams = [ + searchID, i, condition, + this._conditions[i].operator + ? this._conditions[i].operator : null, + this._conditions[i].value + ? this._conditions[i].value : null, + this._conditions[i].required + ? 1 : null + ]; + Zotero.DB.query(sql, sqlParams); + } + + Zotero.DB.commitTransaction(); + } + catch (e) { + Zotero.DB.rollbackTransaction(); + throw (e); + } + + // If successful, set values in object + if (!this.id) { + this._id = searchID; + } + + if (!this.key) { + this._key = key; + } + + if (isNew) { + Zotero.Notifier.trigger('add', 'search', this.id); } else { - var isNew = true; - - this._savedSearchID = Zotero.ID.get('savedSearches'); - - var sql = "INSERT INTO savedSearches (savedSearchID, savedSearchName) " - + "VALUES (?,?)"; - Zotero.DB.query(sql, - [this._savedSearchID, {string: this._savedSearchName}]); + Zotero.Notifier.trigger('modify', 'search', this.id, this._previousData); } - // Close gaps in savedSearchIDs - var saveConditions = {}; - var i = 1; - for (var id in this._conditions) { - if (!fixGaps && id != i) { - Zotero.DB.rollbackTransaction(); - throw ('searchConditionIDs not contiguous and |fixGaps| not set in save() of saved search ' + this._savedSearchID); - } - saveConditions[i] = this._conditions[id]; - i++; - } - - this._conditions = saveConditions; - - // TODO: use proper bound parameters once DB class is updated - for (var i in this._conditions){ - var sql = "INSERT INTO savedSearchConditions (savedSearchID, " - + "searchConditionID, condition, operator, value, required) " - + "VALUES (?,?,?,?,?,?)"; - - // Convert condition and mode to "condition[/mode]" - var condition = this._conditions[i]['mode'] ? - this._conditions[i]['condition'] + '/' + this._conditions[i]['mode'] : - this._conditions[i]['condition'] - - var sqlParams = [ - this._savedSearchID, i, condition, - this._conditions[i]['operator'] - ? this._conditions[i]['operator'] : null, - this._conditions[i]['value'] - ? this._conditions[i]['value'] : null, - this._conditions[i]['required'] - ? 1 : null - ]; - Zotero.DB.query(sql, sqlParams); - } - - Zotero.DB.commitTransaction(); - Zotero.Notifier.trigger( - (isNew ? 'add' : 'modify'), 'search', this._savedSearchID - ); - return this._savedSearchID; + return this._id; } @@ -198,9 +329,9 @@ Zotero.Search.prototype.clone = function() { var conditions = this.getSearchConditions(); for each(var condition in conditions) { - var name = condition['mode'] ? - condition['condition'] + '/' + condition['mode'] : - condition['condition'] + var name = condition.mode ? + condition.condition + '/' + condition.mode : + condition.condition s.addCondition(name, condition.operator, condition.value, condition.required); @@ -210,7 +341,11 @@ Zotero.Search.prototype.clone = function() { } -Zotero.Search.prototype.addCondition = function(condition, operator, value, required){ +Zotero.Search.prototype.addCondition = function(condition, operator, value, required) { + if (this.id && !this._loaded) { + this.load(); + } + if (!Zotero.SearchConditions.hasOperator(condition, operator)){ throw ("Invalid operator '" + operator + "' for condition " + condition); } @@ -271,6 +406,10 @@ Zotero.Search.prototype.setScope = function (searchObj, includeChildren) { Zotero.Search.prototype.updateCondition = function(searchConditionID, condition, operator, value, required){ + if (this.id && !this._loaded) { + this.load(); + } + if (typeof this._conditions[searchConditionID] == 'undefined'){ throw ('Invalid searchConditionID ' + searchConditionID + ' in updateCondition()'); } @@ -282,7 +421,7 @@ Zotero.Search.prototype.updateCondition = function(searchConditionID, condition, var [condition, mode] = Zotero.SearchConditions.parseCondition(condition); this._conditions[searchConditionID] = { - id: searchConditionID, + id: parseInt(searchConditionID), condition: condition, mode: mode, operator: operator, @@ -296,6 +435,10 @@ Zotero.Search.prototype.updateCondition = function(searchConditionID, condition, Zotero.Search.prototype.removeCondition = function(searchConditionID){ + if (this.id && !this._loaded) { + this.load(); + } + if (typeof this._conditions[searchConditionID] == 'undefined'){ throw ('Invalid searchConditionID ' + searchConditionID + ' in removeCondition()'); } @@ -309,6 +452,9 @@ Zotero.Search.prototype.removeCondition = function(searchConditionID){ * for the given searchConditionID */ Zotero.Search.prototype.getSearchCondition = function(searchConditionID){ + if (this.id && !this._loaded) { + this.load(); + } return this._conditions[searchConditionID]; } @@ -318,6 +464,9 @@ Zotero.Search.prototype.getSearchCondition = function(searchConditionID){ * used in the search, indexed by searchConditionID */ Zotero.Search.prototype.getSearchConditions = function(){ + if (this.id && !this._loaded) { + this.load(); + } var conditions = []; var i = 1; for each(var condition in this._conditions) { @@ -336,6 +485,9 @@ Zotero.Search.prototype.getSearchConditions = function(){ Zotero.Search.prototype.hasPostSearchFilter = function() { + if (this.id && !this._loaded) { + this.load(); + } for each(var i in this._conditions){ if (i.condition == 'fulltextContent'){ return true; @@ -349,6 +501,10 @@ Zotero.Search.prototype.hasPostSearchFilter = function() { * Run the search and return an array of item ids for results */ Zotero.Search.prototype.search = function(asTempTable){ + if (this.id && !this._loaded) { + this.load(); + } + if (!this._sql){ this._buildQuery(); } @@ -651,6 +807,20 @@ Zotero.Search.prototype.search = function(asTempTable){ } +Zotero.Search.prototype.serialize = function() { + var obj = { + primary: { + id: this.id, + dateModified: this.dateModified, + key: this.key + }, + name: this.name, + conditions: this.getSearchConditions() + }; + return obj; +} + + /* * Get the SQL string for the search */ @@ -670,6 +840,20 @@ Zotero.Search.prototype.getSQLParams = function(){ } +Zotero.Search.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 search exists + if (this.id && this.exists() && !this._previousData) { + this._previousData = this.serialize(); + } +} + + /* * Batch insert */ @@ -895,8 +1079,7 @@ Zotero.Search.prototype._buildQuery = function(){ condSQL += "NOT "; } condSQL += "IN ("; - var search = new Zotero.Search(); - search.load(condition['value']); + var search = new Zotero.Search(condition.value); // Check if there are any post-search filters var hasFilter = search.hasPostSearchFilter(); @@ -1292,16 +1475,32 @@ Zotero.Search.prototype._buildQuery = function(){ } +Zotero.Search.prototype._generateKey = function () { + return Zotero.ID.getKey(); +} + + + Zotero.Searches = new function(){ this.get = get; this.getAll = getAll; + this.getUpdated = getUpdated; this.erase = erase; - function get(id){ - var sql = "SELECT savedSearchID AS id, savedSearchName AS name " - + "FROM savedSearches WHERE savedSearchID=?"; - return Zotero.DB.rowQuery(sql, [id]); + /** + * Retrieve a saved search + * + * @param int id savedSearchID + * @return object|bool Zotero.Search object, + * or false if it doesn't exist + */ + function get(id) { + var sql = "SELECT COUNT(*) FROM savedSearches WHERE savedSearchID=?"; + if (Zotero.DB.valueQuery(sql, id)) { + return new Zotero.Search(id); + } + return false; } @@ -1315,21 +1514,37 @@ Zotero.Searches = new function(){ } + function getUpdated(date) { + var sql = "SELECT savedSearchID FROM savedSearches"; + if (date) { + sql += " WHERE dateModified>?"; + return Zotero.DB.columnQuery(sql, Zotero.Date.dateToSQL(date, true)); + } + return Zotero.DB.columnQuery(sql); + } + + /* * Delete a given saved search from the DB */ - function erase(savedSearchID){ - Zotero.DB.beginTransaction(); - var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=" - + savedSearchID; - Zotero.DB.query(sql); + function erase(ids) { + ids = Zotero.flattenArguments(ids); + var notifierData = {}; - var sql = "DELETE FROM savedSearches WHERE savedSearchID=" - + savedSearchID; - Zotero.DB.query(sql); + Zotero.DB.beginTransaction(); + for each(var id in ids) { + var search = new Zotero.Search(id); + notifierData[id] = { old: search.serialize() }; + + var sql = "DELETE FROM savedSearchConditions WHERE savedSearchID=?"; + Zotero.DB.query(sql, id); + + var sql = "DELETE FROM savedSearches WHERE savedSearchID=?"; + Zotero.DB.query(sql, id); + } Zotero.DB.commitTransaction(); - Zotero.Notifier.trigger('delete', 'search', savedSearchID); + Zotero.Notifier.trigger('delete', 'search', ids, notifierData); } } diff --git a/chrome/content/zotero/xpcom/sync.js b/chrome/content/zotero/xpcom/sync.js index dc06ef0ad..23bb95a69 100644 --- a/chrome/content/zotero/xpcom/sync.js +++ b/chrome/content/zotero/xpcom/sync.js @@ -9,8 +9,26 @@ Zotero.Sync = new function() { this.purgeDeletedObjects = purgeDeletedObjects; this.removeFromDeleted = removeFromDeleted; + // Keep in sync with syncObjectTypes table this.__defineGetter__('syncObjects', function () { - return ['Creator', 'Item', 'Collection']; + return { + creator: { + singular: 'Creator', + plural: 'Creators' + }, + item: { + singular: 'Item', + plural: 'Items' + }, + collection: { + singular: 'Collection', + plural: 'Collections' + }, + search: { + singular: 'Search', + plural: 'Searches' + } + }; }); default xml namespace = ''; @@ -47,7 +65,7 @@ Zotero.Sync = new function() { } - function getObjectTypeName(typeID) { + function getObjectTypeName(typeID, plural) { if (!_typesLoaded) { _loadObjectTypes(); } @@ -64,10 +82,8 @@ Zotero.Sync = new function() { uploadIDs.changed = {}; uploadIDs.deleted = {}; - for each(var Type in Zotero.Sync.syncObjects) { - var Types = Type + 's'; // 'Items' - var type = Type.toLowerCase(); // 'item' - var types = type + 's'; // 'items' + for each(var syncObject in Zotero.Sync.syncObjects) { + var types = syncObject.plural.toLowerCase(); // 'items' uploadIDs.updated[types] = []; uploadIDs.changed[types] = {}; @@ -89,10 +105,9 @@ Zotero.Sync = new function() { } var updatedIDs = {}; - for each(var Type in this.syncObjects) { - var Types = Type + 's'; // 'Items' - var type = Type.toLowerCase(); // 'item' - var types = type + 's'; // 'items' + for each(var syncObject in this.syncObjects) { + var Types = syncObject.plural; // 'Items' + var types = syncObject.plural.toLowerCase(); // 'items' Zotero.debug("Getting updated local " + types); @@ -156,12 +171,14 @@ Zotero.Sync = new function() { } var deletedIDs = {}; - for each(var Type in this.syncObjects) { - deletedIDs[Type.toLowerCase() + 's'] = []; + for each(var syncObject in this.syncObjects) { + deletedIDs[syncObject.plural.toLowerCase()] = []; } for each(var row in rows) { - deletedIDs[this.getObjectTypeName(row.syncObjectTypeID) + 's'].push({ + var type = this.getObjectTypeName(row.syncObjectTypeID); + type = this.syncObjects[type].plural.toLowerCase() + deletedIDs[type].push({ id: row.objectID, key: row.key }); @@ -239,8 +256,7 @@ Zotero.Sync.EventListener = new function () { * Blacklist objects from going into the sync delete log */ function ignoreDeletions(type, ids) { - var cap = type[0].toUpperCase() + type.substr(1); - if (Zotero.Sync.syncObjects.indexOf(cap) == -1) { + if (!Zotero.Sync.syncObjects[type]) { throw ("Invalid type '" + type + "' in Zotero.Sync.EventListener.ignoreDeletions()"); } @@ -260,8 +276,7 @@ Zotero.Sync.EventListener = new function () { * Remove objects blacklisted from the sync delete log */ function unignoreDeletions(type, ids) { - var cap = type[0].toUpperCase() + type.substr(1); - if (Zotero.Sync.syncObjects.indexOf(cap) == -1) { + if (!Zotero.Sync.syncObjects[type]) { throw ("Invalid type '" + type + "' in Zotero.Sync.EventListener.ignoreDeletions()"); } @@ -521,9 +536,7 @@ Zotero.Sync.Server = new function () { } if (_syncInProgress) { - Zotero.log("Sync operation already in progress", 'error'); - return; - + _error("Sync operation already in progress"); } _syncInProgress = true; @@ -990,8 +1003,14 @@ Zotero.Sync.Server = new function () { function _error(e) { + _syncInProgress = false; _resetAttempts(); Zotero.DB.rollbackAllTransactions(); + + if (_sessionID && _sessionLock) { + Zotero.Sync.Server.unlock() + } + throw(e); } } @@ -1047,6 +1066,10 @@ Zotero.Sync.Server.Data = new function() { this.xmlToCollection = xmlToCollection; this.creatorToXML = creatorToXML; this.xmlToCreator = xmlToCreator; + this.searchToXML = searchToXML; + this.xmlToSearch = xmlToSearch; + + var _noMergeTypes = ['search']; default xml namespace = ''; @@ -1061,10 +1084,11 @@ Zotero.Sync.Server.Data = new function() { Zotero.DB.beginTransaction(); - for each(var Type in Zotero.Sync.syncObjects) { - var Types = Type + 's'; // 'Items' + for each(var syncObject in Zotero.Sync.syncObjects) { + var Type = syncObject.singular; // 'Item' + var Types = syncObject.plural; // 'Items' var type = Type.toLowerCase(); // 'item' - var types = type + 's'; // 'items' + var types = Types.toLowerCase(); // 'items' if (!xml[types]) { continue; @@ -1092,48 +1116,61 @@ Zotero.Sync.Server.Data = new function() { // Local object has been modified since last sync if ((objDate > lastLocalSyncDate && - objDate < Zotero.Sync.Server.nextLocalSyncDate) - // Check for object in updated array, since it might - // have been modified during sync process, making its - // date equal to Zotero.Sync.Server.nextLocalSyncDate - // and therefore excluded above (example: an item - // linked to a creator whose id changed) - || uploadIDs.updated[types].indexOf(obj.id) != -1) { + objDate < Zotero.Sync.Server.nextLocalSyncDate) + // Check for object in updated array, since it might + // have been modified during sync process, making its + // date equal to Zotero.Sync.Server.nextLocalSyncDate + // and therefore excluded above (example: an item + // linked to a creator whose id changed) + || uploadIDs.updated[types].indexOf(obj.id) != -1) { var remoteObj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode); - /* - // For now, show item conflicts even if only - // dateModified changed, since we need to handle - // creator conflicts there - if (type != 'item') { - // Skip if only dateModified changed - var diff = obj.diff(remoteObj, false, true); - if (!diff) { + // Some types we don't bother to reconcile + if (_noMergeTypes.indexOf(type) != -1) { + if (obj.dateModified > remoteObj.dateModified) { + Zotero.Sync.addToUpdated(uploadIDs.updated.items, obj.id); continue; } + else { + obj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode, obj); + } } - */ - - // Will be handled by item CR for now - if (type == 'creator') { - remoteCreatorStore[remoteObj.id] = remoteObj; + // Mark other types for conflict resolution + else { + /* + // For now, show item conflicts even if only + // dateModified changed, since we need to handle + // creator conflicts there + if (type != 'item') { + // Skip if only dateModified changed + var diff = obj.diff(remoteObj, false, true); + if (!diff) { + continue; + } + } + */ + + // Will be handled by item CR for now + if (type == 'creator') { + remoteCreatorStore[remoteObj.id] = remoteObj; + continue; + } + + if (type != 'item') { + alert('Reconciliation unimplemented for ' + types); + throw ('Reconciliation unimplemented for ' + types); + } + + // TODO: order reconcile by parent/child? + + toReconcile.push([ + obj, + remoteObj + ]); + continue; } - - if (type != 'item') { - alert('Reconciliation unimplemented for ' + types); - _error('Reconciliation unimplemented for ' + types); - } - - // TODO: order reconcile by parent/child? - - toReconcile.push([ - obj, - remoteObj - ]); - - continue; } // Local object hasn't been modified -- overwrite else { @@ -1216,12 +1253,15 @@ Zotero.Sync.Server.Data = new function() { continue; } - var remoteObj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode); + // TODO: non-merged items + if (type != 'item') { - alert('Reconciliation unimplemented for ' + types); - _error('Reconciliation unimplemented for ' + types); + alert('Delete reconciliation unimplemented for ' + types); + _error('Delete reconciliation unimplemented for ' + types); } + var remoteObj = Zotero.Sync.Server.Data['xmlTo' + Type](xmlNode); + // TODO: order reconcile by parent/child? toReconcile.push([ @@ -1229,7 +1269,7 @@ Zotero.Sync.Server.Data = new function() { remoteObj ]); - break typeloop; + continue typeloop; } // Create locally @@ -1245,7 +1285,10 @@ Zotero.Sync.Server.Data = new function() { } } + + // // Handle deleted objects + // if (xml.deleted && xml.deleted[types]) { Zotero.debug("Processing remotely deleted " + types); @@ -1275,7 +1318,9 @@ Zotero.Sync.Server.Data = new function() { } } + // // Reconcile objects that have changed locally and remotely + // if (toReconcile.length) { var io = { dataIn: { @@ -1345,11 +1390,11 @@ Zotero.Sync.Server.Data = new function() { } } + // Sort collections in order of parent collections, + // so referenced parent collections always exist when saving if (type == 'collection') { var collections = []; - // Sort collections in order of parent collections, - // so referenced parent collections always exist when saving var cmp = function (a, b) { var pA = a.parent; var pB = b.parent; @@ -1421,8 +1466,10 @@ Zotero.Sync.Server.Data = new function() { /** * ids = { - * items: [123, 234, 345, 456], - * creators: [321, 432, 543, 654], + * updated: { + * items: [123, 234, 345, 456], + * creators: [321, 432, 543, 654] + * }, * changed: { * items: { * oldID: { oldID: 1234, newID: 5678 }, ... @@ -1449,10 +1496,11 @@ Zotero.Sync.Server.Data = new function() { // Updates - for each(var Type in Zotero.Sync.syncObjects) { - var Types = Type + 's'; // 'Items' + for each(var syncObject in Zotero.Sync.syncObjects) { + var Type = syncObject.singular; // 'Item' + var Types = syncObject.plural; // 'Items' var type = Type.toLowerCase(); // 'item' - var types = type + 's'; // 'items' + var types = Types.toLowerCase(); // 'items' if (!ids.updated[types]) { continue; @@ -1462,7 +1510,7 @@ Zotero.Sync.Server.Data = new function() { switch (type) { // Items.get() can take multiple ids, - // so we handle it differently + // so we handle them differently case 'item': var objs = Zotero[Types].get(ids.updated[types]); for each(var obj in objs) { @@ -1481,10 +1529,11 @@ Zotero.Sync.Server.Data = new function() { // TODO: handle changed ids // Deletions - for each(var Type in Zotero.Sync.syncObjects) { - var Types = Type + 's'; // 'Items' + for each(var syncObject in Zotero.Sync.syncObjects) { + var Type = syncObject.singular; // 'Item' + var Types = syncObject.plural; // 'Items' var type = Type.toLowerCase(); // 'item' - var types = type + 's'; // 'items' + var types = Types.toLowerCase(); // 'items' if (!ids.deleted[types]) { continue; @@ -1849,4 +1898,106 @@ Zotero.Sync.Server.Data = new function() { return creator; } + + + function searchToXML(search) { + var xml = ; + + xml.@id = search.id; + xml.@name = search.name; + xml.@dateModified = search.dateModified; + xml.@key = search.key; + + var conditions = search.getSearchConditions(); + if (conditions) { + for each(var condition in conditions) { + var conditionXML = + conditionXML.@id = condition.id; + conditionXML.@condition = condition.condition; + if (condition.mode) { + conditionXML.@mode = condition.mode; + } + conditionXML.@operator = condition.operator; + conditionXML.@value = condition.value; + if (condition.required) { + conditionXML.@required = 1; + } + xml.condition += conditionXML; + } + } + + return xml; + } + + + /** + * Convert E4X object into an unsaved Zotero.Search + * + * @param object xmlSearch E4X XML node with search data + * @param object item (Optional) Existing Zotero.Search to update + * @param bool newID (Optional) Ignore passed searchID and choose new one + */ + function xmlToSearch(xmlSearch, search, newID) { + if (!search) { + if (newID) { + search = new Zotero.Search(null); + } + else { + search = new Zotero.Search(parseInt(xmlSearch.@id)); + /* + if (search.exists()) { + throw ("Search specified in XML node already exists " + + "in Zotero.Sync.Server.Data.xmlToSearch()"); + } + */ + } + } + else if (newID) { + _error("Cannot use new id with existing search in " + + "Zotero.Sync.Server.Data.xmlToSearch()"); + } + + search.name = xmlSearch.@name.toString(); + search.dateModified = xmlSearch.@dateModified.toString(); + search.key = xmlSearch.@key.toString(); + + var conditionID = -1; + + // Search conditions + for each(var condition in xmlSearch.condition) { + conditionID = parseInt(condition.@id); + var name = condition.@condition.toString(); + var mode = condition.@mode.toString(); + if (mode) { + name = name + '/' + mode; + } + if (search.getSearchCondition(conditionID)) { + search.updateCondition( + conditionID, + name, + condition.@operator.toString(), + condition.@value.toString(), + !!condition.@required.toString() + ); + } + else { + var newID = search.addCondition( + name, + condition.@operator.toString(), + condition.@value.toString(), + !!condition.@required.toString() + ); + if (newID != conditionID) { + throw ("Search condition ids not contiguous in Zotero.Sync.Server.xmlToSearch()"); + } + } + } + + conditionID++; + while (search.getSearchCondition(conditionID)) { + search.removeCondition(conditionID); + } + + return search; + } } diff --git a/chrome/skin/default/zotero/overlay.css b/chrome/skin/default/zotero/overlay.css index ba43377c1..b8f8d2c19 100644 --- a/chrome/skin/default/zotero/overlay.css +++ b/chrome/skin/default/zotero/overlay.css @@ -191,6 +191,11 @@ list-style-image: url('chrome://zotero/skin/toolbar-advanced-search.png'); } +#zotero-tb-sync #zotero-last-sync-time +{ + color: gray; +} + #zotero-tb-fullscreen { list-style-image: url('chrome://zotero/skin/toolbar-fullscreen-bottom.png'); diff --git a/system.sql b/system.sql index 8abd7210e..bf207a8e6 100644 --- a/system.sql +++ b/system.sql @@ -30,7 +30,7 @@ CREATE TABLE fields ( fieldID INTEGER PRIMARY KEY, fieldName TEXT, fieldFormatID INT, - FOREIGN KEY (fieldFormatID) REFERENCES fieldFormat(fieldFormatID) + FOREIGN KEY (fieldFormatID) REFERENCES fieldFormats(fieldFormatID) ); -- Defines valid fields for each itemType, their display order, and their default visibility @@ -1248,4 +1248,4 @@ INSERT INTO "charsets" VALUES(168, 'x0212'); INSERT INTO "syncObjectTypes" VALUES(1, 'collection'); INSERT INTO "syncObjectTypes" VALUES(2, 'creator'); INSERT INTO "syncObjectTypes" VALUES(3, 'item'); -INSERT INTO "syncObjectTypes" VALUES(4, 'savedSearch'); +INSERT INTO "syncObjectTypes" VALUES(4, 'search'); diff --git a/triggers.sql b/triggers.sql index 1afdd5b24..e83272e4d 100644 --- a/triggers.sql +++ b/triggers.sql @@ -608,29 +608,29 @@ CREATE TRIGGER fkd_itemTags_tagID_tags_tagID WHERE (SELECT COUNT(*) FROM itemTags WHERE tagID = OLD.tagID) > 0; END; --- savedSearchConditions/searchConditionID -DROP TRIGGER IF EXISTS fki_savedSearchConditions_searchConditionID_savedSearches_savedSearchID; -CREATE TRIGGER fki_savedSearchConditions_searchConditionID_savedSearches_savedSearchID +-- savedSearchConditions/savedSearchID +DROP TRIGGER IF EXISTS fki_savedSearchConditions_savedSearchID_savedSearches_savedSearchID; +CREATE TRIGGER fki_savedSearchConditions_savedSearchID_savedSearches_savedSearchID BEFORE INSERT ON savedSearchConditions FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'insert on table "savedSearchConditions" violates foreign key constraint "fki_savedSearchConditions_searchConditionID_savedSearches_savedSearchID"') - WHERE NEW.searchConditionID IS NOT NULL AND (SELECT COUNT(*) FROM savedSearches WHERE savedSearchID = NEW.searchConditionID) = 0; + SELECT RAISE(ABORT, 'insert on table "savedSearchConditions" violates foreign key constraint "fki_savedSearchConditions_savedSearchID_savedSearches_savedSearchID"') + WHERE (SELECT COUNT(*) FROM savedSearches WHERE savedSearchID = NEW.savedSearchID) = 0; END; -DROP TRIGGER IF EXISTS fku_savedSearchConditions_searchConditionID_savedSearches_savedSearchID; -CREATE TRIGGER fku_savedSearchConditions_searchConditionID_savedSearches_savedSearchID - BEFORE UPDATE OF searchConditionID ON savedSearchConditions +DROP TRIGGER IF EXISTS fku_savedSearchConditions_savedSearchID_savedSearches_savedSearchID; +CREATE TRIGGER fku_savedSearchConditions_savedSearchID_savedSearches_savedSearchID + BEFORE UPDATE OF savedSearchID ON savedSearchConditions FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'update on table "savedSearchConditions" violates foreign key constraint "fku_savedSearchConditions_searchConditionID_savedSearches_savedSearchID"') - WHERE NEW.searchConditionID IS NOT NULL AND (SELECT COUNT(*) FROM savedSearches WHERE savedSearchID = NEW.searchConditionID) = 0; + SELECT RAISE(ABORT, 'update on table "savedSearchConditions" violates foreign key constraint "fku_savedSearchConditions_savedSearchID_savedSearches_savedSearchID"') + WHERE (SELECT COUNT(*) FROM savedSearches WHERE savedSearchID = NEW.savedSearchID) = 0; END; -DROP TRIGGER IF EXISTS fkd_savedSearchConditions_searchConditionID_savedSearches_savedSearchID; -CREATE TRIGGER fkd_savedSearchConditions_searchConditionID_savedSearches_savedSearchID +DROP TRIGGER IF EXISTS fkd_savedSearchConditions_savedSearchID_savedSearches_savedSearchID; +CREATE TRIGGER fkd_savedSearchConditions_savedSearchID_savedSearches_savedSearchID BEFORE DELETE ON savedSearches FOR EACH ROW BEGIN - SELECT RAISE(ABORT, 'delete on table "savedSearches" violates foreign key constraint "fkd_savedSearchConditions_searchConditionID_savedSearches_savedSearchID"') - WHERE (SELECT COUNT(*) FROM savedSearchConditions WHERE searchConditionID = OLD.savedSearchID) > 0; + SELECT RAISE(ABORT, 'delete on table "savedSearches" violates foreign key constraint "fkd_savedSearchConditions_savedSearchID_savedSearches_savedSearchID"') + WHERE (SELECT COUNT(*) FROM savedSearchConditions WHERE savedSearchID = OLD.savedSearchID) > 0; END; -- syncDeleteLog/syncObjectTypeID