mirror of
https://github.com/zotero/zotero.git
synced 2026-09-11 22:51:15 +00:00
Show an indexing-progress banner during best-match searches
While a best-match search runs against a partially built embeddings index, results cover only the indexed items and can look arbitrary. Show the indexing progress in a banner above the items list, updating as the index fills, so incomplete results aren't mistaken for a complete ranking.
This commit is contained in:
parent
c5d41229bb
commit
bb528d278a
6 changed files with 201 additions and 7 deletions
|
|
@ -171,6 +171,62 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
return this._bestMatchBarFractions || new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding-index coverage while a best-match search is active, for the
|
||||
* banner above the items list. Null when there's no active best-match
|
||||
* search or every eligible item is indexed.
|
||||
*
|
||||
* @returns {Object|null} - { type: 'indexing'|'paused', indexed, total }
|
||||
*/
|
||||
getBestMatchIndexState() {
|
||||
return this._bestMatchIndexState || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current index coverage across the selected rows' libraries.
|
||||
* Never throws -- the banner is informational and shouldn't break a
|
||||
* refresh.
|
||||
*
|
||||
* @return {Promise<Object|null>}
|
||||
*/
|
||||
async _getBestMatchIndexState() {
|
||||
try {
|
||||
let status = Zotero.Embeddings.Indexing.getStatus();
|
||||
if (!status.enabled) {
|
||||
return null;
|
||||
}
|
||||
// Counts aren't populated until the indexer runs in this session
|
||||
if (!status.libraries.length) {
|
||||
status = await Zotero.Embeddings.Indexing.refreshStatus();
|
||||
}
|
||||
let libraryIDs = new Set(
|
||||
this.collectionTreeRows
|
||||
.map(row => row.ref?.libraryID)
|
||||
.filter(id => id !== undefined)
|
||||
);
|
||||
let libraries = status.libraries
|
||||
.filter(lib => !libraryIDs.size || libraryIDs.has(lib.libraryID));
|
||||
let indexed = libraries.reduce((sum, lib) => sum + lib.indexed, 0);
|
||||
let total = libraries.reduce((sum, lib) => sum + lib.eligible, 0);
|
||||
if (indexed >= total) {
|
||||
return null;
|
||||
}
|
||||
// Only an explicit pause reports as paused. Anything else --
|
||||
// between runs (startup, the pre-run debounce) or after an error
|
||||
// (detailed in the preferences) -- reports as indexing, since the
|
||||
// banner explains the incomplete coverage, not the indexer state
|
||||
return {
|
||||
type: status.paused ? 'paused' : 'indexing',
|
||||
indexed,
|
||||
total
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The semantic stage of a best-match search: score the merged,
|
||||
* deduplicated results from all selected rows against the query in a
|
||||
|
|
@ -235,6 +291,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
Zotero.logError(e);
|
||||
}
|
||||
this._bestMatchRanks = new Map();
|
||||
this._bestMatchIndexState = await this._getBestMatchIndexState();
|
||||
// A rank-only search's membership doesn't depend on the index, so
|
||||
// show its results unranked; anything else shows no results rather
|
||||
// than an unranked scope
|
||||
|
|
@ -274,6 +331,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
}
|
||||
this._bestMatchRanks = ranks;
|
||||
this._bestMatchBarFractions = fractions;
|
||||
this._bestMatchIndexState = await this._getBestMatchIndexState();
|
||||
return kept;
|
||||
}
|
||||
|
||||
|
|
@ -511,6 +569,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
this.collectionTreeRows.forEach(row => row.clearCache());
|
||||
this._bestMatchRanks = null;
|
||||
this._bestMatchBarFractions = null;
|
||||
this._bestMatchIndexState = null;
|
||||
// Get the full set of items we want to show, merged across all selected rows
|
||||
let newSearchItemSet = new Set();
|
||||
for (let arr of await Promise.all(this.collectionTreeRows.map(row => row.getItems()))) {
|
||||
|
|
@ -1354,6 +1413,30 @@ class CollectionViewItemTree extends ItemTree {
|
|||
return super.sort(itemIDs);
|
||||
}
|
||||
|
||||
/**
|
||||
* While a best-match search runs against a partially built embeddings
|
||||
* index, show a banner above the items list with the indexing progress,
|
||||
* so incomplete results aren't mistaken for a complete ranking. Updates
|
||||
* arrive with the periodic refreshes the indexer triggers as it fills
|
||||
* the index.
|
||||
*/
|
||||
_renderTablePrologue() {
|
||||
let state = this.rowProvider.getBestMatchIndexState();
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="best-match-index-banner"
|
||||
key="best-match-index-banner"
|
||||
data-l10n-id={state.type == 'paused'
|
||||
? 'items-best-match-indexing-paused'
|
||||
: 'items-best-match-indexing'}
|
||||
data-l10n-args={JSON.stringify({ indexed: state.indexed, total: state.total })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const showMessage = !this.collectionTreeRows.length || this._itemsPaneMessage;
|
||||
|
||||
|
|
|
|||
|
|
@ -509,6 +509,9 @@ class VirtualizedTable extends React.Component {
|
|||
// Render with display: none
|
||||
hide: PropTypes.bool,
|
||||
|
||||
// An element rendered above the column header (e.g. a status banner)
|
||||
prologue: PropTypes.element,
|
||||
|
||||
multiSelect: PropTypes.bool,
|
||||
|
||||
requireSelection: PropTypes.bool,
|
||||
|
|
@ -1420,6 +1423,7 @@ class VirtualizedTable extends React.Component {
|
|||
return (
|
||||
<div {...props}>
|
||||
{columnDragMarker}
|
||||
{this.props.prologue}
|
||||
{header}
|
||||
<div {...jsWindowProps}>
|
||||
{/* Pinned copy of the current section's header. Lives inside the scrolling
|
||||
|
|
|
|||
|
|
@ -1322,14 +1322,18 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
}
|
||||
|
||||
// A refresh can change the derived column set (e.g. the forced
|
||||
// Relevance column while a best-match search is active), and the
|
||||
// header only picks that up through a render
|
||||
// Relevance column while a best-match search is active) or the table
|
||||
// prologue, and the header and prologue only pick those up through a
|
||||
// render
|
||||
this._getColumns();
|
||||
if (this.tree && this._renderedColumnsId !== this._columnsId) {
|
||||
let columnsChanged = this._renderedColumnsId !== this._columnsId;
|
||||
if (this.tree && (columnsChanged || this._renderedPrologueId !== this._getPrologueId())) {
|
||||
await new Promise(resolve => this.forceUpdate(resolve));
|
||||
// The rows above were painted with the previous column set, and the
|
||||
// render only rebuilds the header
|
||||
this.tree.invalidate();
|
||||
if (columnsChanged) {
|
||||
// The rows above were painted with the previous column set, and
|
||||
// the render only rebuilds the header
|
||||
this.tree.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
const itemsViewInActiveWindow = Zotero.getActiveZoteroPane()?.itemsView == this;
|
||||
|
|
@ -1478,13 +1482,37 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
</div>);
|
||||
}
|
||||
|
||||
/**
|
||||
* An element to render above the table's column header (e.g. a status
|
||||
* banner). Subclasses override this; the base tree renders nothing.
|
||||
*
|
||||
* @return {React.Element|null}
|
||||
*/
|
||||
_renderTablePrologue() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the current prologue's content, for handleRowModelUpdate()
|
||||
* to detect when a refresh changed the prologue and a render is needed
|
||||
*
|
||||
* @return {String|null}
|
||||
*/
|
||||
_getPrologueId() {
|
||||
let prologue = this._renderTablePrologue();
|
||||
return prologue ? JSON.stringify(prologue.props) : null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const showMessage = !!this._itemsPaneMessage;
|
||||
const itemsPaneMessage = this._renderItemsPaneMessage(showMessage);
|
||||
|
||||
let columns = this._getColumns();
|
||||
// The columns the header currently shows, for handleRowModelUpdate()
|
||||
// The columns and prologue the table currently shows, for
|
||||
// handleRowModelUpdate()
|
||||
this._renderedColumnsId = this._columnsId;
|
||||
let prologue = this._renderTablePrologue();
|
||||
this._renderedPrologueId = prologue ? JSON.stringify(prologue.props) : null;
|
||||
let virtualizedTable = React.createElement(VirtualizedTree,
|
||||
{
|
||||
getRowCount: () => this.rowProvider.getRowCount(),
|
||||
|
|
@ -1492,6 +1520,7 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
ref: ref => this.tree = ref,
|
||||
treeboxRef: ref => this._treebox = ref,
|
||||
renderItem: this._renderItem.bind(this),
|
||||
prologue,
|
||||
hide: showMessage,
|
||||
key: "virtualized-table",
|
||||
|
||||
|
|
|
|||
|
|
@ -458,6 +458,9 @@ items-column-last-read = Last Read
|
|||
items-column-relevance = Relevance
|
||||
items-column-relevance-rank = Rank { $rank }
|
||||
|
||||
items-best-match-indexing = Indexing in progress — { $indexed } of { $total } items indexed
|
||||
items-best-match-indexing-paused = Indexing is paused — { $indexed } of { $total } items indexed
|
||||
|
||||
report-error =
|
||||
.label = Report Error…
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,17 @@
|
|||
min-width: $min-width-items-pane;
|
||||
}
|
||||
|
||||
// Indexing-progress banner above the column header while a best-match search
|
||||
// runs against a partially built embeddings index
|
||||
.virtualized-table .best-match-index-banner {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 8px;
|
||||
background: var(--material-sidepane);
|
||||
border-bottom: var(--material-panedivider);
|
||||
color: var(--fill-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#zotero-items-tree {
|
||||
.virtualized-table-body, .drag-image-container {
|
||||
padding: 4px 8px 8px;
|
||||
|
|
|
|||
|
|
@ -208,6 +208,13 @@ describe("CollectionViewItemTree", function () {
|
|||
beforeEach(function () {
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
|
||||
// A fully built index by default, so no indexing banner appears
|
||||
stubs.push(sinon.stub(Zotero.Embeddings.Indexing, 'getStatus').returns({
|
||||
enabled: true,
|
||||
indexing: false,
|
||||
paused: false,
|
||||
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 0, eligible: 0 }]
|
||||
}));
|
||||
Zotero.Prefs.set('search.quicksearch-mode', 'bestMatch');
|
||||
});
|
||||
|
||||
|
|
@ -330,6 +337,63 @@ describe("CollectionViewItemTree", function () {
|
|||
}
|
||||
});
|
||||
|
||||
it("should show an indexing-progress banner while the index is incomplete", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "A", collections: [col.id] });
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.resolves(new Map([[item.id, 0.7]])));
|
||||
Zotero.Embeddings.Indexing.getStatus.returns({
|
||||
enabled: true,
|
||||
indexing: true,
|
||||
paused: false,
|
||||
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }]
|
||||
});
|
||||
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
// Localization is async, so poll for the translated counts (the
|
||||
// test times out on failure)
|
||||
let banner = win.document.querySelector('.best-match-index-banner');
|
||||
assert.ok(banner);
|
||||
while (!/752 of 9,553/.test(banner.textContent)) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
|
||||
// The paused wording requires the explicit paused flag -- the
|
||||
// indexer merely not running at the moment (startup, the pre-run
|
||||
// debounce) still reports indexing
|
||||
Zotero.Embeddings.Indexing.getStatus.returns({
|
||||
enabled: true,
|
||||
indexing: false,
|
||||
paused: false,
|
||||
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }]
|
||||
});
|
||||
await itemsView.setFilter('search', 'between runs query');
|
||||
banner = win.document.querySelector('.best-match-index-banner');
|
||||
assert.equal(banner.getAttribute('data-l10n-id'), 'items-best-match-indexing');
|
||||
Zotero.Embeddings.Indexing.getStatus.returns({
|
||||
enabled: true,
|
||||
indexing: false,
|
||||
paused: true,
|
||||
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }]
|
||||
});
|
||||
await itemsView.setFilter('search', 'paused query');
|
||||
banner = win.document.querySelector('.best-match-index-banner');
|
||||
assert.equal(banner.getAttribute('data-l10n-id'), 'items-best-match-indexing-paused');
|
||||
|
||||
// A complete index shows no banner
|
||||
Zotero.Embeddings.Indexing.getStatus.returns({
|
||||
enabled: true,
|
||||
indexing: false,
|
||||
paused: false,
|
||||
libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 9553, eligible: 9553 }]
|
||||
});
|
||||
await itemsView.setFilter('search', 'another query');
|
||||
assert.notOk(win.document.querySelector('.best-match-index-banner'));
|
||||
});
|
||||
|
||||
it("should score once across a multi-collection selection", async function () {
|
||||
let col1 = await createDataObject('collection');
|
||||
let col2 = await createDataObject('collection');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue