mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
lazily render fulltext search snippets in itemTree
Render search snippets in itemTree lazily, as the user scrolls to them. Fulltext table is contentless, so we cannot fetch snippet() for each search match. For embeddings, we need to fetch the structured-text to locate the right block. Both of these operations can take a long time when done to a lot of items in _refresh before rendering, which is why search snippets are extracted on demand. BestMatch.Session is a new object to wrap the interaction between the item tree and the search engines. BestMatch.Session.score returns the search results with an indication which of them should have search snippets. Not all search results do - purely semantic matches on abstracts or notes, as well as all matches on annotations get a snippet. ItemTree renders a placeholder child row for items that will have snippets. Based on the matches flag above, the itemTree renders placeholder rows. When the placeholder row is rendered, onSearchMatchRendered is called to tell BestMatch.Session which attachment's snippets need to be shown. BestMatch.Session maintains a queue and handles extracting of snippets when the browser is free to avoid freezing the main thread. When the snippets are extracted, the placeholder row is replaced with rows of search matches. BestMatch.Session maintains the state of what snippets were already extracted. Drop search result itemPane componenets, on a new search scroll the itemTree to the top to see the most relevant results.
This commit is contained in:
parent
100b7e1f52
commit
cc13ce7018
19 changed files with 1270 additions and 571 deletions
|
|
@ -43,11 +43,13 @@ const React = require('react');
|
|||
const ReactDOM = require('react-dom');
|
||||
const ItemTree = require('zotero/itemTree');
|
||||
const { ItemTreeRowProvider } = ItemTree;
|
||||
const { LibraryHeaderItemTreeRow, SpacerItemTreeRow } = require('zotero/itemTreeRow');
|
||||
const { LibraryHeaderItemTreeRow, SpacerItemTreeRow, SearchMatch } = require('zotero/itemTreeRow');
|
||||
|
||||
const { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs");
|
||||
const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.mjs');
|
||||
|
||||
const PRELOADED_MATCH_PREVIEWS = 10;
|
||||
|
||||
const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$");
|
||||
|
||||
// Minimal CollectionTreeRow-like object for callers that pass plain objects to
|
||||
|
|
@ -270,18 +272,36 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
let itemsByID = new Map(candidates.map(item => [item.id, item]));
|
||||
let scores;
|
||||
let generation = this._bestMatchGeneration;
|
||||
// The session scores the query and owns the match previews the tree
|
||||
// shows as child rows. A new query gets a fresh session -- the old
|
||||
// one's fills must never touch rows again -- while a re-score of the
|
||||
// same query (an item edit, an index update) keeps it, so
|
||||
// already-derived previews survive; the previews of the items that
|
||||
// actually changed are invalidated in notify().
|
||||
let session = this._bestMatchSession;
|
||||
let newQuery = !session || session.queryText !== query;
|
||||
if (newQuery) {
|
||||
session?.dispose();
|
||||
session = Zotero.BestMatch.createSession(query);
|
||||
session.onUpdate = itemIDs => this._onMatchPreviewsUpdate(session, itemIDs);
|
||||
this._bestMatchSession = session;
|
||||
}
|
||||
try {
|
||||
({ scores } = await Zotero.BestMatch.scoreItemIDs(query, [...itemsByID.keys()], {
|
||||
scores = await session.score([...itemsByID.keys()], {
|
||||
// A newer filter (e.g. more typed search text) makes this
|
||||
// query obsolete -- stop scoring and let its refresh take over
|
||||
shouldCancel: () => generation !== this._bestMatchGeneration
|
||||
}));
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Zotero.BestMatch.ScoringCancelledError) {
|
||||
throw e;
|
||||
}
|
||||
Zotero.logError(e);
|
||||
session.dispose();
|
||||
if (this._bestMatchSession == session) {
|
||||
this._bestMatchSession = null;
|
||||
}
|
||||
this._bestMatchRanks = new Map();
|
||||
this._bestMatchIndexState = await this._getBestMatchIndexState();
|
||||
// A rank-only search's membership doesn't depend on scoring, so
|
||||
|
|
@ -333,6 +353,24 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
ranks.set(item.treeViewID, rankOfScore.get(score));
|
||||
fractions.set(item.treeViewID, scores.get(itemID) || 0);
|
||||
}
|
||||
// A new query's results are shown from the top (see _refresh()), so
|
||||
// the previews the reader lands on are the best-ranked ones. Deriving
|
||||
// them before the rows appear is what keeps those rows from visibly
|
||||
// growing into their matches a moment after they're drawn; the rest
|
||||
// fill in on demand as they're scrolled to.
|
||||
if (newQuery) {
|
||||
this._scrollToTopOnUpdate = true;
|
||||
await session.preload(
|
||||
[...scores.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([itemID]) => itemID)
|
||||
.filter(itemID => session.getPreviews(itemID))
|
||||
.slice(0, PRELOADED_MATCH_PREVIEWS)
|
||||
);
|
||||
if (generation !== this._bestMatchGeneration) {
|
||||
throw new Zotero.BestMatch.ScoringCancelledError();
|
||||
}
|
||||
}
|
||||
let kept = [];
|
||||
for (let item of items) {
|
||||
if (!(item instanceof Zotero.Item) || !effectiveScores.has(item.id)) {
|
||||
|
|
@ -349,6 +387,89 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called for every pending search-match row the tree draws: rendering
|
||||
* is the demand signal for deriving previews. Reports are collected
|
||||
* across the render pass and flushed as one request on a microtask -- a
|
||||
* request per row would re-enter from the render that answers the first
|
||||
* one. Each flush replaces the session's previous request, so scrolling
|
||||
* past unfilled rows discards their work; rows still pending on screen
|
||||
* are restated by the re-render that follows each fill.
|
||||
*
|
||||
* @param {Number} itemID - The item whose pending preview was drawn
|
||||
*/
|
||||
onSearchMatchRendered(itemID) {
|
||||
if (!this._bestMatchSession) {
|
||||
return;
|
||||
}
|
||||
if (!this._renderedMatchItemIDs) {
|
||||
this._renderedMatchItemIDs = new Set();
|
||||
Promise.resolve().then(() => {
|
||||
let itemIDs = [...this._renderedMatchItemIDs];
|
||||
this._renderedMatchItemIDs = null;
|
||||
this._bestMatchSession?.request(itemIDs);
|
||||
});
|
||||
}
|
||||
this._renderedMatchItemIDs.add(itemID);
|
||||
}
|
||||
|
||||
/**
|
||||
* A session reported previews that settled: replace each affected
|
||||
* container's placeholder row with the derived match rows -- or with
|
||||
* nothing, when derivation found nothing to show. Runs after any
|
||||
* in-flight refresh, and only while the session is still the view's;
|
||||
* a superseded session's fills never touch rows. A selected placeholder
|
||||
* hands its selection to the first derived row.
|
||||
*
|
||||
* @param {Zotero.BestMatch.Session} session
|
||||
* @param {Number[]} itemIDs
|
||||
*/
|
||||
async _onMatchPreviewsUpdate(session, itemIDs) {
|
||||
try {
|
||||
// A refresh in flight materializes the settled previews itself
|
||||
await this.itemTree._refreshPromise;
|
||||
if (session !== this._bestMatchSession) {
|
||||
return;
|
||||
}
|
||||
this.itemTree._cacheState();
|
||||
let handoffID = null;
|
||||
let changed = false;
|
||||
for (let itemID of itemIDs) {
|
||||
let index = this._rowMap[itemID];
|
||||
// A collapsed container materializes its rows on reopen
|
||||
if (index === undefined || !this.isContainerOpen(index)) {
|
||||
continue;
|
||||
}
|
||||
let placeholderIndex = this._rowMap['SM' + itemID + '-pending'];
|
||||
if (placeholderIndex !== undefined
|
||||
&& this.itemTree.selection.isSelected(placeholderIndex)) {
|
||||
let preview = session.getPreviews(itemID);
|
||||
// The first derived row, or the container itself when
|
||||
// nothing derived
|
||||
handoffID = preview?.state == 'filled'
|
||||
? 'SM' + itemID + '-' + preview.entries[0].key
|
||||
: itemID;
|
||||
}
|
||||
this._refreshContainer(index, true);
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
this.refreshRowMap();
|
||||
if (handoffID !== null && this._rowMap[handoffID] !== undefined) {
|
||||
this.itemTree.selection.select(this._rowMap[handoffID]);
|
||||
}
|
||||
this.runListeners('update', true, {
|
||||
restoreSelection: handoffID === null,
|
||||
restoreScroll: true
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When showing multiple libraries, group rows by library in collections-list
|
||||
* order -- independent of the active sort direction
|
||||
|
|
@ -652,6 +773,11 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
});
|
||||
}
|
||||
// The ranking stage: one scoring pass over the merged results
|
||||
if (!bestMatchSearch && this._bestMatchSession) {
|
||||
// Leaving best-match search: the previews go with it
|
||||
this._bestMatchSession.dispose();
|
||||
this._bestMatchSession = null;
|
||||
}
|
||||
if (bestMatchSearch) {
|
||||
try {
|
||||
newSearchItems = await this._applyBestMatch(newSearchItems);
|
||||
|
|
@ -707,6 +833,11 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
if (!row.isObjectRow) {
|
||||
continue;
|
||||
}
|
||||
// Don't copy search-match rows -- they're rebuilt from the new
|
||||
// query's previews when their container reopens
|
||||
if (row.ref instanceof SearchMatch) {
|
||||
continue;
|
||||
}
|
||||
// Top-level items
|
||||
if (row.level == 0) {
|
||||
// A top-level attachment moved into a parent. Don't copy, it will be added
|
||||
|
|
@ -833,11 +964,18 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
if (!this.isContainer(i) || this.isContainerOpen(i)) {
|
||||
continue;
|
||||
}
|
||||
let item = this.getRow(i).ref;
|
||||
let row = this.getRow(i);
|
||||
if (!(row.ref instanceof Zotero.Item)) {
|
||||
continue;
|
||||
}
|
||||
let item = row.ref;
|
||||
let attachments = item.isRegularItem() ? item.getAttachments() : [];
|
||||
// expand item row if it is a parent of a match
|
||||
// OR if it has a child that is a parent of a match
|
||||
let shouldBeOpened = searchParentIDs.has(item.id) || attachments.some(id => searchParentIDs.has(id));
|
||||
// OR if it has best-match preview rows to show
|
||||
let shouldBeOpened = searchParentIDs.has(item.id)
|
||||
|| attachments.some(id => searchParentIDs.has(id))
|
||||
|| !!this._bestMatchSession?.getPreviews(item.id);
|
||||
if (shouldBeOpened) {
|
||||
this._toggleOpenState(i, true);
|
||||
}
|
||||
|
|
@ -861,6 +999,12 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
try {
|
||||
await this._refresh(options);
|
||||
|
||||
// A new best-match query shows its results from the top (see
|
||||
// _applyBestMatch()), wherever the previous ones were scrolled to
|
||||
if (this._scrollToTopOnUpdate) {
|
||||
this._scrollToTopOnUpdate = false;
|
||||
options = { ...options, scrollToTop: true };
|
||||
}
|
||||
this.runListeners('update', true, options);
|
||||
await this.itemTree.waitForLoad();
|
||||
this.itemTree.runListeners('refresh');
|
||||
|
|
@ -897,6 +1041,13 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
|||
const cachedSelection = this.itemTree._cachedSelection;
|
||||
const collectionTreeRows = this.collectionTreeRows;
|
||||
|
||||
// A changed item's derived match previews are stale: back to
|
||||
// placeholders, re-derived on their next render. The re-score the
|
||||
// change triggers below keeps every other item's derived text.
|
||||
if (type == 'item' && ['modify', 'refresh'].includes(action) && this._bestMatchSession) {
|
||||
this._bestMatchSession.invalidate(ids.map(id => parseInt(id)));
|
||||
}
|
||||
|
||||
var madeChanges = false;
|
||||
var refresh = false;
|
||||
var reuseSearchResults = false;
|
||||
|
|
|
|||
|
|
@ -74,8 +74,6 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
|||
['attachment-row', 'chrome://zotero/content/elements/attachmentRow.js'],
|
||||
['attachment-annotations-box', 'chrome://zotero/content/elements/attachmentAnnotationsBox.js'],
|
||||
['annotation-row', 'chrome://zotero/content/elements/annotationRow.js'],
|
||||
['search-results-box', 'chrome://zotero/content/elements/searchResultsBox.js'],
|
||||
['search-result-row', 'chrome://zotero/content/elements/searchResultRow.js'],
|
||||
['annotation-items-pane', 'chrome://zotero/content/elements/annotationItemsPane.js'],
|
||||
['context-notes-list', 'chrome://zotero/content/elements/contextNotesList.js'],
|
||||
['note-row', 'chrome://zotero/content/elements/noteRow.js'],
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@
|
|||
|
||||
<related-box id="zotero-editpane-related" class="zotero-editpane-related" data-pane="related"/>
|
||||
|
||||
<search-results-box id="zotero-editpane-search-results" data-pane="search-results" hidden="true"/>
|
||||
</html:div>
|
||||
</html:div>
|
||||
</hbox>
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@
|
|||
}
|
||||
|
||||
get _builtInPanes() {
|
||||
return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related", "search-results"];
|
||||
return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related"];
|
||||
}
|
||||
|
||||
get container() {
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
{
|
||||
// A best-match search result card: one excerpt of an item's text that
|
||||
// matches the query (see Zotero.BestMatch.getMatchingExcerpts()) --
|
||||
// where the excerpt came from as the head, its text as the quote,
|
||||
// presented like an annotation-row (the two share their styling, see
|
||||
// scss/elements/_annotationRow.scss). A semantic chunk heads with its
|
||||
// place in the document (outline path, section part, page); a lexical
|
||||
// excerpt heads with its source's name and marks its matched ranges in
|
||||
// the quote.
|
||||
class SearchResultRow extends XULElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<html:div class="head">
|
||||
<html:div class="title">
|
||||
<html:span class="path"/>
|
||||
<html:span class="part"/>
|
||||
</html:div>
|
||||
<html:div class="location"/>
|
||||
</html:div>
|
||||
<html:div class="body">
|
||||
<html:div class="quote"/>
|
||||
<html:button class="show-more" data-l10n-id="search-result-row-show-more" hidden="true"/>
|
||||
</html:div>
|
||||
`);
|
||||
|
||||
_result = null;
|
||||
|
||||
get result() {
|
||||
return this._result;
|
||||
}
|
||||
|
||||
set result(result) {
|
||||
this._result = result;
|
||||
this.render();
|
||||
}
|
||||
|
||||
init() {
|
||||
this._path = this.querySelector('.path');
|
||||
this._part = this.querySelector('.part');
|
||||
this._location = this.querySelector('.location');
|
||||
this._quote = this.querySelector('.quote');
|
||||
this._showMore = this.querySelector('.show-more');
|
||||
this._showMore.addEventListener('click', (event) => {
|
||||
// The card's activation (open the attachment) shouldn't fire
|
||||
// for the toggle
|
||||
event.stopPropagation();
|
||||
this._toggleExpanded();
|
||||
});
|
||||
this.render();
|
||||
}
|
||||
|
||||
// The head's label for where a lexical excerpt came from, localized
|
||||
// with the names the rest of the UI gives those parts of an item.
|
||||
// Attachment content ('content') isn't here: it labels with the same
|
||||
// generic fulltext string an outline-less chunk falls back to.
|
||||
_getSourceLabel(source) {
|
||||
switch (source) {
|
||||
case 'title':
|
||||
return Zotero.ItemFields.getLocalizedString('title');
|
||||
case 'abstract':
|
||||
return Zotero.ItemFields.getLocalizedString('abstractNote');
|
||||
case 'note':
|
||||
return Zotero.ItemTypes.getLocalizedString('note');
|
||||
case 'annotation':
|
||||
return Zotero.ItemTypes.getLocalizedString('annotation');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.initialized || !this._result) return;
|
||||
|
||||
// Where the excerpt came from: a chunk's outline path, a lexical
|
||||
// source's name, or the generic fulltext label
|
||||
let sourceLabel = this._getSourceLabel(this._result.source);
|
||||
if (this._result.outlinePath) {
|
||||
this._path.removeAttribute('data-l10n-id');
|
||||
this._path.textContent = this._result.outlinePath;
|
||||
}
|
||||
else if (sourceLabel) {
|
||||
this._path.removeAttribute('data-l10n-id');
|
||||
this._path.textContent = sourceLabel;
|
||||
}
|
||||
else {
|
||||
document.l10n.setAttributes(this._path, 'search-result-row-fulltext');
|
||||
}
|
||||
// Which piece of a split section this is, so a match reads as
|
||||
// coming from the middle or the end of its section
|
||||
let parts = this._result.sectionParts;
|
||||
this._part.hidden = !(parts > 1);
|
||||
if (parts > 1) {
|
||||
this._part.textContent = `${this._result.sectionPart}/${parts}`;
|
||||
}
|
||||
// The page the chunk's section starts on, labeled the way
|
||||
// annotation rows label theirs
|
||||
this._location.hidden = !this._result.pageLabel;
|
||||
if (this._result.pageLabel) {
|
||||
this._location.textContent
|
||||
= Zotero.getString('pdfReader.page') + ' ' + this._result.pageLabel;
|
||||
}
|
||||
|
||||
this._renderQuote();
|
||||
|
||||
// Offer "Show More" only when the quote is actually clamped,
|
||||
// which is only measurable once the card has a layout
|
||||
this.classList.remove('expanded');
|
||||
this._showMore.hidden = true;
|
||||
requestAnimationFrame(() => {
|
||||
this._showMore.hidden
|
||||
= this._quote.scrollHeight <= this._quote.clientHeight;
|
||||
});
|
||||
|
||||
// A11y - make focusable and describe the card
|
||||
this.setAttribute('tabindex', 0);
|
||||
this.setAttribute('aria-label', [
|
||||
this._result.outlinePath || sourceLabel,
|
||||
this._location.hidden ? '' : this._location.textContent,
|
||||
this._result.text
|
||||
].filter(Boolean).join('. '));
|
||||
}
|
||||
|
||||
// The excerpt's text, with any matched ranges wrapped for highlighting
|
||||
_renderQuote() {
|
||||
let text = this._result.text || '';
|
||||
let ranges = this._result.ranges || [];
|
||||
if (!ranges.length) {
|
||||
this._quote.textContent = text;
|
||||
return;
|
||||
}
|
||||
this._quote.replaceChildren();
|
||||
let position = 0;
|
||||
for (let [start, end] of ranges) {
|
||||
if (start > position) {
|
||||
this._quote.append(text.slice(position, start));
|
||||
}
|
||||
let match = document.createElement('span');
|
||||
match.className = 'match';
|
||||
match.textContent = text.slice(start, end);
|
||||
this._quote.append(match);
|
||||
position = end;
|
||||
}
|
||||
if (position < text.length) {
|
||||
this._quote.append(text.slice(position));
|
||||
}
|
||||
}
|
||||
|
||||
_toggleExpanded() {
|
||||
let expanded = this.classList.toggle('expanded');
|
||||
document.l10n.setAttributes(this._showMore,
|
||||
expanded ? 'search-result-row-show-less' : 'search-result-row-show-more');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('search-result-row', SearchResultRow);
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
/*
|
||||
***** BEGIN LICENSE BLOCK *****
|
||||
|
||||
Copyright © 2026 Corporation for Digital Scholarship
|
||||
Vienna, Virginia, USA
|
||||
https://www.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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** END LICENSE BLOCK *****
|
||||
*/
|
||||
|
||||
{
|
||||
const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
|
||||
"chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
|
||||
{ global: "current" }
|
||||
);
|
||||
|
||||
// Most match excerpts shown for an item
|
||||
const MAX_RESULTS = 5;
|
||||
|
||||
// Why the selected item matched the active best-match search: cards with
|
||||
// excerpts of the item's own text around the matches (see
|
||||
// Zotero.BestMatch.getMatchingExcerpts()), so they can be read without
|
||||
// opening anything. Shown only while a best-match search is active, for
|
||||
// items with something to show.
|
||||
class SearchResultsBox extends ItemPaneSectionElementBase {
|
||||
content = MozXULElement.parseXULToFragment(`
|
||||
<collapsible-section data-l10n-id="section-search-results" data-pane="search-results">
|
||||
<html:div class="body">
|
||||
</html:div>
|
||||
</collapsible-section>
|
||||
`);
|
||||
|
||||
get item() {
|
||||
return this._item;
|
||||
}
|
||||
|
||||
set item(item) {
|
||||
super.item = item instanceof Zotero.Item ? item : null;
|
||||
// A new item's emptiness isn't known until asyncRender scores it
|
||||
this._count = undefined;
|
||||
}
|
||||
|
||||
get collectionTreeRows() {
|
||||
return super.collectionTreeRows;
|
||||
}
|
||||
|
||||
// The item pane sets collectionTreeRows after item, so this is where
|
||||
// everything visibility depends on is finally known
|
||||
set collectionTreeRows(collectionTreeRows) {
|
||||
super.collectionTreeRows = collectionTreeRows;
|
||||
this._updateHidden();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.initCollapsibleSection();
|
||||
this._body = this.querySelector('.body');
|
||||
// The header's count placeholder needs a value before the first
|
||||
// async render fills in the real one
|
||||
this._section.setCount(0);
|
||||
// Double-click (or Enter on a focused card) opens the attachment
|
||||
// at the chunk
|
||||
this._body.addEventListener('dblclick', this._handleActivate);
|
||||
this._body.addEventListener('keydown', (event) => {
|
||||
if (event.key == 'Enter') {
|
||||
this._handleActivate(event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The query the selected collection rows are ranked by, or false when
|
||||
// no best-match search is active. Rows can be duck-typed stand-ins
|
||||
// (e.g. the citation dialog's), which implement only part of the row
|
||||
// API.
|
||||
get _query() {
|
||||
// Quick-search state (setSearch()) lives on collection tree row
|
||||
// *instances*, and the ones passed down the item pane are
|
||||
// re-fetched from the collections view at item-selection time --
|
||||
// which can have rebuilt its rows since the items view got its
|
||||
// own set. Only the items view's instances are guaranteed to
|
||||
// carry the active search, so prefer those; the passed rows are
|
||||
// the fallback for hosts without an items view.
|
||||
let itemsView = this.closest('item-pane')?.itemsView;
|
||||
let rows = itemsView?.collectionTreeRows?.length
|
||||
? itemsView.collectionTreeRows
|
||||
: this.collectionTreeRows;
|
||||
for (let row of rows || []) {
|
||||
if (typeof row.getBestMatchQuery == 'function') {
|
||||
let query = row.getBestMatchQuery();
|
||||
if (query) {
|
||||
return query;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A query change re-renders even when the item didn't change
|
||||
get _renderDependencies() {
|
||||
return [...super._renderDependencies, this._query];
|
||||
}
|
||||
|
||||
render() {}
|
||||
|
||||
async asyncRender() {
|
||||
if (!this.initialized) return;
|
||||
if (this._isAlreadyRendered("async")) return;
|
||||
|
||||
let item = this.item;
|
||||
let query = this._query;
|
||||
this._body.replaceChildren();
|
||||
if (!item || !query) {
|
||||
return;
|
||||
}
|
||||
|
||||
let excerpts = [];
|
||||
try {
|
||||
excerpts = await Zotero.BestMatch.getMatchingExcerpts(query, item.id,
|
||||
{ limit: MAX_RESULTS });
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
}
|
||||
// The selection may have moved on while scoring
|
||||
if (this.item !== item) {
|
||||
return;
|
||||
}
|
||||
this._count = excerpts.length;
|
||||
this._section.setCount(excerpts.length);
|
||||
this._updateHidden();
|
||||
// Left in the order getMatchingExcerpts() returns them, strongest
|
||||
// match first: with only a handful of cards shown, the best one
|
||||
// earning the top slot matters more than reading them in
|
||||
// document order
|
||||
for (let excerpt of excerpts) {
|
||||
let row = document.createXULElement('search-result-row');
|
||||
row.result = excerpt;
|
||||
this._body.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
// For a file attachment, open it where the activated card's excerpt
|
||||
// is: for a PDF with a stored chunk position, scrolled to and
|
||||
// highlighting the section; without one (EPUB, snapshot, a lexical
|
||||
// excerpt), just open it. Other item types show their matched text in
|
||||
// the pane already, so a card activation has nowhere to go.
|
||||
_handleActivate = (event) => {
|
||||
let row = event.target.closest('search-result-row');
|
||||
// The Show More toggle isn't an activation
|
||||
if (!row || !this.item || !this.item.isFileAttachment()
|
||||
|| event.target.closest('.show-more')) {
|
||||
return;
|
||||
}
|
||||
if (typeof ZoteroPane == 'undefined') {
|
||||
return;
|
||||
}
|
||||
let position = row.result?.position;
|
||||
ZoteroPane.viewAttachment(this.item.id, null, false,
|
||||
position ? { location: { position } } : undefined)
|
||||
.catch(e => Zotero.logError(e));
|
||||
};
|
||||
|
||||
_updateHidden() {
|
||||
// Visible only during a best-match search; asyncRender hides it
|
||||
// again when nothing matched. Deciding emptiness needs the async
|
||||
// scoring, so unlike the annotations section this one can't know
|
||||
// its final state synchronously -- it appears, then empties out,
|
||||
// rather than flickering in late.
|
||||
this.hidden = !this.item || !this._query || this.tabType == 'reader'
|
||||
|| this._count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("search-results-box", SearchResultsBox);
|
||||
}
|
||||
|
|
@ -107,6 +107,9 @@ class ItemTreeRowProvider {
|
|||
this._searchItemIDs = new Set();
|
||||
this._searchParentIDs = new Set();
|
||||
this._includeTrashed = false;
|
||||
// The best-match search session whose previews rows show as match
|
||||
// children (see SearchMatch in itemTreeRow.js), while one is active
|
||||
this._bestMatchSession = null;
|
||||
this.onUpdate = this.createEventBinding('update');
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +234,7 @@ class ItemTreeRowProvider {
|
|||
}
|
||||
return row.isContainerEmpty({
|
||||
includeTrashed: this._includeTrashed,
|
||||
getMatchPreviews: this._bestMatchSession?.getPreviews,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -248,8 +252,22 @@ class ItemTreeRowProvider {
|
|||
|
||||
_refreshContainer(index, skipRowMapRefresh = false) {
|
||||
if (!this.isContainer(index)) return;
|
||||
// Reopening recreates child rows closed, so remember which
|
||||
// descendants were open and reopen them afterward
|
||||
let level = this.getLevel(index);
|
||||
let openDescendantIDs = [];
|
||||
for (let i = index + 1; i < this._rows.length && this.getLevel(i) > level; i++) {
|
||||
if (this.isContainer(i) && this.isContainerOpen(i)) {
|
||||
openDescendantIDs.push(this.getRow(i).id);
|
||||
}
|
||||
}
|
||||
this._closeContainer(index, true);
|
||||
this._openContainer(index, true);
|
||||
if (openDescendantIDs.length) {
|
||||
// _restoreOpenState() looks rows up by id
|
||||
this.refreshRowMap();
|
||||
this._restoreOpenState(openDescendantIDs);
|
||||
}
|
||||
if (!skipRowMapRefresh) {
|
||||
this.refreshRowMap();
|
||||
}
|
||||
|
|
@ -282,6 +300,7 @@ class ItemTreeRowProvider {
|
|||
searchItemIDs: this._searchItemIDs,
|
||||
includeTrashed: this._includeTrashed,
|
||||
filterChildItems: this.itemTree.props.filterChildItems,
|
||||
getMatchPreviews: this._bestMatchSession?.getPreviews,
|
||||
});
|
||||
|
||||
let childRows = childRefs.map(ref => this.createRow(ref, level + 1, false));
|
||||
|
|
@ -1280,6 +1299,8 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
* @param {boolean} options.restoreSelection - Whether to restore the cached selection.
|
||||
* @param {boolean} options.ensureRowsAreVisible - Whether to ensure selected rows are visible.
|
||||
* @param {boolean} options.restoreScroll - Whether to restore the cached scroll position.
|
||||
* @param {boolean} options.scrollToTop - Whether to show the list from the top, ignoring
|
||||
* the cached scroll position.
|
||||
* @param {boolean} options.loading - Whether to show loading state (hides tree, shows message).
|
||||
* @param {string} options.message - Optional message to display (for loading, errors, intro text).
|
||||
*/
|
||||
|
|
@ -1338,7 +1359,8 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
|
||||
const itemsViewInActiveWindow = Zotero.getActiveZoteroPane()?.itemsView == this;
|
||||
const prioritizeRestore = !(options.selectInActiveWindow && itemsViewInActiveWindow);
|
||||
const ensureVisible = options.restoreScroll ? false : options.ensureRowsAreVisible;
|
||||
const ensureVisible = options.restoreScroll || options.scrollToTop
|
||||
? false : options.ensureRowsAreVisible;
|
||||
|
||||
if (prioritizeRestore && options.restoreSelection) {
|
||||
this._restoreSelection(null, options.expandCollapsedParents, ensureVisible);
|
||||
|
|
@ -1352,7 +1374,10 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
}
|
||||
}
|
||||
|
||||
if (options.restoreScroll) {
|
||||
if (options.scrollToTop) {
|
||||
this._treebox?.scrollTo(0);
|
||||
}
|
||||
else if (options.restoreScroll) {
|
||||
this._restoreScrollPosition();
|
||||
}
|
||||
|
||||
|
|
@ -2358,6 +2383,13 @@ var ItemTree = class ItemTree extends LibraryTree {
|
|||
|
||||
row.renderRow(div, index, columns, rowData, this._renderCtx);
|
||||
|
||||
// A pending search-match row on screen is the demand signal for
|
||||
// deriving its item's previews: the virtualized list only renders
|
||||
// what's visible, so rendering names exactly what's worth deriving
|
||||
if (row.type == 'search-match-placeholder') {
|
||||
this.rowProvider.onSearchMatchRendered?.(row.ref.itemID);
|
||||
}
|
||||
|
||||
if (!oldDiv) {
|
||||
if (this.props.dragAndDrop && row.isDraggable) {
|
||||
div.setAttribute('draggable', true);
|
||||
|
|
|
|||
|
|
@ -397,9 +397,13 @@ const COLUMNS = [
|
|||
renderCell(index, data, column, isFirstColumn, doc) {
|
||||
let cell = doc.createElement('span');
|
||||
cell.className = `cell ${column.className}`;
|
||||
let fraction = this.rowProvider.getBestMatchBarFractions()
|
||||
.get(this.getRow(index).id);
|
||||
if (fraction !== undefined) {
|
||||
let row = this.getRow(index);
|
||||
// Rows that carry their own relevance (e.g. a search-match row,
|
||||
// showing the strength of the evidence it displays) report it
|
||||
// themselves; every other row's bar comes from the view's scores
|
||||
let fraction = this.rowProvider.getBestMatchBarFractions().get(row.id)
|
||||
?? row.getRelevanceFraction();
|
||||
if (fraction !== null && fraction !== undefined) {
|
||||
let bar = doc.createElement('span');
|
||||
bar.className = 'relevance-bar';
|
||||
let fill = doc.createElement('span');
|
||||
|
|
@ -408,9 +412,11 @@ const COLUMNS = [
|
|||
bar.append(fill);
|
||||
cell.append(bar);
|
||||
// The rank reaches assistive technology via the row label; show
|
||||
// it visually as a tooltip
|
||||
doc.l10n.formatValue('items-column-relevance-rank', { rank: data })
|
||||
.then(label => cell.title = label);
|
||||
// it visually as a tooltip. Match rows carry no rank of their own.
|
||||
if (data) {
|
||||
doc.l10n.formatValue('items-column-relevance-rank', { rank: data })
|
||||
.then(label => cell.title = label);
|
||||
}
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,6 +103,17 @@ class ItemTreeRow {
|
|||
return getCSSItemTypeIcon('document');
|
||||
}
|
||||
|
||||
/**
|
||||
* The 0-1 fraction the Relevance column's bar shows for this row on its
|
||||
* own, for rows carrying their own relevance rather than taking it from
|
||||
* the view's best-match scores, or null for rows that don't
|
||||
*
|
||||
* @return {Number|null}
|
||||
*/
|
||||
getRelevanceFraction() {
|
||||
return null;
|
||||
}
|
||||
|
||||
renderRow(div, index, columns, rowData, renderCtx) {
|
||||
for (let column of columns) {
|
||||
if (column.hidden) continue;
|
||||
|
|
@ -461,11 +472,16 @@ class FileItemTreeRow extends ZoteroItemTreeRow {
|
|||
return true;
|
||||
}
|
||||
|
||||
isContainerEmpty() {
|
||||
isContainerEmpty({ getMatchPreviews } = {}) {
|
||||
// An attachment with search matches to show can be expanded even
|
||||
// with no annotations of its own
|
||||
if (getMatchPreviews?.(this.ref.id)) {
|
||||
return false;
|
||||
}
|
||||
return this.ref.numAnnotations() == 0;
|
||||
}
|
||||
|
||||
getChildItems({ searchMode, searchItemIDs } = {}) {
|
||||
getChildItems({ searchMode, searchItemIDs, getMatchPreviews } = {}) {
|
||||
let annotations = this.ref.getAnnotations();
|
||||
// With "Hide Non-Matching Annotations" enabled, if any of the attachment's
|
||||
// annotations match a search, show only those and hide the rest. If none match,
|
||||
|
|
@ -477,7 +493,8 @@ class FileItemTreeRow extends ZoteroItemTreeRow {
|
|||
annotations = matches;
|
||||
}
|
||||
}
|
||||
return annotations;
|
||||
// Fulltext match rows come after the annotations
|
||||
return [...annotations, ...SearchMatch.forItem(this.ref, getMatchPreviews)];
|
||||
}
|
||||
|
||||
_supportsBestAttachmentState() {
|
||||
|
|
@ -574,6 +591,155 @@ class AnnotationItemTreeRow extends ZoteroItemTreeRow {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference a search-match row wraps: one place a best-match search
|
||||
* matched inside an item, or -- with no entry yet -- a stand-in for that
|
||||
* item's matches while its preview is still being derived.
|
||||
*
|
||||
* Item tree rows normally wrap data objects. A preview isn't a stored
|
||||
* object, so this stands in as the tree's reference to one.
|
||||
*/
|
||||
class SearchMatch {
|
||||
constructor(itemID, entry = null) {
|
||||
this.itemID = itemID;
|
||||
// A preview entry (see Zotero.BestMatch.Session#getPreviews()), or
|
||||
// null while the item's previews are still pending
|
||||
this.entry = entry;
|
||||
this.treeViewID = 'SM' + itemID + (entry ? '-' + entry.key : '-pending');
|
||||
this.id = this.treeViewID;
|
||||
}
|
||||
|
||||
get isPending() {
|
||||
return !this.entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* The search-match refs to materialize under an item, from its
|
||||
* best-match preview: one pending ref while the preview is being
|
||||
* derived, one ref per derived entry once it's filled, and nothing when
|
||||
* the item has no preview or its preview derived nothing.
|
||||
*
|
||||
* @param {Zotero.Item} item
|
||||
* @param {Function} [getMatchPreviews] - itemID -> preview accessor (see
|
||||
* Zotero.BestMatch.Session#getPreviews()), passed by the row
|
||||
* provider while a best-match search is active
|
||||
* @return {SearchMatch[]}
|
||||
*/
|
||||
static forItem(item, getMatchPreviews) {
|
||||
let preview = getMatchPreviews?.(item.id);
|
||||
if (!preview) {
|
||||
return [];
|
||||
}
|
||||
if (preview.state == 'pending') {
|
||||
return [new SearchMatch(item.id)];
|
||||
}
|
||||
return preview.entries.map(entry => new SearchMatch(item.id, entry));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row showing one place a best-match search matched inside its parent row's
|
||||
* item: a derived excerpt with its matches highlighted. The ref is a
|
||||
* SearchMatch carrying the preview entry it shows.
|
||||
*/
|
||||
class SearchMatchItemTreeRow extends ItemTreeRow {
|
||||
get type() {
|
||||
return 'search-match';
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
return this.ref.entry.text;
|
||||
}
|
||||
|
||||
getField(field) {
|
||||
if (field == 'title') {
|
||||
return this.getDisplayTitle();
|
||||
}
|
||||
return super.getField(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* A match row's bar shows the strength of the evidence it displays,
|
||||
* rather than its item's relevance
|
||||
*/
|
||||
getRelevanceFraction() {
|
||||
return this.ref.entry?.strength ?? null;
|
||||
}
|
||||
|
||||
getIcon() {
|
||||
let icon = getCSSIcon('search');
|
||||
icon.classList.add('icon-item-type');
|
||||
return icon;
|
||||
}
|
||||
|
||||
renderRow(div, index, columns, rowData, renderCtx) {
|
||||
let titleColumn = Object.assign(
|
||||
{},
|
||||
columns.find(column => column.dataKey == 'title'),
|
||||
{ className: 'title' }
|
||||
);
|
||||
div.appendChild(renderCtx.renderCell(index, rowData.title, titleColumn, true));
|
||||
// The relevance bar while a best-match search shows the Relevance column
|
||||
let relevanceColumn = columns.find(column => column.dataKey == 'relevance');
|
||||
if (relevanceColumn && !relevanceColumn.hidden) {
|
||||
let cell = renderCtx.renderCell(index, rowData?.relevance, relevanceColumn, false);
|
||||
if (cell) {
|
||||
div.appendChild(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderPrimaryCell(index, data, column) {
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className} primary`;
|
||||
let textSpan = document.createElement('span');
|
||||
textSpan.className = 'cell-text';
|
||||
let { text, ranges } = this.ref.entry;
|
||||
let last = 0;
|
||||
for (let [start, end] of ranges || []) {
|
||||
if (start > last) {
|
||||
textSpan.append(text.slice(last, start));
|
||||
}
|
||||
let mark = document.createElement('span');
|
||||
mark.className = 'search-match-highlight';
|
||||
mark.textContent = text.slice(start, end);
|
||||
textSpan.append(mark);
|
||||
last = end;
|
||||
}
|
||||
if (last < text.length) {
|
||||
textSpan.append(text.slice(last));
|
||||
}
|
||||
span.append(textSpan);
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row standing in for an item's search-match rows while its preview is
|
||||
* still pending: a single row showing that matches are on their way, which
|
||||
* the fill replaces with the item's SearchMatchItemTreeRows. The ref is a
|
||||
* SearchMatch with no entry yet.
|
||||
*/
|
||||
class SearchMatchPlaceholderItemTreeRow extends SearchMatchItemTreeRow {
|
||||
get type() {
|
||||
return 'search-match-placeholder';
|
||||
}
|
||||
|
||||
getDisplayTitle() {
|
||||
return '';
|
||||
}
|
||||
|
||||
renderPrimaryCell(index, data, column) {
|
||||
let span = document.createElement('span');
|
||||
span.className = `cell ${column.className} primary`;
|
||||
let textSpan = document.createElement('span');
|
||||
textSpan.className = 'cell-text search-match-pending';
|
||||
textSpan.textContent = Zotero.ftl.formatValueSync('items-search-match-pending');
|
||||
span.append(textSpan);
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Row wrapping a Zotero.Collection (shown in trash view).
|
||||
*/
|
||||
|
|
@ -751,6 +917,11 @@ class SpacerItemTreeRow extends ItemTreeRow {
|
|||
ItemTreeRow.create = function (ref, level, isOpen) {
|
||||
if (ref instanceof Zotero.Collection) return new CollectionItemTreeRow(ref, level, isOpen);
|
||||
if (ref instanceof Zotero.Search) return new SearchItemTreeRow(ref, level, isOpen);
|
||||
if (ref instanceof SearchMatch) {
|
||||
return ref.isPending
|
||||
? new SearchMatchPlaceholderItemTreeRow(ref, level, isOpen)
|
||||
: new SearchMatchItemTreeRow(ref, level, isOpen);
|
||||
}
|
||||
if (ref.isAnnotation?.()) return new AnnotationItemTreeRow(ref, level, isOpen);
|
||||
if (ref.isFileAttachment?.()) return new FileItemTreeRow(ref, level, isOpen);
|
||||
return new ZoteroItemTreeRow(ref, level, isOpen);
|
||||
|
|
@ -761,6 +932,9 @@ module.exports.ItemTreeRow = ItemTreeRow;
|
|||
module.exports.ZoteroItemTreeRow = ZoteroItemTreeRow;
|
||||
module.exports.FileItemTreeRow = FileItemTreeRow;
|
||||
module.exports.AnnotationItemTreeRow = AnnotationItemTreeRow;
|
||||
module.exports.SearchMatchItemTreeRow = SearchMatchItemTreeRow;
|
||||
module.exports.SearchMatchPlaceholderItemTreeRow = SearchMatchPlaceholderItemTreeRow;
|
||||
module.exports.SearchMatch = SearchMatch;
|
||||
module.exports.CollectionItemTreeRow = CollectionItemTreeRow;
|
||||
module.exports.SearchItemTreeRow = SearchItemTreeRow;
|
||||
module.exports.SpacerItemTreeRow = SpacerItemTreeRow;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ Zotero.BestMatch = new function () {
|
|||
return Zotero.Embeddings.isEnabled();
|
||||
}
|
||||
|
||||
function _hasPreviews(itemID) {
|
||||
return !!Zotero.Items.get(itemID)?.isFileAttachment?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a query has anything for best-match search to rank by. The
|
||||
* lexical engine needs at least one scoring unit; failing that, the
|
||||
|
|
@ -171,74 +175,310 @@ Zotero.BestMatch = new function () {
|
|||
};
|
||||
|
||||
/**
|
||||
* Excerpts showing why an item matches a query, for the item pane's
|
||||
* search-results section: the union of both engines' evidence, so an
|
||||
* item ranked by either kind of match -- or both -- explains itself.
|
||||
* A best-match search session: one query's scoring pass plus the
|
||||
* previews explaining its matches, derived on demand.
|
||||
*
|
||||
* The lexical engine's excerpts around the query's literal matches (see
|
||||
* Zotero.Lexical.getMatchingExcerpts()) are always collected; they carry
|
||||
* a source name and highlight ranges. With a semantic model enabled, the
|
||||
* item's most similar indexed chunks join them (see
|
||||
* Zotero.Embeddings.getMatchingChunks()), carrying document locations --
|
||||
* with the query's literal matches highlighted within their text too, so
|
||||
* a chunk that's both similar and a literal hit tells both at once. A
|
||||
* lexical fulltext excerpt whose match a shown chunk already covers is
|
||||
* dropped as redundant; one from a passage no chunk surfaced stays.
|
||||
*
|
||||
* Entries are ordered by the strength of the evidence they show, each on
|
||||
* its engine's 0-1 display scale, and capped at the limit together. A
|
||||
* semantic index that isn't ready contributes nothing, leaving the
|
||||
* lexical excerpts alone.
|
||||
*
|
||||
* @param {String} queryText
|
||||
* @param {Number} itemID
|
||||
* @param {Object} [options]
|
||||
* @param {Number} [options.limit=5] - Most entries to return
|
||||
* @return {Promise<Object[]>} - Entries with `text`, `ranges`, and
|
||||
* `strength`, plus chunk location fields or a lexical `source`
|
||||
* score() ranks candidates and synchronously builds a pending preview
|
||||
* per matched item that has anything to show, with no I/O. request()
|
||||
* names the items whose previews are wanted next; each call replaces
|
||||
* the last, so only what is still wanted gets derived, and repeating a
|
||||
* request is free. Derivation runs one item at a time, each waiting
|
||||
* first for a moment when the main thread has nothing else to do. An
|
||||
* item's entries arrive all at once -- both engines' evidence, merged,
|
||||
* deduplicated and ordered by strength (see getMatchingExcerpts()) --
|
||||
* and onUpdate reports each item whose preview settled; consumers read
|
||||
* them back with getPreviews(). A disposed session derives nothing and
|
||||
* never calls onUpdate.
|
||||
*/
|
||||
this.getMatchingExcerpts = async function (queryText, itemID, options = {}) {
|
||||
// Temporary, for testing: the bestMatchEngine pref keeps the pinned
|
||||
// engine's excerpts alone -- no lexical excerpts or highlights when
|
||||
// pinned semantic, no chunks when pinned lexical
|
||||
let engine = Zotero.Prefs.get('search.bestMatchEngine');
|
||||
let limit = options.limit ?? 5;
|
||||
let excerpts = engine == 'semantic'
|
||||
? []
|
||||
: await Zotero.Lexical.getMatchingExcerpts(queryText, itemID, options);
|
||||
if (engine == 'lexical' || !_useSemantic()
|
||||
|| !Zotero.Embeddings.normalizeQuery(queryText || '')) {
|
||||
return excerpts;
|
||||
this.Session = class {
|
||||
constructor(queryText) {
|
||||
// Called with the itemIDs whose previews settled since the last
|
||||
// call, from filling or from a failed derivation
|
||||
this.onUpdate = null;
|
||||
this._queryText = queryText;
|
||||
this._previews = new Map();
|
||||
this._queue = [];
|
||||
this._inFlight = new Set();
|
||||
this._pumping = false;
|
||||
this._disposed = false;
|
||||
this._scoreGeneration = 0;
|
||||
}
|
||||
let chunks = [];
|
||||
try {
|
||||
chunks = await Zotero.Embeddings.getMatchingChunks(queryText, itemID, options);
|
||||
// Only fulltext chunks carry their own text; item-level matches
|
||||
// have nothing to excerpt
|
||||
chunks = chunks.filter(chunk => chunk.text);
|
||||
|
||||
get queryText() {
|
||||
return this._queryText;
|
||||
}
|
||||
catch (e) {
|
||||
if (!(e instanceof Zotero.Embeddings.IndexNotReadyError)) {
|
||||
throw e;
|
||||
|
||||
/**
|
||||
* Score candidates for this session's query (see
|
||||
* Zotero.BestMatch.scoreItemIDs()) and rebuild the preview set from
|
||||
* the engines' match sets, synchronously and with no I/O once
|
||||
* scoring resolves: a pending preview for every item a preview is
|
||||
* shown for that some engine can show match excerpts in. Items still
|
||||
* matched keep their settled previews -- a re-score doesn't drop
|
||||
* derived text -- and items no longer matched lose theirs. A scoring
|
||||
* pass superseded by a newer one on the same session leaves the
|
||||
* previews to the newer pass.
|
||||
*
|
||||
* @param {Number[]} itemIDs - Candidate item IDs to score
|
||||
* @param {Object} [options] - Passed through to scoreItemIDs()
|
||||
* @return {Promise<Map>} - itemID -> score, as scoreItemIDs() returns
|
||||
* @throws {Zotero.BestMatch.ScoringCancelledError}
|
||||
*/
|
||||
async score(itemIDs, options = {}) {
|
||||
let generation = ++this._scoreGeneration;
|
||||
let { scores, matches } = await Zotero.BestMatch.scoreItemIDs(
|
||||
this._queryText, itemIDs, options);
|
||||
if (this._disposed || generation != this._scoreGeneration) {
|
||||
return scores;
|
||||
}
|
||||
let previews = new Map();
|
||||
for (let itemID of new Set([...matches.lexical, ...matches.semantic])) {
|
||||
if (!_hasPreviews(itemID)) {
|
||||
continue;
|
||||
}
|
||||
let existing = this._previews.get(itemID);
|
||||
if (existing && existing.state != 'pending') {
|
||||
previews.set(itemID, existing);
|
||||
continue;
|
||||
}
|
||||
previews.set(itemID, {
|
||||
state: 'pending',
|
||||
entries: [],
|
||||
lexical: matches.lexical.has(itemID),
|
||||
semantic: matches.semantic.has(itemID)
|
||||
});
|
||||
}
|
||||
this._previews = previews;
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preview to show for an item, or null when there's nothing to
|
||||
* show: no preview for it (see _hasPreviews()), or one that derived
|
||||
* nothing after all. Passed to consumers as a bare function, so it's
|
||||
* bound to its session.
|
||||
*
|
||||
* @param {Number} itemID
|
||||
* @return {Object|null} - { state, entries }: state is 'pending'
|
||||
* (placeholder) or 'filled'; entries are the derived entries
|
||||
* (see getMatchingExcerpts()), each with a `key` unique within
|
||||
* the preview and stable for as long as the preview stays filled
|
||||
*/
|
||||
getPreviews = (itemID) => {
|
||||
let preview = this._previews.get(itemID);
|
||||
return preview && preview.state != 'empty' ? preview : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive previews for a small batch of items immediately.
|
||||
*
|
||||
* @param {Number[]} itemIDs
|
||||
*/
|
||||
async preload(itemIDs) {
|
||||
for (let itemID of itemIDs) {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
await this._settle(itemID);
|
||||
}
|
||||
}
|
||||
if (!chunks.length) {
|
||||
return excerpts;
|
||||
|
||||
/**
|
||||
* Ask for the given items' previews to be derived next. Each call
|
||||
* replaces the previous request -- items no longer asked for aren't
|
||||
* derived -- and items already settled or mid-derivation are
|
||||
* skipped, so repeating a request is free.
|
||||
*
|
||||
* @param {Number[]} itemIDs
|
||||
*/
|
||||
request(itemIDs) {
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
this._queue = itemIDs.filter((itemID) => {
|
||||
return this._previews.get(itemID)?.state == 'pending'
|
||||
&& !this._inFlight.has(itemID);
|
||||
});
|
||||
this._pump();
|
||||
}
|
||||
let ranges = engine == 'semantic'
|
||||
? chunks.map(() => [])
|
||||
: await Zotero.Lexical.findMatchRanges(
|
||||
queryText, chunks.map(chunk => chunk.text));
|
||||
chunks = chunks.map((chunk, i) => ({
|
||||
...chunk,
|
||||
ranges: ranges[i],
|
||||
strength: Zotero.Embeddings.getScoreFraction(chunk.score)
|
||||
}));
|
||||
excerpts = excerpts.filter(
|
||||
excerpt => excerpt.source != 'content' || !_coveredByChunk(excerpt, chunks));
|
||||
return [...chunks, ...excerpts]
|
||||
.sort((a, b) => (b.strength || 0) - (a.strength || 0))
|
||||
.slice(0, limit);
|
||||
|
||||
/**
|
||||
* Drop the given items' previews back to placeholders, for items
|
||||
* whose content changed and made derived text stale
|
||||
*
|
||||
* @param {Number[]} itemIDs
|
||||
*/
|
||||
invalidate(itemIDs) {
|
||||
for (let itemID of itemIDs) {
|
||||
let preview = this._previews.get(itemID);
|
||||
if (!preview) {
|
||||
continue;
|
||||
}
|
||||
// A fresh object, so a fill of the old one that's still in
|
||||
// flight can't settle it (see _fill())
|
||||
this._previews.set(itemID, {
|
||||
...preview,
|
||||
state: 'pending',
|
||||
entries: []
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the session: abandon queued and in-flight derivation. A
|
||||
* disposed session derives nothing and never calls onUpdate.
|
||||
*/
|
||||
dispose() {
|
||||
this._disposed = true;
|
||||
this._queue = [];
|
||||
this.onUpdate = null;
|
||||
}
|
||||
|
||||
// Derive queued previews one at a time, each first waiting for a
|
||||
// moment when the main thread has nothing else to do. The queue is
|
||||
// read one item per turn, so a request() arriving mid-derivation
|
||||
// takes effect at the very next item.
|
||||
async _pump() {
|
||||
if (this._pumping) {
|
||||
return;
|
||||
}
|
||||
this._pumping = true;
|
||||
try {
|
||||
while (!this._disposed && this._queue.length) {
|
||||
await new Promise(
|
||||
resolve => Services.tm.idleDispatchToMainThread(resolve));
|
||||
if (this._disposed) {
|
||||
return;
|
||||
}
|
||||
let itemID = this._queue.shift();
|
||||
if (!await this._settle(itemID)) {
|
||||
continue;
|
||||
}
|
||||
if (!this._disposed && this.onUpdate) {
|
||||
try {
|
||||
this.onUpdate([itemID]);
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
this._pumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive one pending item's preview, reporting whether it settled
|
||||
// here: an item already settled or mid-derivation elsewhere is left
|
||||
// alone. A derivation that failed would fail again, so it settles for
|
||||
// showing nothing rather than being retried.
|
||||
async _settle(itemID) {
|
||||
let preview = this._previews.get(itemID);
|
||||
if (!preview || preview.state != 'pending' || this._inFlight.has(itemID)) {
|
||||
return false;
|
||||
}
|
||||
this._inFlight.add(itemID);
|
||||
try {
|
||||
await this._fill(itemID, preview);
|
||||
}
|
||||
catch (e) {
|
||||
Zotero.logError(e);
|
||||
preview.state = 'empty';
|
||||
}
|
||||
finally {
|
||||
this._inFlight.delete(itemID);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every excerpt explaining why an item matched this session's query:
|
||||
* the lexical engine's excerpts around the query's literal matches
|
||||
* (see Zotero.Lexical.getMatchingExcerpts()) merged with the item's
|
||||
* most similar indexed chunks (see
|
||||
* Zotero.Embeddings.getMatchingChunks()), which carry document
|
||||
* locations and have those literal matches highlighted within them
|
||||
* too. A lexical fulltext excerpt a shown chunk already covers is
|
||||
* dropped as redundant, and what's left is ordered by strength, each
|
||||
* entry on its engine's 0-1 display scale.
|
||||
*
|
||||
* Only the engines scoring recorded a match in are asked (see
|
||||
* score()), so an item that matched one of them never pays the
|
||||
* other's cost -- scanning the document's whole text, or embedding
|
||||
* the query. A semantic index that isn't ready contributes nothing.
|
||||
*
|
||||
* @param {Number} itemID
|
||||
* @return {Promise<Object[]>} - Entries with `text`, `ranges`, and
|
||||
* `strength`, plus chunk location fields or a lexical `source`
|
||||
*/
|
||||
async getMatchingExcerpts(itemID) {
|
||||
let queryText = this._queryText;
|
||||
let preview = this._previews.get(itemID);
|
||||
// Temporary, for testing: the bestMatchEngine pref keeps the
|
||||
// pinned engine's excerpts alone -- no lexical excerpts or
|
||||
// highlights when pinned semantic, no chunks when pinned lexical
|
||||
let engine = Zotero.Prefs.get('search.bestMatchEngine');
|
||||
// Uncapped: the tree shows every place an item matched
|
||||
let options = { limit: Infinity };
|
||||
let excerpts = engine == 'semantic' || preview?.lexical === false
|
||||
? []
|
||||
: await Zotero.Lexical.getMatchingExcerpts(queryText, itemID, options);
|
||||
if (preview?.semantic === false || engine == 'lexical' || !_useSemantic()
|
||||
|| !Zotero.Embeddings.normalizeQuery(queryText || '')) {
|
||||
return excerpts;
|
||||
}
|
||||
let chunks = [];
|
||||
try {
|
||||
chunks = await Zotero.Embeddings.getMatchingChunks(queryText, itemID, options);
|
||||
// Only fulltext chunks carry their own text; item-level
|
||||
// matches have nothing to excerpt
|
||||
chunks = chunks.filter(chunk => chunk.text);
|
||||
}
|
||||
catch (e) {
|
||||
if (!(e instanceof Zotero.Embeddings.IndexNotReadyError)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
if (!chunks.length) {
|
||||
return excerpts;
|
||||
}
|
||||
let ranges = engine == 'semantic'
|
||||
? chunks.map(() => [])
|
||||
: await Zotero.Lexical.findMatchRanges(
|
||||
queryText, chunks.map(chunk => chunk.text));
|
||||
chunks = chunks.map((chunk, i) => ({
|
||||
...chunk,
|
||||
ranges: ranges[i],
|
||||
strength: Zotero.Embeddings.getScoreFraction(chunk.score)
|
||||
}));
|
||||
excerpts = excerpts.filter(
|
||||
excerpt => excerpt.source != 'content' || !_coveredByChunk(excerpt, chunks));
|
||||
return [...chunks, ...excerpts]
|
||||
.sort((a, b) => (b.strength || 0) - (a.strength || 0));
|
||||
}
|
||||
|
||||
// Derive one item's entries, all at once. A preview replaced while
|
||||
// deriving (a re-score, an invalidate) keeps the newer object
|
||||
// untouched.
|
||||
async _fill(itemID, preview) {
|
||||
let entries = await this.getMatchingExcerpts(itemID);
|
||||
if (this._disposed || this._previews.get(itemID) != preview) {
|
||||
return;
|
||||
}
|
||||
preview.entries = entries.map((entry, i) => ({ key: i, ...entry }));
|
||||
preview.state = entries.length ? 'filled' : 'empty';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a search session for a query (see Zotero.BestMatch.Session)
|
||||
*
|
||||
* @param {String} queryText
|
||||
* @return {Zotero.BestMatch.Session}
|
||||
*/
|
||||
this.createSession = function (queryText) {
|
||||
return new this.Session(queryText);
|
||||
};
|
||||
|
||||
// Whether a lexical fulltext excerpt's matched evidence already appears
|
||||
|
|
|
|||
|
|
@ -2229,7 +2229,12 @@ var ZoteroPane = new function () {
|
|||
}
|
||||
|
||||
var selectedItems = this.itemsView.getSelectedObjects();
|
||||
|
||||
|
||||
// The pane shows data objects; rows standing in for something else
|
||||
// (e.g. a search-match preview row) have none to show, so a
|
||||
// selection of only those reads as an empty one for now
|
||||
selectedItems = selectedItems.filter(o => o instanceof Zotero.DataObject);
|
||||
|
||||
// Display buttons at top of item pane depending on context. This needs to run even if the
|
||||
// selection hasn't changed, because the selected items might have been modified.
|
||||
this.itemPane.data = selectedItems;
|
||||
|
|
|
|||
|
|
@ -460,6 +460,7 @@ 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
|
||||
items-search-match-pending = Loading matches…
|
||||
|
||||
report-error =
|
||||
.label = Report Error…
|
||||
|
|
@ -643,7 +644,6 @@ pane-related = Related
|
|||
pane-attachment-info = Attachment Info
|
||||
pane-attachment-preview = Preview
|
||||
pane-attachment-annotations = Annotations
|
||||
pane-search-results = Search Results
|
||||
|
||||
pane-header-attachment-associated =
|
||||
.label = Rename associated file
|
||||
|
|
@ -683,15 +683,6 @@ section-related =
|
|||
.label = { $count } Related
|
||||
section-attachment-info =
|
||||
.label = { pane-attachment-info }
|
||||
section-search-results =
|
||||
.label = { $count ->
|
||||
[one] { $count } Search Result
|
||||
*[other] { $count } Search Results
|
||||
}
|
||||
search-result-row-fulltext = Full Text
|
||||
search-result-row-show-more = Show More
|
||||
search-result-row-show-less = Show Less
|
||||
|
||||
section-button-remove =
|
||||
.tooltiptext = { general-remove }
|
||||
section-button-add =
|
||||
|
|
@ -729,8 +720,6 @@ sidenav-attachment-preview =
|
|||
.tooltiptext = { pane-attachment-preview }
|
||||
sidenav-attachment-annotations =
|
||||
.tooltiptext = { pane-attachment-annotations }
|
||||
sidenav-search-results =
|
||||
.tooltiptext = { pane-search-results }
|
||||
sidenav-libraries-collections =
|
||||
.tooltiptext = { pane-libraries-collections }
|
||||
sidenav-tags =
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@
|
|||
@import "elements/attachmentRow";
|
||||
@import "elements/attachmentAnnotationsBox";
|
||||
@import "elements/annotationRow";
|
||||
@import "elements/searchResultsBox";
|
||||
@import "elements/noteRow";
|
||||
@import "elements/librariesCollectionsBox";
|
||||
@import "elements/duplicatesMergePane";
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ $item-pane-sections: (
|
|||
"libraries-collections": var(--accent-teal),
|
||||
"tags": var(--accent-orange),
|
||||
"related": var(--accent-wood),
|
||||
"search-results": var(--accent-gold),
|
||||
);
|
||||
|
||||
$tagColorsLookup: (
|
||||
|
|
|
|||
|
|
@ -373,7 +373,29 @@
|
|||
padding: 0 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.search-match-row {
|
||||
.cell {
|
||||
font-size: $font-size-small;
|
||||
&.title {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
max-width: fit-content;
|
||||
}
|
||||
// Push the relevance bar to the row's end, where the Relevance
|
||||
// column sits while a best-match search is active
|
||||
&.relevance {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
.search-match-pending {
|
||||
color: var(--fill-secondary);
|
||||
}
|
||||
.search-match-highlight {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.cell:not(.hasAttachment) .item-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
// search-result-row (the search-results section's chunk cards) shares this
|
||||
// structure and look, minus the parts it doesn't have (icon, action, tags):
|
||||
// the section already carries the magnifier in its head, so repeating it on
|
||||
// every card says nothing
|
||||
annotation-row, search-result-row {
|
||||
annotation-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
// The section's icon is the quick-search magnifier, the same one the search
|
||||
// bar uses, rather than a per-size copy of its own. Both the sidenav button
|
||||
// and the section head otherwise derive their icon from the pane name (see
|
||||
// _itemPaneSidenav.scss and _collapsibleSection.scss), so each needs pointing
|
||||
// at the shared file.
|
||||
item-pane-sidenav .btn[data-pane="search-results"] {
|
||||
background-image: url("chrome://zotero/skin/20/universal/magnifier.svg");
|
||||
}
|
||||
|
||||
collapsible-section[data-pane="search-results"] > .head .title::before {
|
||||
background-image: icon-url("16/universal/magnifier.svg");
|
||||
}
|
||||
|
||||
search-results-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > collapsible-section {
|
||||
& > .body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
@include comfortable {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The parts search-result-row adds on top of the shared annotation-row look
|
||||
// (see _annotationRow.scss): the section-part indicator and page label in the
|
||||
// head, and the clamp toggle under the quote
|
||||
search-result-row {
|
||||
.head {
|
||||
.part {
|
||||
font-weight: 400;
|
||||
color: var(--fill-secondary);
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
.location {
|
||||
margin-inline-start: auto;
|
||||
color: var(--fill-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded .body .quote {
|
||||
-webkit-line-clamp: none;
|
||||
}
|
||||
|
||||
// A lexical excerpt's matched ranges
|
||||
.body .quote .match {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
align-self: flex-start;
|
||||
margin: 0 8px 4px 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fill-secondary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,7 +182,30 @@ describe("Zotero.BestMatch", function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe("#getMatchingExcerpts()", function () {
|
||||
describe("Session#getMatchingExcerpts()", function () {
|
||||
// Previews exist only for file attachments (see _hasPreviews())
|
||||
var attachment;
|
||||
|
||||
before(async function () {
|
||||
attachment = await importFileAttachment('test.pdf');
|
||||
});
|
||||
|
||||
// A session that has scored the attachment, recording which engines
|
||||
// matched it -- what getMatchingExcerpts() consults instead of being
|
||||
// told per call
|
||||
async function sessionFor({ lexical = true, semantic = true } = {}) {
|
||||
stubs.push(sinon.stub(Zotero.BestMatch, 'scoreItemIDs').resolves({
|
||||
scores: new Map([[attachment.id, 0.9]]),
|
||||
matches: {
|
||||
lexical: new Set(lexical ? [attachment.id] : []),
|
||||
semantic: new Set(semantic ? [attachment.id] : [])
|
||||
}
|
||||
}));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([attachment.id]);
|
||||
return session;
|
||||
}
|
||||
|
||||
it("should return lexical excerpts when no semantic model is enabled", async function () {
|
||||
let lexicalExcerpts = [{ source: 'title', text: 'owl', ranges: [[0, 3]], strength: 1 }];
|
||||
let chunksStub = sinon.stub(Zotero.Embeddings, 'getMatchingChunks');
|
||||
|
|
@ -191,7 +214,8 @@ describe("Zotero.BestMatch", function () {
|
|||
stubs.push(sinon.stub(Zotero.Lexical, 'getMatchingExcerpts')
|
||||
.resolves(lexicalExcerpts));
|
||||
|
||||
let excerpts = await Zotero.BestMatch.getMatchingExcerpts('owl', 1);
|
||||
let session = await sessionFor();
|
||||
let excerpts = await session.getMatchingExcerpts(attachment.id);
|
||||
assert.isFalse(chunksStub.called);
|
||||
assert.equal(excerpts, lexicalExcerpts);
|
||||
});
|
||||
|
|
@ -207,12 +231,13 @@ describe("Zotero.BestMatch", function () {
|
|||
stubs.push(sinon.stub(Zotero.Lexical, 'findMatchRanges')
|
||||
.resolves([[[4, 7]]]));
|
||||
|
||||
let excerpts = await Zotero.BestMatch.getMatchingExcerpts('owl', 1);
|
||||
let session = await sessionFor();
|
||||
let excerpts = await session.getMatchingExcerpts(attachment.id);
|
||||
assert.lengthOf(excerpts, 1);
|
||||
assert.equal(excerpts[0].text, 'the owl chunk');
|
||||
assert.deepEqual(excerpts[0].ranges, [[4, 7]]);
|
||||
assert.equal(excerpts[0].strength, 0.6);
|
||||
// Chunk fields pass through for the card's location line
|
||||
// Chunk fields pass through for the row's location line
|
||||
assert.equal(excerpts[0].position, 1);
|
||||
});
|
||||
|
||||
|
|
@ -242,7 +267,8 @@ describe("Zotero.BestMatch", function () {
|
|||
}
|
||||
]));
|
||||
|
||||
let excerpts = await Zotero.BestMatch.getMatchingExcerpts('owl migration', 1);
|
||||
let session = await sessionFor();
|
||||
let excerpts = await session.getMatchingExcerpts(attachment.id);
|
||||
assert.deepEqual(
|
||||
excerpts.map(excerpt => excerpt.source || 'chunk'),
|
||||
['title', 'chunk', 'content']
|
||||
|
|
@ -250,22 +276,39 @@ describe("Zotero.BestMatch", function () {
|
|||
assert.include(excerpts[2].text, 'different');
|
||||
});
|
||||
|
||||
it("should cap the merged entries at the limit", async function () {
|
||||
it("should skip the lexical engine for an item that didn't match it", async function () {
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
|
||||
let lexicalStub = sinon.stub(Zotero.Lexical, 'getMatchingExcerpts');
|
||||
stubs.push(lexicalStub);
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'getMatchingChunks').resolves([
|
||||
{ text: 'chunk one', score: 0.8 },
|
||||
{ text: 'chunk two', score: 0.4 }
|
||||
]));
|
||||
stubs.push(sinon.stub(Zotero.Lexical, 'findMatchRanges').resolves([[], []]));
|
||||
stubs.push(sinon.stub(Zotero.Lexical, 'getMatchingExcerpts').resolves([
|
||||
{ source: 'title', text: 'owl', ranges: [[0, 3]], strength: 0.6 }
|
||||
{ text: 'the owl chunk', score: 0.6 }
|
||||
]));
|
||||
stubs.push(sinon.stub(Zotero.Lexical, 'findMatchRanges').resolves([[]]));
|
||||
|
||||
let excerpts = await Zotero.BestMatch.getMatchingExcerpts('owl', 1, { limit: 2 });
|
||||
assert.lengthOf(excerpts, 2);
|
||||
assert.equal(excerpts[0].text, 'chunk one');
|
||||
assert.equal(excerpts[1].source, 'title');
|
||||
// Scoring recorded a semantic match only, so the document's text
|
||||
// is never read or scanned
|
||||
let session = await sessionFor({ lexical: false });
|
||||
let excerpts = await session.getMatchingExcerpts(attachment.id);
|
||||
assert.isFalse(lexicalStub.called);
|
||||
assert.lengthOf(excerpts, 1);
|
||||
assert.equal(excerpts[0].text, 'the owl chunk');
|
||||
});
|
||||
|
||||
it("should skip the semantic engine for an item that didn't match it", async function () {
|
||||
let lexicalExcerpts = [{ source: 'title', text: 'owl', ranges: [[0, 3]], strength: 1 }];
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
|
||||
stubs.push(sinon.stub(Zotero.Lexical, 'getMatchingExcerpts')
|
||||
.resolves(lexicalExcerpts));
|
||||
let chunksStub = sinon.stub(Zotero.Embeddings, 'getMatchingChunks');
|
||||
stubs.push(chunksStub);
|
||||
|
||||
// Scoring recorded a lexical match only, so the query is never
|
||||
// embedded for it
|
||||
let session = await sessionFor({ semantic: false });
|
||||
let excerpts = await session.getMatchingExcerpts(attachment.id);
|
||||
assert.isFalse(chunksStub.called);
|
||||
assert.equal(excerpts, lexicalExcerpts);
|
||||
});
|
||||
|
||||
it("should keep the lexical excerpts alone when the model shows nothing", async function () {
|
||||
|
|
@ -278,11 +321,12 @@ describe("Zotero.BestMatch", function () {
|
|||
let chunksStub = sinon.stub(Zotero.Embeddings, 'getMatchingChunks')
|
||||
.resolves([{ text: null }]);
|
||||
stubs.push(chunksStub);
|
||||
assert.equal(await Zotero.BestMatch.getMatchingExcerpts('owl', 1), lexicalExcerpts);
|
||||
let session = await sessionFor();
|
||||
assert.equal(await session.getMatchingExcerpts(attachment.id), lexicalExcerpts);
|
||||
|
||||
// The semantic index isn't ready
|
||||
chunksStub.rejects(new Zotero.Embeddings.IndexNotReadyError('test'));
|
||||
assert.equal(await Zotero.BestMatch.getMatchingExcerpts('owl', 1), lexicalExcerpts);
|
||||
assert.equal(await session.getMatchingExcerpts(attachment.id), lexicalExcerpts);
|
||||
});
|
||||
|
||||
it("should rethrow an unexpected semantic failure", async function () {
|
||||
|
|
@ -291,11 +335,237 @@ describe("Zotero.BestMatch", function () {
|
|||
stubs.push(sinon.stub(Zotero.Embeddings, 'getMatchingChunks')
|
||||
.rejects(new Error('model exploded')));
|
||||
|
||||
let e = await getPromiseError(Zotero.BestMatch.getMatchingExcerpts('owl', 1));
|
||||
let session = await sessionFor();
|
||||
let e = await getPromiseError(session.getMatchingExcerpts(attachment.id));
|
||||
assert.equal(e.message, 'model exploded');
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session", function () {
|
||||
// Previews are only built for file attachments (see _hasPreviews()),
|
||||
// so the items these tests score are real ones
|
||||
var att1, att2, att3;
|
||||
|
||||
before(async function () {
|
||||
att1 = await importFileAttachment('test.pdf');
|
||||
att2 = await importFileAttachment('test.pdf');
|
||||
att3 = await importFileAttachment('test.pdf');
|
||||
});
|
||||
|
||||
function stubScore(scores, lexicalIDs, semanticIDs) {
|
||||
let stub = sinon.stub(Zotero.BestMatch, 'scoreItemIDs').resolves({
|
||||
scores,
|
||||
matches: {
|
||||
lexical: new Set(lexicalIDs || []),
|
||||
semantic: new Set(semanticIDs || [])
|
||||
}
|
||||
});
|
||||
stubs.push(stub);
|
||||
return stub;
|
||||
}
|
||||
|
||||
function stubDerive(entriesByItem) {
|
||||
let stub = sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts').callsFake(
|
||||
async itemID => entriesByItem.get(itemID) || []);
|
||||
stubs.push(stub);
|
||||
return stub;
|
||||
}
|
||||
|
||||
function settledOnce(session) {
|
||||
return new Promise((resolve) => {
|
||||
session.onUpdate = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
it("should build placeholder previews from the engines' match sets", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9], [att2.id, 0.8], [att3.id, 0.7]]), [att1.id], [att2.id]);
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
let scores = await session.score([att1.id, att2.id, att3.id]);
|
||||
assert.equal(scores.get(att1.id), 0.9);
|
||||
assert.equal(session.getPreviews(att1.id).state, 'pending');
|
||||
assert.equal(session.getPreviews(att2.id).state, 'pending');
|
||||
// A scored item neither engine can show matches in -- a semantic
|
||||
// match that is its own preview -- gets no placeholder
|
||||
assert.isNull(session.getPreviews(att3.id));
|
||||
});
|
||||
|
||||
it("should fill requested previews all at once and report them", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
let derive = stubDerive(new Map([[att1.id, [
|
||||
{ source: 'title', text: 'owl atlas', ranges: [[0, 3]], strength: 1 },
|
||||
{ source: 'abstract', text: 'about owls', ranges: [[6, 10]], strength: 0.5 }
|
||||
]]]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
let settled = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
assert.deepEqual(await settled, [att1.id]);
|
||||
let preview = session.getPreviews(att1.id);
|
||||
assert.equal(preview.state, 'filled');
|
||||
assert.deepEqual(preview.entries.map(entry => entry.key), [0, 1]);
|
||||
assert.equal(preview.entries[0].text, 'owl atlas');
|
||||
assert.deepEqual(derive.firstCall.args, [att1.id]);
|
||||
});
|
||||
|
||||
it("should not derive again for a repeated or settled request", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
let derive = stubDerive(new Map([[att1.id, [
|
||||
{ source: 'title', text: 'owl', ranges: [], strength: 1 }
|
||||
]]]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
let settled = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
await settled;
|
||||
session.request([att1.id]);
|
||||
await Zotero.Promise.delay(50);
|
||||
assert.equal(derive.callCount, 1);
|
||||
});
|
||||
|
||||
it("should derive preloaded previews without waiting to be requested", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9], [att2.id, 0.8]]), [att1.id, att2.id]);
|
||||
let derive = stubDerive(new Map([
|
||||
[att1.id, [{ source: 'title', text: 'one', ranges: [], strength: 1 }]]
|
||||
]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id, att2.id]);
|
||||
|
||||
await session.preload([att1.id]);
|
||||
// Settled by the time preload() resolves, with no request() and no
|
||||
// wait for an idle main thread
|
||||
assert.equal(session.getPreviews(att1.id).state, 'filled');
|
||||
assert.equal(session.getPreviews(att2.id).state, 'pending');
|
||||
|
||||
// A preview already in hand costs nothing to preload again
|
||||
await session.preload([att1.id]);
|
||||
assert.equal(derive.callCount, 1);
|
||||
});
|
||||
|
||||
it("should not preload after dispose", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
let derive = stubDerive(new Map([[att1.id, [
|
||||
{ source: 'title', text: 'owl', ranges: [], strength: 1 }
|
||||
]]]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
session.dispose();
|
||||
await session.preload([att1.id]);
|
||||
assert.equal(derive.callCount, 0);
|
||||
});
|
||||
|
||||
it("should let a newer request supersede an older one", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9], [att2.id, 0.8]]), [att1.id, att2.id]);
|
||||
let derive = stubDerive(new Map([
|
||||
[att1.id, [{ source: 'title', text: 'one', ranges: [], strength: 1 }]],
|
||||
[att2.id, [{ source: 'title', text: 'two', ranges: [], strength: 1 }]]
|
||||
]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id, att2.id]);
|
||||
let settled = settledOnce(session);
|
||||
// The second request lands before the first's idle batch runs
|
||||
session.request([att1.id]);
|
||||
session.request([att2.id]);
|
||||
assert.deepEqual(await settled, [att2.id]);
|
||||
assert.equal(derive.callCount, 1);
|
||||
assert.equal(session.getPreviews(att1.id).state, 'pending');
|
||||
});
|
||||
|
||||
it("should show nothing for a preview that derives nothing, and not retry it", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
let derive = stubDerive(new Map());
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
let settled = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
assert.deepEqual(await settled, [att1.id]);
|
||||
assert.isNull(session.getPreviews(att1.id));
|
||||
session.request([att1.id]);
|
||||
await Zotero.Promise.delay(50);
|
||||
assert.equal(derive.callCount, 1);
|
||||
});
|
||||
|
||||
it("should show nothing for a failed derivation", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.rejects(new Error('cache file missing')));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
let settled = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
assert.deepEqual(await settled, [att1.id]);
|
||||
assert.isNull(session.getPreviews(att1.id));
|
||||
});
|
||||
|
||||
it("should rederive an invalidated preview on the next request", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
let derive = stubDerive(new Map([[att1.id, [
|
||||
{ source: 'title', text: 'owl', ranges: [], strength: 1 }
|
||||
]]]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
let settled = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
await settled;
|
||||
|
||||
session.invalidate([att1.id]);
|
||||
assert.equal(session.getPreviews(att1.id).state, 'pending');
|
||||
|
||||
let settledAgain = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
await settledAgain;
|
||||
assert.equal(derive.callCount, 2);
|
||||
assert.equal(session.getPreviews(att1.id).state, 'filled');
|
||||
});
|
||||
|
||||
it("should derive nothing and never report after dispose", async function () {
|
||||
stubScore(new Map([[att1.id, 0.9]]), [att1.id]);
|
||||
let derive = stubDerive(new Map([[att1.id, [
|
||||
{ source: 'title', text: 'owl', ranges: [], strength: 1 }
|
||||
]]]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id]);
|
||||
let updated = false;
|
||||
session.onUpdate = () => {
|
||||
updated = true;
|
||||
};
|
||||
session.request([att1.id]);
|
||||
session.dispose();
|
||||
await Zotero.Promise.delay(50);
|
||||
assert.isFalse(updated);
|
||||
assert.equal(derive.callCount, 0);
|
||||
});
|
||||
|
||||
it("should keep settled previews across a re-score and drop unmatched items", async function () {
|
||||
let scoreStub = sinon.stub(Zotero.BestMatch, 'scoreItemIDs');
|
||||
stubs.push(scoreStub);
|
||||
scoreStub.onFirstCall().resolves({
|
||||
scores: new Map([[att1.id, 0.9], [att2.id, 0.8]]),
|
||||
matches: { lexical: new Set([att1.id, att2.id]), semantic: new Set() }
|
||||
});
|
||||
scoreStub.onSecondCall().resolves({
|
||||
scores: new Map([[att1.id, 0.9]]),
|
||||
matches: { lexical: new Set([att1.id]), semantic: new Set() }
|
||||
});
|
||||
stubDerive(new Map([[att1.id, [
|
||||
{ source: 'title', text: 'owl', ranges: [], strength: 1 }
|
||||
]]]));
|
||||
let session = Zotero.BestMatch.createSession('owl');
|
||||
await session.score([att1.id, att2.id]);
|
||||
let settled = settledOnce(session);
|
||||
session.request([att1.id]);
|
||||
await settled;
|
||||
|
||||
await session.score([att1.id, att2.id]);
|
||||
// The derived text survives the re-score...
|
||||
let preview = session.getPreviews(att1.id);
|
||||
assert.equal(preview.state, 'filled');
|
||||
assert.equal(preview.entries[0].text, 'owl');
|
||||
// ...and an item no longer matched loses its preview
|
||||
assert.isNull(session.getPreviews(att2.id));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("#isSearchableQuery()", function () {
|
||||
it("should accept any query the lexical engine can parse", function () {
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(false));
|
||||
|
|
|
|||
|
|
@ -255,6 +255,25 @@ describe("CollectionViewItemTree", function () {
|
|||
});
|
||||
}
|
||||
|
||||
// The demand path is what most of these tests exercise, so skip the
|
||||
// preload that would otherwise settle top-ranked previews before
|
||||
// their rows are ever drawn (see _applyBestMatch())
|
||||
function skipPreload() {
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'preload').resolves());
|
||||
}
|
||||
|
||||
// Wait for a row to appear (or, with present = false, disappear):
|
||||
// preview derivation is demand-driven and asynchronous, so the
|
||||
// 1->n replacement lands some time after the placeholder renders
|
||||
async function waitForMatchRow(view, id, present = true) {
|
||||
let deadline = Date.now() + 5000;
|
||||
while ((view.getRowIndexByID(id) === false) == present
|
||||
&& Date.now() < deadline) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
return view.getRowIndexByID(id);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
|
||||
|
|
@ -501,6 +520,256 @@ describe("CollectionViewItemTree", function () {
|
|||
}
|
||||
});
|
||||
|
||||
it("should show placeholder match rows under matched attachments", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "matchrow A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
|
||||
// Hold derivation so the placeholder stays put for the test
|
||||
skipPreload();
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.returns(new Promise(() => {})));
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
let matchRow = itemsView.getRowIndexByID('SM' + attachment.id + '-pending');
|
||||
assert.notStrictEqual(matchRow, false);
|
||||
// The parent and the matched attachment both auto-expanded
|
||||
assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(item.id)));
|
||||
assert.isTrue(itemsView.isContainerOpen(itemsView.getRowIndexByID(attachment.id)));
|
||||
assert.equal(itemsView.getLevel(matchRow), 2);
|
||||
|
||||
// Clearing the search removes the match rows
|
||||
await itemsView.setFilter('search', '');
|
||||
assert.isFalse(itemsView.getRowIndexByID('SM' + attachment.id + '-pending'));
|
||||
});
|
||||
|
||||
it("should place a semantic match's placeholder under its attachment", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "chunkcount A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs').callsFake(
|
||||
async (query, itemIDs) => ({
|
||||
scores: new Map(itemIDs.includes(attachment.id)
|
||||
? [[attachment.id, 0.9]] : []),
|
||||
previewableIDs: new Set(itemIDs.includes(attachment.id)
|
||||
? [attachment.id] : [])
|
||||
})
|
||||
));
|
||||
|
||||
// Hold derivation so the placeholder stays put for the test
|
||||
skipPreload();
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.returns(new Promise(() => {})));
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
let matchRow = itemsView.getRowIndexByID('SM' + attachment.id + '-pending');
|
||||
assert.notStrictEqual(matchRow, false);
|
||||
// Under the attachment, which auto-expanded to show it
|
||||
let attachmentRow = itemsView.getRowIndexByID(attachment.id);
|
||||
assert.equal(itemsView.getParentIndex(matchRow), attachmentRow);
|
||||
});
|
||||
|
||||
it("should show no match rows for a matched note", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "notematch A", collections: [col.id] });
|
||||
let note = new Zotero.Item('note');
|
||||
note.parentID = item.id;
|
||||
note.setNote('<p>notematch text</p>');
|
||||
await note.saveTx();
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(note.id) ? [[note.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
|
||||
// Hold derivation so the placeholder stays put for the test
|
||||
skipPreload();
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.returns(new Promise(() => {})));
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
// Only file attachments show match rows, so a matched note
|
||||
// stays a plain, childless row
|
||||
let noteRow = itemsView.getRowIndexByID(note.id);
|
||||
assert.notStrictEqual(noteRow, false);
|
||||
assert.isFalse(itemsView.isContainer(noteRow));
|
||||
assert.isFalse(itemsView.getRowIndexByID('SM' + note.id + '-pending'));
|
||||
});
|
||||
|
||||
it("should treat a selected match row as no item selection", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "matchselect A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
|
||||
// Hold derivation so the placeholder stays put for the test
|
||||
skipPreload();
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.returns(new Promise(() => {})));
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
let matchRow = itemsView.getRowIndexByID('SM' + attachment.id + '-pending');
|
||||
itemsView.selection.select(matchRow);
|
||||
assert.lengthOf(itemsView.getSelectedItems(), 0);
|
||||
await zp.itemSelected();
|
||||
assert.equal(zp.itemPane.mode, 'message');
|
||||
});
|
||||
|
||||
it("should show the top-ranked matches already derived on a new search", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "preload A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.resolves([
|
||||
{ source: 'content', text: 'preload owls', ranges: [], strength: 1 }
|
||||
]));
|
||||
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
// The rows are drawn with their matches already in place: no
|
||||
// placeholder was left for a later fill to replace
|
||||
assert.notStrictEqual(
|
||||
itemsView.getRowIndexByID('SM' + attachment.id + '-0'), false);
|
||||
assert.isFalse(
|
||||
itemsView.getRowIndexByID('SM' + attachment.id + '-pending'));
|
||||
});
|
||||
|
||||
it("should replace a rendered placeholder with the derived match rows", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "fillrow A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
skipPreload();
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts').resolves([
|
||||
{ source: 'title', text: 'fillrow owls', ranges: [[8, 12]], strength: 1 },
|
||||
{ source: 'abstract', text: 'about owls', ranges: [[6, 10]], strength: 0.5 }
|
||||
]));
|
||||
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
// Rendering the placeholder requests the derivation; the fill
|
||||
// replaces it with one row per derived entry
|
||||
let first = await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
|
||||
assert.notStrictEqual(first, false);
|
||||
assert.notStrictEqual(itemsView.getRowIndexByID('SM' + attachment.id + '-1'), false);
|
||||
assert.isFalse(itemsView.getRowIndexByID('SM' + attachment.id + '-pending'));
|
||||
assert.equal(itemsView.getRow(first).ref.entry.text, 'fillrow owls');
|
||||
});
|
||||
|
||||
it("should hand a selected placeholder's selection to the first derived row", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "handoff A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
// Hold derivation open until the placeholder is selected
|
||||
skipPreload();
|
||||
let resolveDerive;
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
||||
.returns(new Promise((resolve) => {
|
||||
resolveDerive = resolve;
|
||||
})));
|
||||
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
let placeholderIndex = itemsView.getRowIndexByID('SM' + attachment.id + '-pending');
|
||||
itemsView.selection.select(placeholderIndex);
|
||||
// Wait for the rendered placeholder's derivation to start,
|
||||
// then let it finish
|
||||
let deadline = Date.now() + 5000;
|
||||
while (!resolveDerive && Date.now() < deadline) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
resolveDerive([{ source: 'title', text: 'handoff owls', ranges: [], strength: 1 }]);
|
||||
|
||||
let first = await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
|
||||
assert.isTrue(itemsView.selection.isSelected(first));
|
||||
});
|
||||
|
||||
it("should remove the placeholder when derivation finds nothing to show", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "emptyfill A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
skipPreload();
|
||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts').resolves([]));
|
||||
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
|
||||
await waitForMatchRow(itemsView, 'SM' + attachment.id + '-pending', false);
|
||||
assert.isFalse(itemsView.getRowIndexByID('SM' + attachment.id + '-pending'));
|
||||
assert.isFalse(itemsView.getRowIndexByID('SM' + attachment.id + '-0'));
|
||||
// The item stays -- it's still a scored result
|
||||
assert.notStrictEqual(itemsView.getRowIndexByID(item.id), false);
|
||||
});
|
||||
|
||||
it("should rederive a modified attachment's previews", async function () {
|
||||
let col = await createDataObject('collection');
|
||||
let item = await createDataObject('item', { title: "rederive A", collections: [col.id] });
|
||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.id });
|
||||
Zotero.Lexical.scoreItemIDs.callsFake(async (query, itemIDs) => new Map(
|
||||
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||
.callsFake(scoreEnvelope(new Map())));
|
||||
skipPreload();
|
||||
let derive = sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts').resolves([
|
||||
{ source: 'title', text: 'rederive owls', ranges: [], strength: 1 }
|
||||
]);
|
||||
stubs.push(derive);
|
||||
|
||||
await select(win, col);
|
||||
itemsView = zp.itemsView;
|
||||
await itemsView.setFilter('search', 'some query');
|
||||
await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
|
||||
|
||||
// Editing the attachment drops its derived preview back to a
|
||||
// placeholder, and the re-render derives it again
|
||||
let before = derive.callCount;
|
||||
attachment.setField('title', 'rederive B');
|
||||
await attachment.saveTx();
|
||||
let deadline = Date.now() + 5000;
|
||||
while (derive.callCount <= before && Date.now() < deadline) {
|
||||
await Zotero.Promise.delay(10);
|
||||
}
|
||||
assert.isAbove(derive.callCount, before);
|
||||
let first = await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
|
||||
assert.notStrictEqual(first, false);
|
||||
});
|
||||
|
||||
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] });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue