tag autocomplete matching non-left bound substring

Tag autocomplete will now match tag substrings in the middle,
not just the start of the tag.

Refactoring to optimize performance for libraries
with many tags:
- From each search, save results into an in-memory cache.
During subsequent searches, check the cache to see if
it has any matches
- Ensure we never run multiple queries at the
same time. If a query is already running, wait for it to
finish before running the next one. If there are multiple
pending queries that arrive while one is running,
discard all but the last one
- Fixed stopSearch not working correctly. Now, if
stopSearch is called, the current query processing will be
properly cancelled in the onRow handler.

Also, drop resultsCallback to keep function flow clearer, since it
is not used anywhere at the moment.
This commit is contained in:
Bogdan Abaev 2025-07-24 15:54:02 -07:00
parent 19a848484a
commit 710b13385b

View file

@ -32,25 +32,86 @@ const Cr = Components.results;
import { Zotero } from "chrome://zotero/content/zotero.mjs";
const MAX_RESULTS = 50;
let searchResultsCache = {};
let currentSearchConfig = null;
let currentQuery = null;
let nextQuery = null;
let lastQueryTs = 0;
/*
* Implements nsIAutoCompleteSearch
*/
export function ZoteroAutoComplete() {}
// Entrypoint to autocomplete search called on each input change
ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchParams, previousResult, listener) {
// FIXME
//this.stopSearch();
// stopSearch is not always called before startSearch. We could
// ensure that any ongoing queries are cancelled here. But instead,
// we let the old query finish, in hope that some query results will
// match the new search string, since they will be added faster.
// this.stopSearch();
var result = Cc["@mozilla.org/autocomplete/simple-result;1"]
.createInstance(Ci.nsIAutoCompleteSimpleResult);
result.setSearchString(searchString);
this._result = result;
this._results = [];
this._includedSearchResults = new Set();
this._listener = listener;
this._cancelled = false;
Zotero.debug("Starting autocomplete search with data '"
let queryID = Zotero.Utilities.randomString();
Zotero.debug("Start autocomplete query " + queryID + " with data '"
+ searchParams + "'" + " and string '" + searchString + "'");
// startSearch is called on each typed character. We don't want to start running
// a new query unless the old one finished to avoid spamming many queries running
// at the same time, which poorly affects performance.
// We maintain a record of the current query and the next query that should run
// after the first one finished.
// Suppose we get "t", "te", "tes", and "test" searches, and queries take a somewhat long time.
// "t" query will be started, "te" and "tes" will be skipped, and we'll only run "test"
// after "t" finishes.
nextQuery = { queryID, searchString, searchParams, cancelled: false };
if (!currentQuery) {
this._processQueries();
}
};
// Fetch search results one query at a time
ZoteroAutoComplete.prototype._processQueries = async function () {
while (nextQuery) {
// Move next query to current
currentQuery = nextQuery;
nextQuery = null;
let currentTime = (new Date()).getTime();
let { queryID, searchString, searchParams } = currentQuery;
// If the search config has not changed since last search, see if any
// earlier search results match this query
let searchConfig = JSON.stringify(searchParams);
let isExpired = currentTime - lastQueryTs > 10 * 1000;
if (currentSearchConfig === searchConfig && !isExpired) {
this._applyCachedResults(searchString);
}
// If the search config has changed, clear the cache
else {
searchResultsCache = {};
currentSearchConfig = searchConfig;
}
lastQueryTs = currentTime;
await this.executeQuery(queryID, searchString, searchParams);
currentQuery = null;
}
};
ZoteroAutoComplete.prototype.executeQuery = async function (queryID, searchString, searchParams) {
Zotero.debug("Execute autocomplete query with data '"
+ searchParams + "'" + " and string '" + searchString + "'");
searchParams = JSON.parse(searchParams);
@ -59,15 +120,13 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
}
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) + '%'];
var sqlParams = ["%" + Zotero.DB.escapeSQLExpression(searchString) + '%'];
if (searchParams.libraryID) {
sql += " AND tagID IN (SELECT tagID FROM itemTags JOIN items USING (itemID) "
+ "WHERE libraryID=?)";
@ -208,7 +267,7 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
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);
this._notifyResult(false, Ci.nsIAutoCompleteResult.RESULT_IGNORED);
return;
}
@ -238,98 +297,109 @@ ZoteroAutoComplete.prototype.startSearch = async function (searchString, searchP
sql += "ORDER BY value";
}
sql += " LIMIT 50";
// Limit search results to 1000, even though we only display 50 results at a time.
// If the user types "t" first, we'll save 1000 records to searchResultsCache,
// so that if they type "est" after, we are more likely to find some "test" results
// in the 1000 matches for "t".
sql += " LIMIT 1000";
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");
try {
// Process matching row results
let onRow = (row, cancel) => {
if (currentQuery?.cancelled) {
cancel();
return;
}
var value = row.getResultByIndex(0);
var id = row.getResultByIndex(1);
this.updateResult(value, id);
}.bind(this);
}
var resultCode;
try {
let results = await 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;
this.updateResult(searchString, value, id);
};
await Zotero.DB.queryAsync(sql, sqlParams, { onRow });
Zotero.debug("Autocomplete query completed");
this._notifyResult(false);
}
catch (e) {
Zotero.debug(e, 1);
resultCode = Ci.nsIAutoCompleteResult.RESULT_FAILURE;
this._notifyResult(false, Ci.nsIAutoCompleteResult.RESULT_FAILURE);
Zotero.debug("Autocomplete query aborted");
}
finally {
this.updateResults(null, null, false, resultCode);
};
};
ZoteroAutoComplete.prototype.updateResult = function (value, id) {
// Add an autocomplete value to the result set and save it in the cache
ZoteroAutoComplete.prototype.updateResult = function (searchString, value, id) {
// A value can be added either during the initial check for cached results
// or after the actual SQL query run. Don't add the same value twice.
if (this._includedSearchResults.has(value)) return false;
// Record the search result in the cache
if (searchResultsCache[searchString] === undefined) {
searchResultsCache[searchString] = [];
}
if (!searchResultsCache[searchString].find(r => r.value === value && r.id === id)) {
searchResultsCache[searchString].push({ value, id });
}
// updateResult can be called by the handler of a query for an old searchString
// (e.g. "t" while typing "test"). In that case, don't display it in the popup.
let doesMatchCurrentQuery = value.toLowerCase().includes(this._result.searchString.toLowerCase());
if (!doesMatchCurrentQuery) return false;
// Don't show too many results
if (this._result.matchCount > MAX_RESULTS) return false;
Zotero.debug(`Appending autocomplete value '${value}'` + (id ? " (" + id + ")" : ''));
// Add to nsIAutoCompleteResult
this._result.appendMatch(value, id, null, null, null, value);
// 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);
this._notifyResult(true);
}
}
this._includedSearchResults.add(value);
return true;
};
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, id, null, null, null, value);
this._results.push(value);
}
else {
//Zotero.debug("Skipping existing value '" + result + "'");
// Add to search results cached results from earlier searches that still match
ZoteroAutoComplete.prototype._applyCachedResults = function (searchString) {
// Fetch cached queries that are a part of the current search string
// (e.g. "t" and "te" queries if "test" is being searched for)
let queries = Object.keys(searchResultsCache)
.filter(q => searchString.toLowerCase().includes(q.toLowerCase()))
.sort((a, b) => b.length - a.length);
let addedResults = 0;
// Find among cached query results those that match the current search string
// and add them to the result set
for (let query of queries) {
let queryResults = searchResultsCache[query];
for (let { value, id } of queryResults) {
if (addedResults >= MAX_RESULTS) break;
let matches = value.toLowerCase().includes(searchString.toLowerCase());
if (!matches) continue;
Zotero.debug(`Found cached autocomplete value '${value}' and id ${id} for query '${searchString}'`);
this.updateResult(searchString, value, id);
addedResults++;
}
}
Zotero.debug("Found " + addedResults + " cached result for query" + searchString);
this._notifyResult(true, null);
return addedResults;
};
ZoteroAutoComplete.prototype._notifyResult = function (ongoing, resultCode) {
if (!resultCode) {
resultCode = "RESULT_";
if (!this._result.matchCount) {
resultCode += "NOMATCH";
resultCode = "RESULT_NOMATCH";
}
else {
resultCode += "SUCCESS";
resultCode = "RESULT_SUCCESS";
}
if (ongoing) {
resultCode += "_ONGOING";
}
resultCode = Ci.nsIAutoCompleteResult[resultCode];
}
Zotero.debug("Found " + this._result.matchCount
+ " result" + (this._result.matchCount != 1 ? "s" : ""));
@ -341,10 +411,12 @@ ZoteroAutoComplete.prototype.createInstance = function (iid) {
return this.QueryInterface(iid);
};
// FIXME
ZoteroAutoComplete.prototype.stopSearch = function (){
ZoteroAutoComplete.prototype.stopSearch = function () {
Zotero.debug('Stopping autocomplete search');
this._cancelled = true;
if (currentQuery) {
currentQuery.cancelled = true;
}
nextQuery = null;
}
// Static