display search results in itemPane

Added search results pane that is shown when a search
result row is selected. It displays the entire chunk
that the snippet is based on. When an attachment is
selected, its search results section lists all search
results
This commit is contained in:
Bogdan Abaev 2026-08-25 17:05:23 -07:00
parent a96d866d1b
commit 2b79e3ddb4
20 changed files with 839 additions and 40 deletions

View file

@ -387,6 +387,16 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
return kept;
}
/**
* The session holding the passages of the active best-match search, or
* null when no such search is running
*
* @return {Zotero.BestMatch.Session|null}
*/
get bestMatchSession() {
return this._bestMatchSession ?? null;
}
/**
* Called for every pending search-match row the tree draws: rendering
* is the demand signal for deriving previews. Reports are collected

View file

@ -74,6 +74,9 @@ 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'],
['search-results-pane', 'chrome://zotero/content/elements/searchResultsPane.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'],

View file

@ -56,6 +56,8 @@
<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>

View file

@ -45,6 +45,7 @@
<description id="batch-edit-prompt-message" />
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
</groupbox>
<search-results-pane id="zotero-search-results-pane" />
</deck>
<item-pane-sidenav id="zotero-view-item-sidenav" no-context-notes="true" class="zotero-view-item-sidenav"/>
`);
@ -55,6 +56,7 @@
this._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
this._messagePane = this.querySelector("#zotero-item-message");
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
this._searchResultsPane = this.querySelector("#zotero-search-results-pane");
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
@ -113,12 +115,12 @@
}
get mode() {
return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt"][this._deck.selectedIndex];
return ["message", "item", "note", "duplicates", "annotations", "batch-edit-prompt", "search-results"][this._deck.selectedIndex];
}
/**
* Set mode of item pane
* @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt"} type view type
* @param {"message" | "item" | "note" | "duplicates" | "annotations" | "batch-edit-prompt" | "search-results"} type view type
*/
set mode(type) {
this.setAttribute("view-type", type);
@ -133,6 +135,11 @@
}
render() {
// Passages of a search match, rather than items: nothing an item
// pane shows describes one, so the passages are all there is
if (this.searchMatches?.length) {
return this.renderSearchResults(this.searchMatches);
}
if (!this.data) return false;
let renderStatus = false;
// Only annotations selected
@ -193,6 +200,13 @@
return true;
}
renderSearchResults(matches) {
this.mode = "search-results";
this._searchResultsPane.matches = matches;
this._searchResultsPane.render();
return true;
}
renderNoteEditor(item) {
this.mode = "note";
@ -627,8 +641,12 @@
getCurrentPane(mode = undefined) {
if (!mode) {
// Guess a mode from the current data
// Passages of a search match, which aren't items at all
if (this.searchMatches?.length) {
mode = "search-results";
}
// Only annotation items selected
if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
else if (this.data.length > 0 && this.data.every(item => item.isAnnotation())) {
mode = "annotations";
}
// No/multiple objects are selected OR selected object is a trashed collection/search
@ -648,7 +666,8 @@
item: "_itemDetails",
note: "_noteEditor",
duplicates: "_duplicatesPane",
annotations: "_annotationsPane"
annotations: "_annotationsPane",
"search-results": "_searchResultsPane"
};
return this[map[mode]];
}
@ -736,6 +755,10 @@
this._deck.selectedIndex = 5;
break;
}
case "search-results": {
this._deck.selectedIndex = 6;
break;
}
}
let isViewingItem = type == "item";
let isViewingDuplicates = type == "duplicates";

View file

@ -102,7 +102,7 @@
}
get _builtInPanes() {
return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related"];
return ["info", "abstract", "attachments", "notes", "note-info", "attachment-info", "attachment-annotations", "libraries-collections", "tags", "related", "search-results"];
}
get container() {

View file

@ -0,0 +1,161 @@
/*
***** 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 passage of an item's text that the
// query matched (see Zotero.BestMatch.Session#getPreviews()) -- where the
// passage sits in the document as the head, the passage itself as the
// quote with the query's words marked in it, presented like an
// annotation-row (the two share their styling, see
// scss/elements/_annotationRow.scss).
//
// The whole passage is quoted, not the line the tree shows: the card is
// where a match is read rather than scanned. A quote too tall for the
// card is clamped, with a toggle to see the rest.
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();
}
render() {
if (!this.initialized || !this._result) return;
// Where the passage sits: the headings it falls under, or the
// generic fulltext label for a passage from a document with no
// outline to read
if (this._result.outlinePath) {
this._path.removeAttribute('data-l10n-id');
this._path.textContent = this._result.outlinePath;
}
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,
this._location.hidden ? '' : this._location.textContent,
this._result.text
].filter(Boolean).join('. '));
}
// The passage'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);
}

View file

@ -0,0 +1,176 @@
/*
***** 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" }
);
// Why the selected item matched the active best-match search: a card per
// passage of the item's own text that the search matched (see
// Zotero.BestMatch.Session#getPreviews()), each carrying the whole
// passage rather than the line the tree quotes, so a match can be read
// without opening anything.
//
// Shows every passage the selected item matched in. A single passage
// selected on its own is a search-results-pane, not an item with a
// section.
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);
}
});
}
get _itemsView() {
return this.closest('item-pane')?.itemsView ?? null;
}
// The session holding the passages of the active best-match search,
// or null when no such search is running. It derives a preview once
// per item and keeps it, so reading one costs nothing after the first
// time.
get _session() {
return this._itemsView?.bestMatchSession ?? null;
}
// A new search re-renders even when the item didn't change
get _renderDependencies() {
return [...super._renderDependencies, this._session];
}
render() {}
async asyncRender() {
if (!this.initialized) return;
if (this._isAlreadyRendered("async")) return;
let item = this.item;
let session = this._session;
this._body.replaceChildren();
if (!item || !session) {
this._count = 0;
this._updateHidden();
return;
}
let preview = session.getPreviews(item.id);
if (preview?.state == 'pending') {
// Selecting an item asks for its passages outright, rather
// than waiting for its rows to be scrolled to
await session.preload([item.id]);
// The selection, or the search, may have moved on while
// deriving
if (this.item !== item || this._session !== session) {
return;
}
preview = session.getPreviews(item.id);
}
let entries = preview?.state == 'filled' ? preview.entries : [];
this._count = entries.length;
this._section.setCount(entries.length);
this._updateHidden();
// Left in the order the preview holds 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 entry of entries) {
let row = document.createXULElement('search-result-row');
row.result = entry;
this._body.append(row);
}
}
// For a file attachment, open it where the activated card's passage
// is: for a PDF with a stored chunk position, scrolled to and
// highlighting the section; without one (EPUB, snapshot, a passage
// cut from flat text), just open it.
_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
// derivation, 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._session || this.tabType == 'reader'
|| this._count === 0;
}
}
customElements.define("search-results-box", SearchResultsBox);
}

View file

@ -0,0 +1,140 @@
/*
***** 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";
{
// The pane shown when what's selected is search matches rather than
// items: a card per selected passage (see
// Zotero.BestMatch.Session#getPreviews()), grouped under the attachment
// each came from.
//
// A passage isn't an item, so nothing an item pane says about one -- its
// fields, its attachments, its tags -- has anything to describe. What
// there is to show is the passage itself.
class SearchResultsPane extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<html:div class="custom-head"></html:div>
<html:div class="body zotero-view-item"></html:div>
`);
_matches = [];
// @param {Object[]} matches - { itemID, entry }, in the order they're shown
set matches(matches) {
this._matches = matches || [];
}
get matches() {
return this._matches;
}
init() {
this._body = this.querySelector('.body');
// Double-click, or Enter on a focused card, opens the attachment
// at the passage
this._body.addEventListener('dblclick', this._handleActivate);
this._body.addEventListener('keydown', (event) => {
if (event.key == 'Enter') {
this._handleActivate(event);
}
});
}
render() {
if (!this.initialized) return;
this._body.replaceChildren();
// Grouped by attachment, in the order the matches arrive, so the
// pane reads in the order the rows do
let byItem = new Map();
for (let match of this._matches) {
if (!byItem.has(match.itemID)) {
byItem.set(match.itemID, []);
}
byItem.get(match.itemID).push(match.entry);
}
for (let [itemID, entries] of byItem) {
let item = Zotero.Items.get(itemID);
let section = document.createXULElement('collapsible-section');
section.dataset.l10nId = 'section-search-results';
section.dataset.pane = `search-results-${itemID}`;
section.summary = item ? item.getDisplayTitle() : '';
document.l10n.setArgs(section, { count: entries.length });
let body = document.createElement('div');
body.className = 'body';
section.append(body);
this._body.append(section);
for (let entry of entries) {
let row = document.createXULElement('search-result-row');
row.result = entry;
row.dataset.itemId = itemID;
body.append(row);
}
}
}
// The buttons the pane's host puts above the cards, if any
renderCustomHead(callback) {
let customHead = this.querySelector(".custom-head");
customHead.replaceChildren();
if (callback) {
callback({
doc: document,
append: (...args) => customHead.append(...args),
});
}
}
// Open the activated card's attachment where its passage is: for a
// PDF with a stored chunk position, scrolled to and highlighting the
// section; without one (EPUB, snapshot, a passage cut from flat
// text), just open it.
_handleActivate = (event) => {
let row = event.target.closest('search-result-row');
// The Show More toggle isn't an activation
if (!row || event.target.closest('.show-more')) {
return;
}
if (typeof ZoteroPane == 'undefined') {
return;
}
let itemID = parseInt(row.dataset.itemId);
let item = Zotero.Items.get(itemID);
if (!item || !item.isFileAttachment()) {
return;
}
let position = row.result?.position;
ZoteroPane.viewAttachment(itemID, null, false,
position ? { location: { position } } : undefined)
.catch(e => Zotero.logError(e));
};
}
customElements.define("search-results-pane", SearchResultsPane);
}

View file

@ -31,7 +31,7 @@ const LibraryTree = require('./libraryTree');
const VirtualizedTable = require('components/virtualized-table');
const { VirtualizedTree, formatColumnName } = VirtualizedTable;
const { COLUMNS } = require("zotero/itemTreeColumns");
const { ItemTreeRow } = require('zotero/itemTreeRow');
const { ItemTreeRow, SearchMatch } = require('zotero/itemTreeRow');
const { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs");
const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.mjs');
@ -1975,6 +1975,36 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
/**
* The session holding the passages of the active best-match search, when
* the view's rows come from one
*
* @return {Zotero.BestMatch.Session|null}
*/
get bestMatchSession() {
return this.rowProvider?.bestMatchSession ?? null;
}
/**
* The passages the selection names, when search-match rows are all it
* holds. Empty for any selection with something else in it, so a caller
* can tell "these are passages" from "these are items".
*
* A pending row stands in for passages that don't exist yet and names
* none.
*
* @return {Object[]} - { itemID, entry } per selected passage
*/
getSelectedSearchMatches() {
let selected = this.getSelectedObjects();
if (!selected.length || !selected.every(ref => ref instanceof SearchMatch)) {
return [];
}
return selected
.filter(ref => ref.entry)
.map(ref => ({ itemID: ref.itemID, entry: ref.entry }));
}
/**
* Get selected items, omitting collections and searches in the trash
*/

View file

@ -620,9 +620,13 @@ class SearchMatch {
/**
* 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
* derived, one ref per quoted entry once it's filled, and nothing when
* the item has no preview or its preview derived nothing.
*
* A preview holds every passage the item matched in; the tree shows the
* strongest few, which are the ones with a line quoted. The rest are
* read whole in the item pane.
*
* @param {Zotero.Item} item
* @param {Function} [getMatchPreviews] - itemID -> preview accessor (see
* Zotero.BestMatch.Session#getPreviews()), passed by the row
@ -637,7 +641,9 @@ class SearchMatch {
if (preview.state == 'pending') {
return [new SearchMatch(item.id)];
}
return preview.entries.map(entry => new SearchMatch(item.id, entry));
return preview.entries
.slice(0, Zotero.BestMatch.MAX_QUOTED_PASSAGES)
.map(entry => new SearchMatch(item.id, entry));
}
}

View file

@ -46,10 +46,13 @@ Zotero.BestMatch = new function () {
const LEXICAL_WEIGHT = 0.3;
// About a line: what a passage is quoted down to for a one-line preview
const SNIPPET_CHARS = 150;
// Most passages shown for one item. The strongest few say what the item
// has to offer, and quoting a passage costs work -- sometimes the model's
// -- so passages past this are not worth deriving.
const MAX_PASSAGES = 3;
// Most passages quoted for one item. Quoting one costs work -- sometimes
// the model's -- and the strongest few already say what the item has to
// offer at a glance. The rest are still derived: they're read whole
// rather than quoted, which needs no line chosen.
const MAX_QUOTED_PASSAGES = 3;
this.MAX_QUOTED_PASSAGES = MAX_QUOTED_PASSAGES;
//
// Errors
@ -415,14 +418,13 @@ Zotero.BestMatch = new function () {
* piece of the document that knows where it sits, rather than a
* window cut around a word.
*
* At most MAX_PASSAGES come back: the strongest few say what the item
* has to offer, and quoting the rest costs more than it shows.
* Every passage that clears its engine's threshold comes back, so a
* consumer showing passages whole can show all of them.
*
* Each passage carries the whole chunk's `text` and a `snippet`
* extent within it -- the one line that best shows the query (see
* _pickSnippets()) -- so a consumer can quote the line or read the
* passage from the same entry. `ranges` locate the query's literal
* matches in the full text.
* Each passage carries the whole chunk's `text`, and the strongest
* MAX_QUOTED_PASSAGES of them also carry a `snippet` extent within it
* -- the one line that best shows the query (see _pickSnippets()).
* `ranges` locate the query's literal matches in the full text.
*
* Only the engines scoring recorded a match in are asked (see
* score()), so an item that matched one of them never pays the
@ -430,9 +432,10 @@ Zotero.BestMatch = new function () {
* nothing.
*
* @param {Number} itemID
* @return {Promise<Object[]>} - Entries with `text`, `snippet`,
* `ranges` and `strength`, plus location fields where the
* passage knows them
* @return {Promise<Object[]>} - Entries with `text`, `ranges` and
* `strength`, plus location fields where the passage knows them,
* strongest first; the first MAX_QUOTED_PASSAGES also have
* `snippet`
*/
async getMatchingExcerpts(itemID) {
let queryText = this._queryText;
@ -476,11 +479,10 @@ Zotero.BestMatch = new function () {
});
}
entries.sort((a, b) => b.strength - a.strength);
entries = entries.slice(0, MAX_PASSAGES);
// Quoting is the expensive half -- a passage the query's words
// aren't in has to be read by the model -- so it happens only for
// the passages that survived
await this._pickSnippets(entries, itemID);
// aren't in has to be read by the model -- so only the passages
// that will be quoted pay for it
await this._pickSnippets(entries.slice(0, MAX_QUOTED_PASSAGES), itemID);
return entries;
}

View file

@ -2228,16 +2228,21 @@ var ZoteroPane = new function () {
return false;
}
var selectedItems = this.itemsView.getSelectedObjects();
var selectedObjects = this.itemsView.getSelectedObjects();
// A selection of search-match rows names passages rather than
// items, and the pane shows those instead
var searchMatches = this.itemsView.getSelectedSearchMatches();
// 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);
// (a search-match row, a library header) have none to show, so a
// selection of only those reads as an empty one
var selectedItems = selectedObjects.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;
this.itemPane.searchMatches = searchMatches;
this.itemPane.collectionTreeRows = collectionTreeRows;
this.itemPane.itemsView = this.itemsView;
this.itemPane.editable = this.collectionsView.editable;
@ -2252,7 +2257,10 @@ var ZoteroPane = new function () {
// Check if selection has actually changed. The onselect event that calls this
// can be called in various situations where the selection didn't actually change,
// such as whenever selectEventsSuppressed is set to false.
var ids = selectedItems.map(item => item.treeViewID);
// Keyed on what's selected rather than what the pane shows, so
// moving between two passages of the same attachment still
// counts as a change
var ids = selectedObjects.map(o => o.treeViewID);
ids.sort();
if (ids.length && Zotero.Utilities.arrayEquals(_lastSelectedItems, ids)) {
return false;

View file

@ -646,6 +646,7 @@ 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
@ -685,6 +686,15 @@ 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 =
@ -722,6 +732,8 @@ 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 =

View file

@ -106,6 +106,8 @@
@import "elements/attachmentRow";
@import "elements/attachmentAnnotationsBox";
@import "elements/annotationRow";
@import "elements/searchResultsBox";
@import "elements/itemPaneSearchResults";
@import "elements/noteRow";
@import "elements/librariesCollectionsBox";
@import "elements/duplicatesMergePane";

View file

@ -95,6 +95,7 @@ $item-pane-sections: (
"libraries-collections": var(--accent-teal),
"tags": var(--accent-orange),
"related": var(--accent-wood),
"search-results": var(--accent-gold),
);
$tagColorsLookup: (

View file

@ -1,4 +1,8 @@
annotation-row {
// 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 {
display: flex;
flex-direction: column;

View file

@ -0,0 +1,48 @@
search-results-pane {
display: flex;
flex-direction: column;
overflow-y: auto;
@include elements-custom-head;
// Keep the attachment's name legible however long it is, cutting it by
// letter rather than dropping the whole line
collapsible-section > .head .title-box .summary {
opacity: 1 !important;
width: 0;
white-space: wrap;
word-break: break-all;
display: inline;
}
// The section's icon is the quick-search magnifier, the same one the
// search bar uses
collapsible-section > .head .title::before {
content: '';
width: 16px;
height: 16px;
background: icon-url("16/universal/magnifier.svg") no-repeat center;
-moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
fill: var(--accent-gold);
stroke: var(--accent-gold);
}
collapsible-section > .body {
display: flex;
flex-direction: column;
gap: 4px;
@include comfortable {
gap: 8px;
}
}
collapsible-section:not(:last-child) {
border-bottom: 1px solid var(--fill-quinary);
}
// A passage shown on its own is there to be read, so it isn't clamped
search-result-row .body .quote {
-webkit-line-clamp: inherit !important;
}
}

View file

@ -0,0 +1,80 @@
// 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;
}
// Where the query's words are in the passage
.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;
}
}
}

View file

@ -356,6 +356,32 @@ describe("Zotero.BestMatch", function () {
assert.closeTo(excerpt.strength, 0.6, 1e-9);
});
it("should derive every matched passage but quote only the strongest", async function () {
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
let scores = [0.9, 0.8, 0.7, 0.6, 0.5];
stubs.push(sinon.stub(Zotero.Embeddings, 'getMatchingChunks').resolves(
scores.map((score, i) => ({ text: `Passage number ${i} of the document.`, score }))
));
// Semantic only, so the passages are kept on the model's word
let session = await sessionFor({ lexical: false });
let excerpts = await session.getMatchingExcerpts(attachment.id);
// Every passage the model kept comes back, for reading whole
assert.lengthOf(excerpts, scores.length);
for (let i = 0; i < scores.length; i++) {
assert.closeTo(excerpts[i].strength, scores[i], 1e-9);
}
// Only the strongest few carry the line the tree quotes
for (let i = 0; i < Zotero.BestMatch.MAX_QUOTED_PASSAGES; i++) {
assert.isDefined(excerpts[i].snippet, `passage ${i} is quoted`);
}
for (let i = Zotero.BestMatch.MAX_QUOTED_PASSAGES; i < excerpts.length; i++) {
assert.isUndefined(excerpts[i].snippet, `passage ${i} is not quoted`);
}
});
it("should fill out a short chosen sentence with what follows it", async function () {
stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true));
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));

View file

@ -605,7 +605,7 @@ describe("CollectionViewItemTree", function () {
assert.isFalse(itemsView.getRowIndexByID('SM' + note.id + '-pending'));
});
it("should treat a selected match row as no item selection", async function () {
it("should show selected match rows as passages in the item pane", 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 });
@ -613,20 +613,47 @@ describe("CollectionViewItemTree", function () {
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(() => {})));
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts').resolves([
{ key: 0, text: 'matchselect owls', ranges: [], strength: 1,
snippet: { start: 0, end: 16 } },
{ key: 1, text: 'more about owls', ranges: [], strength: 0.5,
snippet: { start: 0, end: 15 } }
]));
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);
let first = await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
let second = itemsView.getRowIndexByID('SM' + attachment.id + '-1');
itemsView.selection.select(second);
// A passage isn't an item, so no item is selected
assert.lengthOf(itemsView.getSelectedItems(), 0);
let matches = itemsView.getSelectedSearchMatches();
assert.lengthOf(matches, 1);
assert.equal(matches[0].itemID, attachment.id);
assert.equal(matches[0].entry.key, 1);
await zp.itemSelected();
assert.equal(zp.itemPane.mode, 'message');
// The passage is shown on its own, not the attachment's fields
assert.equal(zp.itemPane.mode, 'search-results');
let pane = zp.itemPane.querySelector('#zotero-search-results-pane');
assert.lengthOf(pane.querySelectorAll('search-result-row'), 1);
// Every selected passage gets a card, under its attachment
itemsView.selection.clearSelection();
itemsView.selection.rangedSelect(first, second, true);
await zp.itemSelected();
assert.lengthOf(itemsView.getSelectedSearchMatches(), 2);
assert.equal(zp.itemPane.mode, 'search-results');
assert.lengthOf(pane.querySelectorAll('search-result-row'), 2);
assert.lengthOf(pane.querySelectorAll('collapsible-section'), 1);
// A selection holding anything that isn't a passage names none
itemsView.selection.clearSelection();
itemsView.selection.rangedSelect(
itemsView.getRowIndexByID(attachment.id), second, true);
assert.isEmpty(itemsView.getSelectedSearchMatches());
});
it("should show the top-ranked matches already derived on a new search", async function () {
@ -681,6 +708,44 @@ describe("CollectionViewItemTree", function () {
assert.equal(itemsView.getRow(first).ref.entry.text, 'fillrow owls');
});
it("should show only the quoted matches as rows", async function () {
let col = await createDataObject('collection');
let item = await createDataObject('item', { title: "quotedrows 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 entries = [0, 1, 2, 3, 4].map(i => ({
key: i,
text: `quotedrows passage ${i}`,
ranges: [],
strength: 1 - i / 10,
snippet: i < 3 ? { start: 0, end: 10 } : undefined
}));
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
.resolves(entries));
await select(win, col);
itemsView = zp.itemsView;
await itemsView.setFilter('search', 'some query');
// The tree shows the quoted passages; the rest are read in
// the item pane
await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
for (let i = 0; i < Zotero.BestMatch.MAX_QUOTED_PASSAGES; i++) {
assert.notStrictEqual(
itemsView.getRowIndexByID('SM' + attachment.id + '-' + i), false,
`passage ${i} has a row`);
}
assert.isFalse(itemsView.getRowIndexByID(
'SM' + attachment.id + '-' + Zotero.BestMatch.MAX_QUOTED_PASSAGES));
// The preview still holds them all
assert.lengthOf(
itemsView.bestMatchSession.getPreviews(attachment.id).entries, 5);
});
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] });