mirror of
https://github.com/zotero/zotero.git
synced 2026-08-28 05:25:31 +00:00
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:
parent
a96d866d1b
commit
2b79e3ddb4
20 changed files with 839 additions and 40 deletions
|
|
@ -387,6 +387,16 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
|
||||||
return kept;
|
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
|
* Called for every pending search-match row the tree draws: rendering
|
||||||
* is the demand signal for deriving previews. Reports are collected
|
* is the demand signal for deriving previews. Reports are collected
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
|
||||||
['attachment-row', 'chrome://zotero/content/elements/attachmentRow.js'],
|
['attachment-row', 'chrome://zotero/content/elements/attachmentRow.js'],
|
||||||
['attachment-annotations-box', 'chrome://zotero/content/elements/attachmentAnnotationsBox.js'],
|
['attachment-annotations-box', 'chrome://zotero/content/elements/attachmentAnnotationsBox.js'],
|
||||||
['annotation-row', 'chrome://zotero/content/elements/annotationRow.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'],
|
['annotation-items-pane', 'chrome://zotero/content/elements/annotationItemsPane.js'],
|
||||||
['context-notes-list', 'chrome://zotero/content/elements/contextNotesList.js'],
|
['context-notes-list', 'chrome://zotero/content/elements/contextNotesList.js'],
|
||||||
['note-row', 'chrome://zotero/content/elements/noteRow.js'],
|
['note-row', 'chrome://zotero/content/elements/noteRow.js'],
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@
|
||||||
|
|
||||||
<related-box id="zotero-editpane-related" class="zotero-editpane-related" data-pane="related"/>
|
<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>
|
||||||
</html:div>
|
</html:div>
|
||||||
</hbox>
|
</hbox>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
<description id="batch-edit-prompt-message" />
|
<description id="batch-edit-prompt-message" />
|
||||||
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
|
<button id="batch-edit-prompt-enable" data-l10n-id="item-pane-batch-editing-enable" />
|
||||||
</groupbox>
|
</groupbox>
|
||||||
|
<search-results-pane id="zotero-search-results-pane" />
|
||||||
</deck>
|
</deck>
|
||||||
<item-pane-sidenav id="zotero-view-item-sidenav" no-context-notes="true" class="zotero-view-item-sidenav"/>
|
<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._duplicatesPane = this.querySelector("#zotero-duplicates-merge-pane");
|
||||||
this._messagePane = this.querySelector("#zotero-item-message");
|
this._messagePane = this.querySelector("#zotero-item-message");
|
||||||
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
|
this._annotationsPane = this.querySelector("#zotero-annotations-pane");
|
||||||
|
this._searchResultsPane = this.querySelector("#zotero-search-results-pane");
|
||||||
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
|
this._batchEditEnableBtn = this.querySelector("#batch-edit-prompt button");
|
||||||
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
|
this._batchEditPromptMessage = this.querySelector("#batch-edit-prompt-message");
|
||||||
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
|
this._sidenav = this.querySelector("#zotero-view-item-sidenav");
|
||||||
|
|
@ -113,12 +115,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
get mode() {
|
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
|
* 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) {
|
set mode(type) {
|
||||||
this.setAttribute("view-type", type);
|
this.setAttribute("view-type", type);
|
||||||
|
|
@ -133,6 +135,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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;
|
if (!this.data) return false;
|
||||||
let renderStatus = false;
|
let renderStatus = false;
|
||||||
// Only annotations selected
|
// Only annotations selected
|
||||||
|
|
@ -193,6 +200,13 @@
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderSearchResults(matches) {
|
||||||
|
this.mode = "search-results";
|
||||||
|
this._searchResultsPane.matches = matches;
|
||||||
|
this._searchResultsPane.render();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
renderNoteEditor(item) {
|
renderNoteEditor(item) {
|
||||||
this.mode = "note";
|
this.mode = "note";
|
||||||
|
|
||||||
|
|
@ -627,8 +641,12 @@
|
||||||
getCurrentPane(mode = undefined) {
|
getCurrentPane(mode = undefined) {
|
||||||
if (!mode) {
|
if (!mode) {
|
||||||
// Guess a mode from the current data
|
// 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
|
// 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";
|
mode = "annotations";
|
||||||
}
|
}
|
||||||
// No/multiple objects are selected OR selected object is a trashed collection/search
|
// No/multiple objects are selected OR selected object is a trashed collection/search
|
||||||
|
|
@ -648,7 +666,8 @@
|
||||||
item: "_itemDetails",
|
item: "_itemDetails",
|
||||||
note: "_noteEditor",
|
note: "_noteEditor",
|
||||||
duplicates: "_duplicatesPane",
|
duplicates: "_duplicatesPane",
|
||||||
annotations: "_annotationsPane"
|
annotations: "_annotationsPane",
|
||||||
|
"search-results": "_searchResultsPane"
|
||||||
};
|
};
|
||||||
return this[map[mode]];
|
return this[map[mode]];
|
||||||
}
|
}
|
||||||
|
|
@ -736,6 +755,10 @@
|
||||||
this._deck.selectedIndex = 5;
|
this._deck.selectedIndex = 5;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "search-results": {
|
||||||
|
this._deck.selectedIndex = 6;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let isViewingItem = type == "item";
|
let isViewingItem = type == "item";
|
||||||
let isViewingDuplicates = type == "duplicates";
|
let isViewingDuplicates = type == "duplicates";
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
get _builtInPanes() {
|
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() {
|
get container() {
|
||||||
|
|
|
||||||
161
chrome/content/zotero/elements/searchResultRow.js
Normal file
161
chrome/content/zotero/elements/searchResultRow.js
Normal 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);
|
||||||
|
}
|
||||||
176
chrome/content/zotero/elements/searchResultsBox.js
Normal file
176
chrome/content/zotero/elements/searchResultsBox.js
Normal 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);
|
||||||
|
}
|
||||||
140
chrome/content/zotero/elements/searchResultsPane.js
Normal file
140
chrome/content/zotero/elements/searchResultsPane.js
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ const LibraryTree = require('./libraryTree');
|
||||||
const VirtualizedTable = require('components/virtualized-table');
|
const VirtualizedTable = require('components/virtualized-table');
|
||||||
const { VirtualizedTree, formatColumnName } = VirtualizedTable;
|
const { VirtualizedTree, formatColumnName } = VirtualizedTable;
|
||||||
const { COLUMNS } = require("zotero/itemTreeColumns");
|
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 { OS } = ChromeUtils.importESModule("chrome://zotero/content/osfile.mjs");
|
||||||
const { ZOTERO_CONFIG } = ChromeUtils.importESModule('resource://zotero/config.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
|
* Get selected items, omitting collections and searches in the trash
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -620,9 +620,13 @@ class SearchMatch {
|
||||||
/**
|
/**
|
||||||
* The search-match refs to materialize under an item, from its
|
* The search-match refs to materialize under an item, from its
|
||||||
* best-match preview: one pending ref while the preview is being
|
* 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.
|
* 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 {Zotero.Item} item
|
||||||
* @param {Function} [getMatchPreviews] - itemID -> preview accessor (see
|
* @param {Function} [getMatchPreviews] - itemID -> preview accessor (see
|
||||||
* Zotero.BestMatch.Session#getPreviews()), passed by the row
|
* Zotero.BestMatch.Session#getPreviews()), passed by the row
|
||||||
|
|
@ -637,7 +641,9 @@ class SearchMatch {
|
||||||
if (preview.state == 'pending') {
|
if (preview.state == 'pending') {
|
||||||
return [new SearchMatch(item.id)];
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,13 @@ Zotero.BestMatch = new function () {
|
||||||
const LEXICAL_WEIGHT = 0.3;
|
const LEXICAL_WEIGHT = 0.3;
|
||||||
// About a line: what a passage is quoted down to for a one-line preview
|
// About a line: what a passage is quoted down to for a one-line preview
|
||||||
const SNIPPET_CHARS = 150;
|
const SNIPPET_CHARS = 150;
|
||||||
// Most passages shown for one item. The strongest few say what the item
|
// Most passages quoted for one item. Quoting one costs work -- sometimes
|
||||||
// has to offer, and quoting a passage costs work -- sometimes the model's
|
// the model's -- and the strongest few already say what the item has to
|
||||||
// -- so passages past this are not worth deriving.
|
// offer at a glance. The rest are still derived: they're read whole
|
||||||
const MAX_PASSAGES = 3;
|
// rather than quoted, which needs no line chosen.
|
||||||
|
const MAX_QUOTED_PASSAGES = 3;
|
||||||
|
|
||||||
|
this.MAX_QUOTED_PASSAGES = MAX_QUOTED_PASSAGES;
|
||||||
|
|
||||||
//
|
//
|
||||||
// Errors
|
// Errors
|
||||||
|
|
@ -415,14 +418,13 @@ Zotero.BestMatch = new function () {
|
||||||
* piece of the document that knows where it sits, rather than a
|
* piece of the document that knows where it sits, rather than a
|
||||||
* window cut around a word.
|
* window cut around a word.
|
||||||
*
|
*
|
||||||
* At most MAX_PASSAGES come back: the strongest few say what the item
|
* Every passage that clears its engine's threshold comes back, so a
|
||||||
* has to offer, and quoting the rest costs more than it shows.
|
* consumer showing passages whole can show all of them.
|
||||||
*
|
*
|
||||||
* Each passage carries the whole chunk's `text` and a `snippet`
|
* Each passage carries the whole chunk's `text`, and the strongest
|
||||||
* extent within it -- the one line that best shows the query (see
|
* MAX_QUOTED_PASSAGES of them also carry a `snippet` extent within it
|
||||||
* _pickSnippets()) -- so a consumer can quote the line or read the
|
* -- the one line that best shows the query (see _pickSnippets()).
|
||||||
* passage from the same entry. `ranges` locate the query's literal
|
* `ranges` locate the query's literal matches in the full text.
|
||||||
* matches in the full text.
|
|
||||||
*
|
*
|
||||||
* Only the engines scoring recorded a match in are asked (see
|
* Only the engines scoring recorded a match in are asked (see
|
||||||
* score()), so an item that matched one of them never pays the
|
* score()), so an item that matched one of them never pays the
|
||||||
|
|
@ -430,9 +432,10 @@ Zotero.BestMatch = new function () {
|
||||||
* nothing.
|
* nothing.
|
||||||
*
|
*
|
||||||
* @param {Number} itemID
|
* @param {Number} itemID
|
||||||
* @return {Promise<Object[]>} - Entries with `text`, `snippet`,
|
* @return {Promise<Object[]>} - Entries with `text`, `ranges` and
|
||||||
* `ranges` and `strength`, plus location fields where the
|
* `strength`, plus location fields where the passage knows them,
|
||||||
* passage knows them
|
* strongest first; the first MAX_QUOTED_PASSAGES also have
|
||||||
|
* `snippet`
|
||||||
*/
|
*/
|
||||||
async getMatchingExcerpts(itemID) {
|
async getMatchingExcerpts(itemID) {
|
||||||
let queryText = this._queryText;
|
let queryText = this._queryText;
|
||||||
|
|
@ -476,11 +479,10 @@ Zotero.BestMatch = new function () {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
entries.sort((a, b) => b.strength - a.strength);
|
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
|
// 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
|
// aren't in has to be read by the model -- so only the passages
|
||||||
// the passages that survived
|
// that will be quoted pay for it
|
||||||
await this._pickSnippets(entries, itemID);
|
await this._pickSnippets(entries.slice(0, MAX_QUOTED_PASSAGES), itemID);
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2228,16 +2228,21 @@ var ZoteroPane = new function () {
|
||||||
return false;
|
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
|
// The pane shows data objects; rows standing in for something else
|
||||||
// (e.g. a search-match preview row) have none to show, so a
|
// (a search-match row, a library header) have none to show, so a
|
||||||
// selection of only those reads as an empty one for now
|
// selection of only those reads as an empty one
|
||||||
selectedItems = selectedItems.filter(o => o instanceof Zotero.DataObject);
|
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
|
// 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.
|
// selection hasn't changed, because the selected items might have been modified.
|
||||||
this.itemPane.data = selectedItems;
|
this.itemPane.data = selectedItems;
|
||||||
|
this.itemPane.searchMatches = searchMatches;
|
||||||
this.itemPane.collectionTreeRows = collectionTreeRows;
|
this.itemPane.collectionTreeRows = collectionTreeRows;
|
||||||
this.itemPane.itemsView = this.itemsView;
|
this.itemPane.itemsView = this.itemsView;
|
||||||
this.itemPane.editable = this.collectionsView.editable;
|
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
|
// 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,
|
// can be called in various situations where the selection didn't actually change,
|
||||||
// such as whenever selectEventsSuppressed is set to false.
|
// 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();
|
ids.sort();
|
||||||
if (ids.length && Zotero.Utilities.arrayEquals(_lastSelectedItems, ids)) {
|
if (ids.length && Zotero.Utilities.arrayEquals(_lastSelectedItems, ids)) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -646,6 +646,7 @@ pane-related = Related
|
||||||
pane-attachment-info = Attachment Info
|
pane-attachment-info = Attachment Info
|
||||||
pane-attachment-preview = Preview
|
pane-attachment-preview = Preview
|
||||||
pane-attachment-annotations = Annotations
|
pane-attachment-annotations = Annotations
|
||||||
|
pane-search-results = Search Results
|
||||||
|
|
||||||
pane-header-attachment-associated =
|
pane-header-attachment-associated =
|
||||||
.label = Rename associated file
|
.label = Rename associated file
|
||||||
|
|
@ -685,6 +686,15 @@ section-related =
|
||||||
.label = { $count } Related
|
.label = { $count } Related
|
||||||
section-attachment-info =
|
section-attachment-info =
|
||||||
.label = { pane-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 =
|
section-button-remove =
|
||||||
.tooltiptext = { general-remove }
|
.tooltiptext = { general-remove }
|
||||||
section-button-add =
|
section-button-add =
|
||||||
|
|
@ -722,6 +732,8 @@ sidenav-attachment-preview =
|
||||||
.tooltiptext = { pane-attachment-preview }
|
.tooltiptext = { pane-attachment-preview }
|
||||||
sidenav-attachment-annotations =
|
sidenav-attachment-annotations =
|
||||||
.tooltiptext = { pane-attachment-annotations }
|
.tooltiptext = { pane-attachment-annotations }
|
||||||
|
sidenav-search-results =
|
||||||
|
.tooltiptext = { pane-search-results }
|
||||||
sidenav-libraries-collections =
|
sidenav-libraries-collections =
|
||||||
.tooltiptext = { pane-libraries-collections }
|
.tooltiptext = { pane-libraries-collections }
|
||||||
sidenav-tags =
|
sidenav-tags =
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@
|
||||||
@import "elements/attachmentRow";
|
@import "elements/attachmentRow";
|
||||||
@import "elements/attachmentAnnotationsBox";
|
@import "elements/attachmentAnnotationsBox";
|
||||||
@import "elements/annotationRow";
|
@import "elements/annotationRow";
|
||||||
|
@import "elements/searchResultsBox";
|
||||||
|
@import "elements/itemPaneSearchResults";
|
||||||
@import "elements/noteRow";
|
@import "elements/noteRow";
|
||||||
@import "elements/librariesCollectionsBox";
|
@import "elements/librariesCollectionsBox";
|
||||||
@import "elements/duplicatesMergePane";
|
@import "elements/duplicatesMergePane";
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ $item-pane-sections: (
|
||||||
"libraries-collections": var(--accent-teal),
|
"libraries-collections": var(--accent-teal),
|
||||||
"tags": var(--accent-orange),
|
"tags": var(--accent-orange),
|
||||||
"related": var(--accent-wood),
|
"related": var(--accent-wood),
|
||||||
|
"search-results": var(--accent-gold),
|
||||||
);
|
);
|
||||||
|
|
||||||
$tagColorsLookup: (
|
$tagColorsLookup: (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
|
|
||||||
48
scss/elements/_itemPaneSearchResults.scss
Normal file
48
scss/elements/_itemPaneSearchResults.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
scss/elements/_searchResultsBox.scss
Normal file
80
scss/elements/_searchResultsBox.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -356,6 +356,32 @@ describe("Zotero.BestMatch", function () {
|
||||||
assert.closeTo(excerpt.strength, 0.6, 1e-9);
|
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 () {
|
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, 'isEnabled').returns(true));
|
||||||
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
|
stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score));
|
||||||
|
|
|
||||||
|
|
@ -605,7 +605,7 @@ describe("CollectionViewItemTree", function () {
|
||||||
assert.isFalse(itemsView.getRowIndexByID('SM' + note.id + '-pending'));
|
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 col = await createDataObject('collection');
|
||||||
let item = await createDataObject('item', { title: "matchselect A", collections: [col.id] });
|
let item = await createDataObject('item', { title: "matchselect A", collections: [col.id] });
|
||||||
let attachment = await importFileAttachment('test.pdf', { parentID: item.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]] : []));
|
itemIDs.includes(attachment.id) ? [[attachment.id, 0.8]] : []));
|
||||||
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs')
|
||||||
.callsFake(scoreEnvelope(new Map())));
|
.callsFake(scoreEnvelope(new Map())));
|
||||||
|
|
||||||
// Hold derivation so the placeholder stays put for the test
|
|
||||||
skipPreload();
|
skipPreload();
|
||||||
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts')
|
stubs.push(sinon.stub(Zotero.BestMatch.Session.prototype, 'getMatchingExcerpts').resolves([
|
||||||
.returns(new Promise(() => {})));
|
{ 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);
|
await select(win, col);
|
||||||
itemsView = zp.itemsView;
|
itemsView = zp.itemsView;
|
||||||
await itemsView.setFilter('search', 'some query');
|
await itemsView.setFilter('search', 'some query');
|
||||||
|
|
||||||
let matchRow = itemsView.getRowIndexByID('SM' + attachment.id + '-pending');
|
let first = await waitForMatchRow(itemsView, 'SM' + attachment.id + '-0');
|
||||||
itemsView.selection.select(matchRow);
|
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);
|
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();
|
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 () {
|
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');
|
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 () {
|
it("should hand a selected placeholder's selection to the first derived row", async function () {
|
||||||
let col = await createDataObject('collection');
|
let col = await createDataObject('collection');
|
||||||
let item = await createDataObject('item', { title: "handoff A", collections: [col.id] });
|
let item = await createDataObject('item', { title: "handoff A", collections: [col.id] });
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue