diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx
index 1415859e66..b7432fe5cf 100644
--- a/chrome/content/zotero/collectionViewItemTree.jsx
+++ b/chrome/content/zotero/collectionViewItemTree.jsx
@@ -206,8 +206,13 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
);
let libraries = status.libraries
.filter(lib => !libraryIDs.size || libraryIDs.has(lib.libraryID));
- let indexed = libraries.reduce((sum, lib) => sum + lib.indexed, 0);
- let total = libraries.reduce((sum, lib) => sum + lib.eligible, 0);
+ // Coverage is coverage: attachment fulltext is reported separately
+ // in the preferences, but an incomplete index is incomplete
+ // whichever part of it is still filling in
+ let indexed = libraries.reduce(
+ (sum, lib) => sum + lib.indexed + lib.indexedAttachments, 0);
+ let total = libraries.reduce(
+ (sum, lib) => sum + lib.eligible + lib.eligibleAttachments, 0);
if (indexed >= total) {
return null;
}
diff --git a/chrome/content/zotero/customElements.js b/chrome/content/zotero/customElements.js
index 59b5b319bc..03f2108a53 100644
--- a/chrome/content/zotero/customElements.js
+++ b/chrome/content/zotero/customElements.js
@@ -74,6 +74,8 @@ Services.scriptloader.loadSubScript('chrome://zotero/content/elements/itemTreeMe
['attachment-row', 'chrome://zotero/content/elements/attachmentRow.js'],
['attachment-annotations-box', 'chrome://zotero/content/elements/attachmentAnnotationsBox.js'],
['annotation-row', 'chrome://zotero/content/elements/annotationRow.js'],
+ ['search-results-box', 'chrome://zotero/content/elements/searchResultsBox.js'],
+ ['search-result-row', 'chrome://zotero/content/elements/searchResultRow.js'],
['annotation-items-pane', 'chrome://zotero/content/elements/annotationItemsPane.js'],
['context-notes-list', 'chrome://zotero/content/elements/contextNotesList.js'],
['note-row', 'chrome://zotero/content/elements/noteRow.js'],
diff --git a/chrome/content/zotero/elements/itemDetails.js b/chrome/content/zotero/elements/itemDetails.js
index 2e9a705f0a..4fbcd62137 100644
--- a/chrome/content/zotero/elements/itemDetails.js
+++ b/chrome/content/zotero/elements/itemDetails.js
@@ -55,6 +55,8 @@
+
+
diff --git a/chrome/content/zotero/elements/itemPaneSidenav.js b/chrome/content/zotero/elements/itemPaneSidenav.js
index 8da724e7da..da0b3539e3 100644
--- a/chrome/content/zotero/elements/itemPaneSidenav.js
+++ b/chrome/content/zotero/elements/itemPaneSidenav.js
@@ -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() {
diff --git a/chrome/content/zotero/elements/searchResultRow.js b/chrome/content/zotero/elements/searchResultRow.js
new file mode 100644
index 0000000000..9cb6a5d778
--- /dev/null
+++ b/chrome/content/zotero/elements/searchResultRow.js
@@ -0,0 +1,131 @@
+/*
+ ***** 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 .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+"use strict";
+
+{
+ // A best-match search result card: one fulltext chunk of an attachment
+ // (see Zotero.Embeddings.getMatchingChunks()), presented like an
+ // annotation-row -- where the chunk sits in the document as the head,
+ // its text as the quote. The two share their styling (see
+ // scss/elements/_annotationRow.scss).
+ class SearchResultRow extends XULElementBase {
+ content = MozXULElement.parseXULToFragment(`
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ _chunk = null;
+
+ get chunk() {
+ return this._chunk;
+ }
+
+ set chunk(chunk) {
+ this._chunk = chunk;
+ 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._chunk) return;
+
+ // The chunk's outline path says where in the document it came
+ // from; a chunk from a document without an outline falls back to
+ // a generic label
+ if (this._chunk.outlinePath) {
+ this._path.removeAttribute('data-l10n-id');
+ this._path.textContent = this._chunk.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._chunk.sectionParts;
+ this._part.hidden = !(parts > 1);
+ if (parts > 1) {
+ this._part.textContent = `${this._chunk.sectionPart}/${parts}`;
+ }
+ // The page the chunk's section starts on, labeled the way
+ // annotation rows label theirs
+ this._location.hidden = !this._chunk.pageLabel;
+ if (this._chunk.pageLabel) {
+ this._location.textContent
+ = Zotero.getString('pdfReader.page') + ' ' + this._chunk.pageLabel;
+ }
+
+ this._quote.textContent = this._chunk.text || '';
+
+ // 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._chunk.outlinePath,
+ this._location.hidden ? '' : this._location.textContent,
+ this._chunk.text
+ ].filter(Boolean).join('. '));
+ }
+
+ _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);
+}
diff --git a/chrome/content/zotero/elements/searchResultsBox.js b/chrome/content/zotero/elements/searchResultsBox.js
new file mode 100644
index 0000000000..0afa3fde28
--- /dev/null
+++ b/chrome/content/zotero/elements/searchResultsBox.js
@@ -0,0 +1,192 @@
+/*
+ ***** 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 .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+{
+ const { ItemPaneSectionElementBase } = ChromeUtils.importESModule(
+ "chrome://zotero/content/elements/itemPaneSectionElementBase.mjs",
+ { global: "current" }
+ );
+
+ // Most matching chunks shown for an attachment
+ const MAX_RESULTS = 5;
+
+ // Why an attachment matched the active best-match search: cards with the
+ // fulltext chunks most similar to the query, so they can be previewed
+ // without opening the file. Shown only while a best-match search is
+ // active, for attachments with matching indexed chunks.
+ class SearchResultsBox extends ItemPaneSectionElementBase {
+ content = MozXULElement.parseXULToFragment(`
+
+
+
+
+ `);
+
+ get item() {
+ return this._item;
+ }
+
+ set item(item) {
+ super.item = (item instanceof Zotero.Item && item.isFileAttachment()) ? item : null;
+ // A new item's emptiness isn't known until asyncRender scores it
+ this._count = undefined;
+ }
+
+ get collectionTreeRows() {
+ return super.collectionTreeRows;
+ }
+
+ // The item pane sets collectionTreeRows after item, so this is where
+ // everything visibility depends on is finally known
+ set collectionTreeRows(collectionTreeRows) {
+ super.collectionTreeRows = collectionTreeRows;
+ this._updateHidden();
+ }
+
+ init() {
+ this.initCollapsibleSection();
+ this._body = this.querySelector('.body');
+ // The header's count placeholder needs a value before the first
+ // async render fills in the real one
+ this._section.setCount(0);
+ // Double-click (or Enter on a focused card) opens the attachment
+ // at the chunk
+ this._body.addEventListener('dblclick', this._handleActivate);
+ this._body.addEventListener('keydown', (event) => {
+ if (event.key == 'Enter') {
+ this._handleActivate(event);
+ }
+ });
+ }
+
+ // The query the selected collection rows are ranked by, or false when
+ // no best-match search is active. Rows can be duck-typed stand-ins
+ // (e.g. the citation dialog's), which implement only part of the row
+ // API.
+ get _query() {
+ // Quick-search state (setSearch()) lives on collection tree row
+ // *instances*, and the ones passed down the item pane are
+ // re-fetched from the collections view at item-selection time --
+ // which can have rebuilt its rows since the items view got its
+ // own set. Only the items view's instances are guaranteed to
+ // carry the active search, so prefer those; the passed rows are
+ // the fallback for hosts without an items view.
+ let itemsView = this.closest('item-pane')?.itemsView;
+ let rows = itemsView?.collectionTreeRows?.length
+ ? itemsView.collectionTreeRows
+ : this.collectionTreeRows;
+ for (let row of rows || []) {
+ if (typeof row.getBestMatchQuery == 'function') {
+ let query = row.getBestMatchQuery();
+ if (query) {
+ return query;
+ }
+ }
+ }
+ return false;
+ }
+
+ // A query change re-renders even when the item didn't change
+ get _renderDependencies() {
+ return [...super._renderDependencies, this._query];
+ }
+
+ render() {}
+
+ async asyncRender() {
+ if (!this.initialized) return;
+ if (this._isAlreadyRendered("async")) return;
+
+ let item = this.item;
+ let query = this._query;
+ this._body.replaceChildren();
+ if (!item || !query) {
+ return;
+ }
+
+ let chunks = [];
+ try {
+ chunks = await Zotero.Embeddings.getMatchingChunks(query, item.id,
+ { limit: MAX_RESULTS });
+ }
+ catch (e) {
+ // Nothing to show while the model is still downloading or the
+ // index is being rebuilt
+ if (!(e instanceof Zotero.Embeddings.IndexNotReadyError)) {
+ Zotero.logError(e);
+ }
+ }
+ // Only fulltext chunks carry their own text; anything else about
+ // the item is already visible in the pane
+ chunks = chunks.filter(chunk => chunk.text);
+ // The selection may have moved on while scoring
+ if (this.item !== item) {
+ return;
+ }
+ this._count = chunks.length;
+ this._section.setCount(chunks.length);
+ this._updateHidden();
+ // Left in the order getMatchingChunks() returns them, strongest
+ // match first: with only a handful of cards shown, the best one
+ // earning the top slot matters more than reading them in
+ // document order
+ for (let chunk of chunks) {
+ let row = document.createXULElement('search-result-row');
+ row.chunk = chunk;
+ this._body.append(row);
+ }
+ }
+
+ // Open the attachment where the activated card's chunk is: for a PDF,
+ // scrolled to and highlighting the chunk's section; without a stored
+ // position (EPUB, snapshot, flat-text fallback), 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 || event.target.closest('.show-more')) {
+ return;
+ }
+ if (typeof ZoteroPane == 'undefined') {
+ return;
+ }
+ let position = row.chunk?.position;
+ ZoteroPane.viewAttachment(this.item.id, null, false,
+ position ? { location: { position } } : undefined)
+ .catch(e => Zotero.logError(e));
+ };
+
+ _updateHidden() {
+ // Visible only for a file attachment during a best-match search;
+ // asyncRender hides it again when nothing matched. Deciding
+ // emptiness needs the async scoring, so unlike the annotations
+ // section this one can't know its final state synchronously --
+ // it appears, then empties out, rather than flickering in late.
+ this.hidden = !this.item || !this._query || this.tabType == 'reader'
+ || this._count === 0;
+ }
+ }
+
+ customElements.define("search-results-box", SearchResultsBox);
+}
diff --git a/chrome/content/zotero/preferences/preferences_advanced.js b/chrome/content/zotero/preferences/preferences_advanced.js
index 69d53c69f4..23097cf7c2 100644
--- a/chrome/content/zotero/preferences/preferences_advanced.js
+++ b/chrome/content/zotero/preferences/preferences_advanced.js
@@ -152,13 +152,17 @@ Zotero_Preferences.Advanced = {
updateSemanticSearchUI: function (status) {
let statusBox = document.getElementById('semantic-search-status');
statusBox.hidden = !status.enabled;
+ // Fulltext indexing only means something with a model selected
+ document.getElementById('semantic-search-index-fulltext').disabled = !status.enabled;
if (!status.enabled) {
return;
}
// Phase / status message
let phaseLabel = document.getElementById('semantic-search-phase');
- let hasRemaining = status.libraries.some(lib => lib.indexed < lib.eligible);
+ let hasRemaining = status.libraries.some(
+ lib => lib.indexed < lib.eligible
+ || lib.indexedAttachments < lib.eligibleAttachments);
if (status.error) {
document.l10n.setAttributes(phaseLabel, 'preferences-advanced-semantic-search-error', { error: status.error });
}
@@ -214,10 +218,18 @@ Zotero_Preferences.Advanced = {
'value',
Zotero.Utilities.Internal.stringWithColon(lib.name)
);
- grid.children[i * 2 + 1].setAttribute(
- 'value',
- `${lib.indexed.toLocaleString()} / ${lib.eligible.toLocaleString()}`
- );
+ // Attachment fulltext is reported on its own, since it's a much
+ // larger and much slower job than the rest -- one combined count
+ // would look stalled. With fulltext indexing off, none are
+ // eligible and only the item count is shown.
+ let counts = `${lib.indexed.toLocaleString()} / ${lib.eligible.toLocaleString()}`;
+ if (lib.eligibleAttachments) {
+ counts += ` ${Zotero.getString('general.and')} `
+ + `${lib.indexedAttachments.toLocaleString()} / `
+ + `${lib.eligibleAttachments.toLocaleString()} `
+ + Zotero.getString('itemTypes.attachment');
+ }
+ grid.children[i * 2 + 1].setAttribute('value', counts);
});
},
diff --git a/chrome/content/zotero/preferences/preferences_advanced.xhtml b/chrome/content/zotero/preferences/preferences_advanced.xhtml
index 7a66fa47d1..3617a6bab2 100644
--- a/chrome/content/zotero/preferences/preferences_advanced.xhtml
+++ b/chrome/content/zotero/preferences/preferences_advanced.xhtml
@@ -307,6 +307,10 @@
+
diff --git a/chrome/content/zotero/xpcom/embeddings.js b/chrome/content/zotero/xpcom/embeddings.js
index 6100019982..097871facc 100644
--- a/chrome/content/zotero/xpcom/embeddings.js
+++ b/chrome/content/zotero/xpcom/embeddings.js
@@ -378,12 +378,34 @@ Zotero.Embeddings = new function () {
// Zotero.Embeddings.Chunking);
// every chunk row carries the hash of the item's full source text,
// and scoring takes the item's best chunk.
+ // An attachment fulltext chunk also records what and where it
+ // came from, so a match can be previewed and located without
+ // re-deriving the chunking: its text, its section's outline path,
+ // the top-level block range it covers, the page it starts on
+ // (pageLabel), a reader-navigable position (navPosition, JSON --
+ // see Zotero.SDT.getSections()), and which piece of a split
+ // section it is (sectionPart of sectionParts). All NULL for
+ // chunks of other item types, which are their own preview and
+ // location.
+ // An attachment that yields no text at all (missing file,
+ // password-protected, no text layer) gets a single row with a
+ // NULL embedding: a record that it was processed, so progress
+ // counts it and later passes skip it (via sourceHash) until the
+ // file changes. Scoring reads only rows with an embedding.
await Zotero.DB.queryAsync(
"CREATE TABLE embeddings.itemEmbeddings (\n"
+ " itemID INTEGER NOT NULL,\n"
+ " chunkIndex INTEGER NOT NULL,\n"
- + " embedding BLOB NOT NULL,\n"
+ + " embedding BLOB,\n"
+ " sourceHash TEXT NOT NULL,\n"
+ + " chunkText TEXT,\n"
+ + " outlinePath TEXT,\n"
+ + " startBlock INTEGER,\n"
+ + " endBlock INTEGER,\n"
+ + " pageLabel TEXT,\n"
+ + " navPosition TEXT,\n"
+ + " sectionPart INTEGER,\n"
+ + " sectionParts INTEGER,\n"
+ " PRIMARY KEY (itemID, chunkIndex)\n"
+ ")"
);
@@ -1095,6 +1117,39 @@ Zotero.Embeddings = new function () {
return new Float32Array(bytes.buffer);
}
+ // The shared guards of the scoring paths: wait out any in-progress model
+ // switch, so a query isn't embedded with one model and compared against
+ // another's vectors, and confirm the stored vectors were produced by the
+ // active model -- during a switch, or a reindex after a revision bump,
+ // the database isn't stamped for the new model until the indexer starts
+ // filling it. Returns the model's calibration: indexing calibrates the
+ // model before it writes a single vector, so a database stamped for this
+ // model always has one to go with it -- but the numbers still have to be
+ // read into memory, since getScoreFraction() reads them synchronously
+ // while rendering.
+ async function _requireReadyIndex() {
+ await Zotero.Embeddings.Indexing.waitForPendingModelSwitch();
+ await Zotero.Embeddings.initDB();
+ let modelVersion = Zotero.Embeddings.getModelVersion();
+ let indexedVersion = await Zotero.DB.valueQueryAsync(
+ "SELECT value FROM embeddings.itemEmbeddingsMeta WHERE key='modelVersion'"
+ );
+ if (indexedVersion !== modelVersion) {
+ throw new Zotero.Embeddings.IndexNotReadyError(
+ `Embeddings index is for '${indexedVersion || 'no model'}', `
+ + `but the active model is '${modelVersion}'`
+ );
+ }
+ let calibration = await Zotero.Embeddings.loadCalibration();
+ if (!calibration) {
+ throw new Zotero.Embeddings.IndexNotReadyError(
+ `Embeddings index is stamped for '${modelVersion}' but the model `
+ + `has no calibration`
+ );
+ }
+ return calibration;
+ }
+
/**
* Score a given set of items by similarity to a query. Items scoring below
* the model's measured minimum aren't matches and aren't returned (see
@@ -1120,34 +1175,7 @@ Zotero.Embeddings = new function () {
if (!itemIDs.length || !this.isEnabled()) {
return scores;
}
- // Wait out any in-progress model switch, so the query isn't embedded
- // with one model and compared against another's vectors
- await Zotero.Embeddings.Indexing.waitForPendingModelSwitch();
- await this.initDB();
- // The stored vectors must have been produced by the active model.
- // During a switch, or a reindex after a revision bump, the database
- // isn't stamped for the new model until the indexer starts filling it.
- let modelVersion = this.getModelVersion();
- let indexedVersion = await Zotero.DB.valueQueryAsync(
- "SELECT value FROM embeddings.itemEmbeddingsMeta WHERE key='modelVersion'"
- );
- if (indexedVersion !== modelVersion) {
- throw new this.IndexNotReadyError(
- `Embeddings index is for '${indexedVersion || 'no model'}', `
- + `but the active model is '${modelVersion}'`
- );
- }
- // Indexing calibrates the model before it writes a single vector, so a
- // database stamped for this model always has a calibration to go with
- // it -- but the numbers still have to be read into memory, since
- // getScoreFraction() reads them synchronously while rendering.
- let calibration = await this.loadCalibration();
- if (!calibration) {
- throw new this.IndexNotReadyError(
- `Embeddings index is stamped for '${modelVersion}' but the model `
- + `has no calibration`
- );
- }
+ let calibration = await _requireReadyIndex();
let generation = _modelGeneration;
let query = _center(await this.embedQuery(queryText));
let minScore = calibration.minScore;
@@ -1169,9 +1197,11 @@ Zotero.Embeddings = new function () {
throw new this.IndexNotReadyError('Model changed during scoring');
}
let chunk = itemIDs.slice(i, i + chunkSize);
+ // Rows without an embedding are processed-but-empty markers (see
+ // _setUpDB()), with nothing to score
let rows = await Zotero.DB.queryAsync(
"SELECT itemID, embedding FROM embeddings.itemEmbeddings WHERE itemID IN ("
- + chunk.map(() => '?').join(',') + ")",
+ + chunk.map(() => '?').join(',') + ") AND embedding IS NOT NULL",
chunk
);
for (let row of rows) {
@@ -1207,6 +1237,55 @@ Zotero.Embeddings = new function () {
}
return scores;
};
+
+ /**
+ * The chunks of a single item most similar to a query, each with where in
+ * the item it came from -- for surfacing why an item matched (e.g. which
+ * section of an attachment's full text). Chunks scoring below the model's
+ * measured minimum aren't matches and aren't returned.
+ *
+ * The text and location fields describe attachment fulltext chunks (see
+ * the itemEmbeddings table); for other item types they're null, and the
+ * item itself is the preview and the location.
+ *
+ * @param {String} queryText
+ * @param {Number} itemID
+ * @param {Object} [options]
+ * @param {Number} [options.limit=3] - Most chunks to return
+ * @return {Promise