/* ***** BEGIN LICENSE BLOCK ***** Copyright © 2009 Center for History and New Media George Mason University, Fairfax, Virginia, USA http://zotero.org This file is part of Zotero. Zotero is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Zotero is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with Zotero. If not, see . ***** END LICENSE BLOCK ***** */ const ZOTERO_AC_CONTRACTID = '@mozilla.org/autocomplete/search;1?name=zotero'; const ZOTERO_AC_CLASSNAME = 'Zotero AutoComplete'; const ZOTERO_AC_CID = Components.ID('{06a2ed11-d0a4-4ff0-a56f-a44545eee6ea}'); const Cc = Components.classes; const Ci = Components.interfaces; const Cr = Components.results; Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); var Zotero = Components.classes["@zotero.org/Zotero;1"] .getService(Components.interfaces.nsISupports) .wrappedJSObject; /* * Implements nsIAutoCompleteSearch */ function ZoteroAutoComplete() {} ZoteroAutoComplete.prototype.startSearch = Zotero.Promise.coroutine(function* (searchString, searchParams, previousResult, listener) { // FIXME //this.stopSearch(); var result = Cc["@mozilla.org/autocomplete/simple-result;1"] .createInstance(Ci.nsIAutoCompleteSimpleResult); result.setSearchString(searchString); this._result = result; this._results = []; this._listener = listener; this._cancelled = false; Zotero.debug("Starting autocomplete search with data '" + searchParams + "'" + " and string '" + searchString + "'"); searchParams = JSON.parse(searchParams); if (!searchParams) { throw new Error("Invalid JSON passed to autocomplete"); } var [fieldName, , subField] = searchParams.fieldName.split("-"); var resultsCallback; switch (fieldName) { case '': break; case 'tag': var sql = "SELECT DISTINCT name AS val, NULL AS id FROM tags WHERE name LIKE ? ESCAPE '\\'"; var sqlParams = [Zotero.DB.escapeSQLExpression(searchString) + '%']; if (searchParams.libraryID) { sql += " AND tagID IN (SELECT tagID FROM itemTags JOIN items USING (itemID) " + "WHERE libraryID=?)"; sqlParams.push(searchParams.libraryID); } if (searchParams.itemID) { sql += " AND name NOT IN (SELECT name FROM tags WHERE tagID IN (" + "SELECT tagID FROM itemTags WHERE itemID = ?))"; sqlParams.push(searchParams.itemID); } sql += " ORDER BY val COLLATE locale"; break; case 'creator': // Valid fieldMode values: // 0 == search two-field creators // 1 == search single-field creators // 2 == search both if (searchParams.fieldMode == 2) { var sql = "SELECT DISTINCT CASE fieldMode WHEN 1 THEN lastName " + "WHEN 0 THEN firstName || ' ' || lastName END AS val, NULL AS id " + "FROM creators "; if (searchParams.libraryID) { sql += "JOIN itemCreators USING (creatorID) JOIN items USING (itemID) "; } sql += "WHERE CASE fieldMode " + "WHEN 1 THEN lastName LIKE ?1 " + "WHEN 0 THEN (firstName || ' ' || lastName LIKE ?1) OR (lastName LIKE ?1) END "; var sqlParams = [searchString + '%']; if (searchParams.libraryID) { sql += ` AND libraryID=?${sqlParams.length + 1}`; sqlParams.push(searchParams.libraryID); } sql += "ORDER BY val"; } else { var sql = "SELECT DISTINCT "; if (searchParams.fieldMode == 1) { sql += "lastName AS val, creatorID || '-1' AS id"; } // Retrieve the matches in the specified field // as well as any full names using the name // // e.g. "Shakespeare" and "Shakespeare, William" // // creatorID is in the format "12345-1" or "12345-2", // - 1 means the row uses only the specified field // - 2 means it uses both else { sql += "CASE WHEN firstName='' OR firstName IS NULL THEN lastName " + "ELSE lastName || ', ' || firstName END AS val, " + "creatorID || '-' || CASE " + "WHEN (firstName = '' OR firstName IS NULL) THEN 1 " + "ELSE 2 END AS id"; } var fromSQL = " FROM creators " if (searchParams.libraryID) { fromSQL += "JOIN itemCreators USING (creatorID) JOIN items USING (itemID) "; } fromSQL += "WHERE " + subField + " LIKE ?1 AND fieldMode=?2"; var sqlParams = [ searchString + '%', searchParams.fieldMode ? searchParams.fieldMode : 0 ]; if (searchParams.itemID) { fromSQL += " AND creatorID NOT IN (SELECT creatorID FROM " + `itemCreators WHERE itemID=?${sqlParams.length + 1}`; sqlParams.push(searchParams.itemID); if (searchParams.creatorTypeID) { fromSQL += ` AND creatorTypeID=?${sqlParams.length + 1}`; sqlParams.push(searchParams.creatorTypeID); } fromSQL += ")"; } if (searchParams.libraryID) { fromSQL += ` AND libraryID=?${sqlParams.length + 1}`; sqlParams.push(searchParams.libraryID); } sql += fromSQL; // If double-field mode, include matches for just this field // as well (i.e. "Shakespeare"), and group to collapse repeats if (searchParams.fieldMode != 1) { sql = "SELECT * FROM (" + sql + " UNION SELECT DISTINCT " + subField + " AS val, creatorID || '-1' AS id" + fromSQL + ") GROUP BY val"; } sql += " ORDER BY val"; } break; case 'dateModified': case 'dateAdded': var sql = "SELECT DISTINCT DATE(" + fieldName + ", 'localtime') AS val, NULL AS id " + "FROM items WHERE " + fieldName + " LIKE ? "; var sqlParams = [searchString + '%']; if (searchParams.libraryID) { sql += "AND libraryID=? "; sqlParams.push(searchParams.libraryID); } sql += "ORDER BY " + fieldName; break; case 'accessDate': var fieldID = Zotero.ItemFields.getID('accessDate'); var sql = "SELECT DISTINCT DATE(value, 'localtime') AS val, NULL AS id FROM itemData "; if (searchParams.libraryID) { sql += "JOIN items USING (itemID) "; } sql += "WHERE fieldID=? AND value LIKE ? "; var sqlParams = [fieldID, searchString + '%']; if (searchParams.libraryID) { sql += "AND libraryID=? "; sqlParams.push(searchParams.libraryID); } sql += "ORDER BY value"; break; default: var fieldID = Zotero.ItemFields.getID(fieldName); if (!fieldID) { Zotero.debug("'" + fieldName + "' is not a valid autocomplete scope", 1); this.updateResults([], false, Ci.nsIAutoCompleteResult.RESULT_IGNORED); return; } // We don't use date autocomplete anywhere, but if we're not // disallowing it altogether, we should at least do it right and // use the user part of the multipart field var valueField = fieldName == 'date' ? 'SUBSTR(value, 12, 100)' : 'value'; var sql = "SELECT DISTINCT " + valueField + " AS val, NULL AS id FROM itemData "; if (searchParams.libraryID) { sql += "JOIN items USING (itemID) "; } sql += "JOIN itemDataValues USING (valueID) " + "WHERE fieldID=?1 AND " + valueField + " LIKE ?2 "; var sqlParams = [fieldID, searchString + '%']; // Exclude values from an item if (searchParams.itemID) { sql += "AND value NOT IN (SELECT value FROM itemData " + "NATURAL JOIN itemDataValues WHERE fieldID=?1 AND itemID=?3) "; sqlParams.push(searchParams.itemID); } // Limit to specific library if (searchParams.libraryID) { sql += `AND libraryID=?${sqlParams.length + 1} `; sqlParams.push(searchParams.libraryID); } sql += "ORDER BY value"; } sql += " LIMIT 50"; var onRow = null; // If there's a result callback (e.g., for sorting), don't use a row handler if (!resultsCallback) { onRow = function (row, cancel) { if (this._cancelled) { Zotero.debug("Cancelling query"); cancel(); return; } var value = row.getResultByIndex(0); var id = row.getResultByIndex(1); this.updateResult(value, id); }.bind(this); } var resultCode; try { let results = yield Zotero.DB.queryAsync(sql, sqlParams, { onRow: onRow }); // Post-process the results if (resultsCallback) { resultsCallback(results); this.updateResults( Object.values(results).map(x => x.val), Object.values(results).map(x => x.id), false ); } resultCode = null; Zotero.debug("Autocomplete query completed"); } catch (e) { Zotero.debug(e, 1); resultCode = Ci.nsIAutoCompleteResult.RESULT_FAILURE; Zotero.debug("Autocomplete query aborted"); } finally { this.updateResults(null, null, false, resultCode); }; }); ZoteroAutoComplete.prototype.updateResult = function (value, id) { Zotero.debug(`Appending autocomplete value '${value}'` + (id ? " (" + id + ")" : '')); // Add to nsIAutoCompleteResult this._result.appendMatch(value, value, null, null, null, id); // Add to our own list this._results.push(value); // Only update the UI every 10 records if (this._result.matchCount % 10 == 0) { this._result.setSearchResult(Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING); this._listener.onSearchResult(this, this._result); } } ZoteroAutoComplete.prototype.updateResults = function (values, ids, ongoing, resultCode) { if (!values) { values = []; } if (!ids) { ids = []; } for (let i = 0; i < values.length; i++) { let value = values[i]; if (!this._results.includes(value)) { let id = ids[i] || null; Zotero.debug("Adding autocomplete value '" + value + "'" + (id ? " (" + id + ")" : "")); this._result.appendMatch(value, value, null, null, null, id); this._results.push(value); } else { //Zotero.debug("Skipping existing value '" + result + "'"); } } if (!resultCode) { resultCode = "RESULT_"; if (!this._result.matchCount) { resultCode += "NOMATCH"; } else { resultCode += "SUCCESS"; } if (ongoing) { resultCode += "_ONGOING"; } resultCode = Ci.nsIAutoCompleteResult[resultCode]; } Zotero.debug("Found " + this._result.matchCount + " result" + (this._result.matchCount != 1 ? "s" : "")); this._result.setSearchResult(resultCode); this._listener.onSearchResult(this, this._result); } // FIXME ZoteroAutoComplete.prototype.stopSearch = function(){ Zotero.debug('Stopping autocomplete search'); this._cancelled = true; } // // XPCOM goop // ZoteroAutoComplete.prototype.classDescription = ZOTERO_AC_CLASSNAME; ZoteroAutoComplete.prototype.classID = ZOTERO_AC_CID; ZoteroAutoComplete.prototype.contractID = ZOTERO_AC_CONTRACTID; ZoteroAutoComplete.prototype.QueryInterface = XPCOMUtils.generateQI([ Components.interfaces.nsIAutoCompleteSearch, Components.interfaces.nsIAutoCompleteObserver, Components.interfaces.nsISupports]); var NSGetFactory = XPCOMUtils.generateNSGetFactory([ZoteroAutoComplete]);