zotero-autocomplete.js (10704B)
1 /* 2 ***** BEGIN LICENSE BLOCK ***** 3 4 Copyright © 2009 Center for History and New Media 5 George Mason University, Fairfax, Virginia, USA 6 http://zotero.org 7 8 This file is part of Zotero. 9 10 Zotero is free software: you can redistribute it and/or modify 11 it under the terms of the GNU Affero General Public License as published by 12 the Free Software Foundation, either version 3 of the License, or 13 (at your option) any later version. 14 15 Zotero is distributed in the hope that it will be useful, 16 but WITHOUT ANY WARRANTY; without even the implied warranty of 17 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 GNU Affero General Public License for more details. 19 20 You should have received a copy of the GNU Affero General Public License 21 along with Zotero. If not, see <http://www.gnu.org/licenses/>. 22 23 ***** END LICENSE BLOCK ***** 24 */ 25 26 const ZOTERO_AC_CONTRACTID = '@mozilla.org/autocomplete/search;1?name=zotero'; 27 const ZOTERO_AC_CLASSNAME = 'Zotero AutoComplete'; 28 const ZOTERO_AC_CID = Components.ID('{06a2ed11-d0a4-4ff0-a56f-a44545eee6ea}'); 29 30 const Cc = Components.classes; 31 const Ci = Components.interfaces; 32 const Cr = Components.results; 33 34 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); 35 36 var Zotero = Components.classes["@zotero.org/Zotero;1"] 37 .getService(Components.interfaces.nsISupports) 38 .wrappedJSObject; 39 40 /* 41 * Implements nsIAutoCompleteSearch 42 */ 43 function ZoteroAutoComplete() {} 44 45 ZoteroAutoComplete.prototype.startSearch = Zotero.Promise.coroutine(function* (searchString, searchParams, previousResult, listener) { 46 // FIXME 47 //this.stopSearch(); 48 49 var result = Cc["@mozilla.org/autocomplete/simple-result;1"] 50 .createInstance(Ci.nsIAutoCompleteSimpleResult); 51 result.setSearchString(searchString); 52 53 this._result = result; 54 this._results = []; 55 this._listener = listener; 56 this._cancelled = false; 57 58 Zotero.debug("Starting autocomplete search with data '" 59 + searchParams + "'" + " and string '" + searchString + "'"); 60 61 searchParams = JSON.parse(searchParams); 62 if (!searchParams) { 63 throw new Error("Invalid JSON passed to autocomplete"); 64 } 65 var [fieldName, , subField] = searchParams.fieldName.split("-"); 66 67 var resultsCallback; 68 69 switch (fieldName) { 70 case '': 71 break; 72 73 case 'tag': 74 var sql = "SELECT DISTINCT name AS val, NULL AS comment FROM tags WHERE name LIKE ?"; 75 var sqlParams = [searchString + '%']; 76 if (searchParams.libraryID !== undefined) { 77 sql += " AND tagID IN (SELECT tagID FROM itemTags JOIN items USING (itemID) " 78 + "WHERE libraryID=?)"; 79 sqlParams.push(searchParams.libraryID); 80 } 81 if (searchParams.itemID) { 82 sql += " AND name NOT IN (SELECT name FROM tags WHERE tagID IN (" 83 + "SELECT tagID FROM itemTags WHERE itemID = ?))"; 84 sqlParams.push(searchParams.itemID); 85 } 86 sql += " ORDER BY val COLLATE locale"; 87 break; 88 89 case 'creator': 90 // Valid fieldMode values: 91 // 0 == search two-field creators 92 // 1 == search single-field creators 93 // 2 == search both 94 if (searchParams.fieldMode == 2) { 95 var sql = "SELECT DISTINCT CASE fieldMode WHEN 1 THEN lastName " 96 + "WHEN 0 THEN firstName || ' ' || lastName END AS val, NULL AS comment " 97 + "FROM creators "; 98 if (searchParams.libraryID !== undefined) { 99 sql += "JOIN itemCreators USING (creatorID) JOIN items USING (itemID) "; 100 } 101 sql += "WHERE CASE fieldMode " 102 + "WHEN 1 THEN lastName LIKE ? " 103 + "WHEN 0 THEN (firstName || ' ' || lastName LIKE ?) OR (lastName LIKE ?) END " 104 var sqlParams = [searchString + '%', searchString + '%', searchString + '%']; 105 if (searchParams.libraryID !== undefined) { 106 sql += " AND libraryID=?"; 107 sqlParams.push(searchParams.libraryID); 108 } 109 sql += "ORDER BY val"; 110 } 111 else 112 { 113 var sql = "SELECT DISTINCT "; 114 if (searchParams.fieldMode == 1) { 115 sql += "lastName AS val, creatorID || '-1' AS comment"; 116 } 117 // Retrieve the matches in the specified field 118 // as well as any full names using the name 119 // 120 // e.g. "Shakespeare" and "Shakespeare, William" 121 // 122 // creatorID is in the format "12345-1" or "12345-2", 123 // - 1 means the row uses only the specified field 124 // - 2 means it uses both 125 else { 126 sql += "CASE WHEN firstName='' OR firstName IS NULL THEN lastName " 127 + "ELSE lastName || ', ' || firstName END AS val, " 128 + "creatorID || '-' || CASE " 129 + "WHEN (firstName = '' OR firstName IS NULL) THEN 1 " 130 + "ELSE 2 END AS comment"; 131 } 132 133 var fromSQL = " FROM creators " 134 if (searchParams.libraryID !== undefined) { 135 fromSQL += "JOIN itemCreators USING (creatorID) JOIN items USING (itemID) "; 136 } 137 fromSQL += "WHERE " + subField + " LIKE ? " + "AND fieldMode=?"; 138 var sqlParams = [ 139 searchString + '%', 140 searchParams.fieldMode ? searchParams.fieldMode : 0 141 ]; 142 if (searchParams.itemID) { 143 fromSQL += " AND creatorID NOT IN (SELECT creatorID FROM " 144 + "itemCreators WHERE itemID=?"; 145 sqlParams.push(searchParams.itemID); 146 if (searchParams.creatorTypeID) { 147 fromSQL += " AND creatorTypeID=?"; 148 sqlParams.push(searchParams.creatorTypeID); 149 } 150 fromSQL += ")"; 151 } 152 if (searchParams.libraryID !== undefined) { 153 fromSQL += " AND libraryID=?"; 154 sqlParams.push(searchParams.libraryID); 155 } 156 157 sql += fromSQL; 158 159 // If double-field mode, include matches for just this field 160 // as well (i.e. "Shakespeare"), and group to collapse repeats 161 if (searchParams.fieldMode != 1) { 162 sql = "SELECT * FROM (" + sql + " UNION SELECT DISTINCT " 163 + subField + " AS val, creatorID || '-1' AS comment" 164 + fromSQL + ") GROUP BY val"; 165 sqlParams = sqlParams.concat(sqlParams); 166 } 167 168 sql += " ORDER BY val"; 169 } 170 break; 171 172 case 'dateModified': 173 case 'dateAdded': 174 var sql = "SELECT DISTINCT DATE(" + fieldName + ", 'localtime') AS val, NULL AS comment FROM items " 175 + "WHERE " + fieldName + " LIKE ? ORDER BY " + fieldName; 176 var sqlParams = [searchString + '%']; 177 break; 178 179 case 'accessDate': 180 var fieldID = Zotero.ItemFields.getID('accessDate'); 181 182 var sql = "SELECT DISTINCT DATE(value, 'localtime') AS val, NULL AS comment FROM itemData " 183 + "WHERE fieldID=? AND value LIKE ? ORDER BY value"; 184 var sqlParams = [fieldID, searchString + '%']; 185 break; 186 187 default: 188 var fieldID = Zotero.ItemFields.getID(fieldName); 189 if (!fieldID) { 190 Zotero.debug("'" + fieldName + "' is not a valid autocomplete scope", 1); 191 this.updateResults([], false, Ci.nsIAutoCompleteResult.RESULT_IGNORED); 192 return; 193 } 194 195 // We don't use date autocomplete anywhere, but if we're not 196 // disallowing it altogether, we should at least do it right and 197 // use the user part of the multipart field 198 var valueField = fieldName == 'date' ? 'SUBSTR(value, 12, 100)' : 'value'; 199 200 var sql = "SELECT DISTINCT " + valueField + " AS val, NULL AS comment " 201 + "FROM itemData NATURAL JOIN itemDataValues " 202 + "WHERE fieldID=?1 AND " + valueField 203 + " LIKE ?2 " 204 205 var sqlParams = [fieldID, searchString + '%']; 206 if (searchParams.itemID) { 207 sql += "AND value NOT IN (SELECT value FROM itemData " 208 + "NATURAL JOIN itemDataValues WHERE fieldID=?1 AND itemID=?3) "; 209 sqlParams.push(searchParams.itemID); 210 } 211 sql += "ORDER BY value"; 212 } 213 214 var onRow = null; 215 // If there's a result callback (e.g., for sorting), don't use a row handler 216 if (!resultsCallback) { 217 onRow = function (row) { 218 if (this._cancelled) { 219 Zotero.debug("Cancelling query"); 220 throw StopIteration; 221 } 222 var result = row.getResultByIndex(0); 223 var comment = row.getResultByIndex(1); 224 this.updateResult(result, comment, true); 225 }.bind(this); 226 } 227 var resultCode; 228 try { 229 let results = yield Zotero.DB.queryAsync(sql, sqlParams, { onRow: onRow }); 230 // Post-process the results 231 if (resultsCallback) { 232 resultsCallback(results); 233 this.updateResults( 234 Object.values(results).map(x => x.val), 235 Object.values(results).map(x => x.comment), 236 false 237 ); 238 } 239 resultCode = null; 240 Zotero.debug("Autocomplete query completed"); 241 } 242 catch (e) { 243 Zotero.debug(e, 1); 244 resultCode = Ci.nsIAutoCompleteResult.RESULT_FAILURE; 245 Zotero.debug("Autocomplete query aborted"); 246 } 247 finally { 248 this.updateResults(null, null, false, resultCode); 249 }; 250 }); 251 252 253 ZoteroAutoComplete.prototype.updateResult = function (result, comment) { 254 Zotero.debug("Appending autocomplete value '" + result + "'" + (comment ? " (" + comment + ")" : "")); 255 // Add to nsIAutoCompleteResult 256 this._result.appendMatch(result, comment ? comment : null); 257 // Add to our own list 258 this._results.push(result); 259 // Only update the UI every 10 records 260 if (this._result.matchCount % 10 == 0) { 261 this._result.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING); 262 this._listener.onUpdateSearchResult(this, this._result); 263 } 264 } 265 266 267 ZoteroAutoComplete.prototype.updateResults = function (results, comments, ongoing, resultCode) { 268 if (!results) { 269 results = []; 270 } 271 if (!comments) { 272 comments = []; 273 } 274 275 for (var i=0; i<results.length; i++) { 276 let result = results[i]; 277 278 if (this._results.indexOf(result) == -1) { 279 comment = comments[i] ? comments[i] : null; 280 Zotero.debug("Adding autocomplete value '" + result + "'" + (comment ? " (" + comment + ")" : "")); 281 this._result.appendMatch(result, comment, null, null); 282 this._results.push(result); 283 } 284 else { 285 //Zotero.debug("Skipping existing value '" + result + "'"); 286 } 287 } 288 289 if (!resultCode) { 290 resultCode = "RESULT_"; 291 if (!this._result.matchCount) { 292 resultCode += "NOMATCH"; 293 } 294 else { 295 resultCode += "SUCCESS"; 296 } 297 if (ongoing) { 298 resultCode += "_ONGOING"; 299 } 300 resultCode = Ci.nsIAutoCompleteResult[resultCode]; 301 } 302 303 Zotero.debug("Found " + this._result.matchCount 304 + " result" + (this._result.matchCount != 1 ? "s" : "")); 305 306 this._result.setSearchResult(resultCode); 307 this._listener.onSearchResult(this, this._result); 308 } 309 310 311 // FIXME 312 ZoteroAutoComplete.prototype.stopSearch = function(){ 313 Zotero.debug('Stopping autocomplete search'); 314 this._cancelled = true; 315 } 316 317 // 318 // XPCOM goop 319 // 320 321 ZoteroAutoComplete.prototype.classDescription = ZOTERO_AC_CLASSNAME; 322 ZoteroAutoComplete.prototype.classID = ZOTERO_AC_CID; 323 ZoteroAutoComplete.prototype.contractID = ZOTERO_AC_CONTRACTID; 324 ZoteroAutoComplete.prototype.QueryInterface = XPCOMUtils.generateQI([ 325 Components.interfaces.nsIAutoCompleteSearch, 326 Components.interfaces.nsIAutoCompleteObserver, 327 Components.interfaces.nsISupports]); 328 329 var NSGetFactory = XPCOMUtils.generateNSGetFactory([ZoteroAutoComplete]);