From ac7eb876327d828a0872a7691ace88a3a3ae2e76 Mon Sep 17 00:00:00 2001 From: Adomas Ven Date: Sun, 21 Jan 2024 08:34:09 +0200 Subject: [PATCH] Add an "Open Documents" section to the citation dialog. Closes #3332 (#3544) - Sort the Open Documents section by reverse-open order and further by reverse tab order (if unopened in this session). - If Library is selected in the Zotero window, automatically show and filter at the top selected items --- .../content/zotero/integration/quickFormat.js | 412 ++++++++++++------ chrome/content/zotero/tabs.js | 1 + chrome/locale/en-US/zotero/zotero.properties | 2 + 3 files changed, 276 insertions(+), 139 deletions(-) diff --git a/chrome/content/zotero/integration/quickFormat.js b/chrome/content/zotero/integration/quickFormat.js index f6f3ff35b6..0c314f71fd 100644 --- a/chrome/content/zotero/integration/quickFormat.js +++ b/chrome/content/zotero/integration/quickFormat.js @@ -43,9 +43,11 @@ var Zotero_QuickFormat = new function () { var locatorLocked = true; var locatorNode = null; var _searchPromise; + var inputIsPristine = true; const SEARCH_TIMEOUT = 250; const SHOWN_REFERENCES = 7; + const ITEM_LIST_MAX_ITEMS = 50; /** * Pre-initialization, when the dialog has loaded but has not yet appeared @@ -199,6 +201,9 @@ var Zotero_QuickFormat = new function () { _showCitation(node); _resize(); } + requestAnimationFrame(() => { + _updateItemList({ citedItems: [] }); + }); } catch (e) { Zotero.logError(e); @@ -431,169 +436,290 @@ var Zotero_QuickFormat = new function () { _updateItemList({ citedItems: [] }); } }); - - /** - * Updates the item list - */ - var _updateItemList = async function (options = {}) { - options = Object.assign({ - citedItems: false, - citedItemsMatchingSearch: false, - searchString: "", - searchResultIDs: [], - preserveSelection: false - }, options); - let { citedItems, citedItemsMatchingSearch, searchString, - searchResultIDs, preserveSelection } = options - - var selectedIndex = 1, previousItemID; - if (Zotero_QuickFormat.citingNotes) citedItems = []; + + function _getMatchingCitedItems(options) { + let { citedItems, citedItemsMatchingSearch, nCitedItemsFromLibrary } = options; + if (Zotero_QuickFormat.citingNotes) return; - // Do this so we can preserve the selected item after cited items have been loaded - if(preserveSelection && referenceBox.selectedIndex !== -1 && referenceBox.selectedIndex !== 2) { - previousItemID = parseInt(referenceBox.selectedItem.getAttribute("zotero-item"), 10); + if (!citedItems) { + return null; } - - while(referenceBox.hasChildNodes()) referenceBox.removeChild(referenceBox.firstChild); - - var nCitedItemsFromLibrary = {}; - if(!citedItems) { - // We don't know whether or not we have cited items, because we are waiting for document - // data - referenceBox.appendChild(_buildListSeparator(Zotero.getString("integration.cited.loading"))); - selectedIndex = 2; - } else if(citedItems.length) { + else if (citedItems.length) { // We have cited items - for(var i=0, n=citedItems.length; i !options.citationItemIDs.has(i.cslItemID ? i.cslItemID : i.id)); + } + } + + async function _getMatchingReaderOpenItems(options) { + if (Zotero_QuickFormat.citingNotes) return []; + let win = Zotero.getMainWindow(); + let tabs = win.Zotero_Tabs.getState(); + let itemIDs = tabs.filter(t => t.type === 'reader').sort((a, b) => { + // Sort selected tab first + if (a.selected) return -1; + else if (b.selected) return 1; + // Then in reverse chronological select order + else if (a.timeUnselected && b.timeUnselected) return b.timeUnselected - a.timeUnselected; + // Then in reverse order for tabs that never got loaded in this session + else if (a.timeUnselected) return -1; + return 1; + }).map(t => t.data.itemID); + if (!itemIDs.length) return []; + + let items = itemIDs.map((itemID) => { + let item = Zotero.Items.get(itemID); + if (item && item.parentItemID) { + itemID = item.parentItemID; + } + return Zotero.Cite.getItem(item.parentItemID); + }); + let matchedItems = items; + if (options.searchString) { + Zotero.debug("QuickFormat: Searching open tabs"); + matchedItems = []; + let splits = Zotero.Fulltext.semanticSplitter(options.searchString); + for (let item of items) { + // Generate a string to search for each item + let itemStr = item.getCreators() + .map(creator => creator.firstName + " " + creator.lastName) + .concat([item.getField("title"), item.getField("date", true, true).substr(0, 4)]) + .join(" "); + + // See if words match + for (let split of splits) { + if (itemStr.toLowerCase().includes(split)) matchedItems.push(item); } } + Zotero.debug("QuickFormat: Found matching open tabs"); + } + return matchedItems.filter(i => !options.citationItemIDs.has(i.cslItemID ? i.cslItemID : i.id)); + } + + async function _getMatchingLibraryItems(options) { + let { searchString, + searchResultIDs, nCitedItemsFromLibrary } = options; + + let win = Zotero.getMainWindow(); + let selectedItems = []; + if (win.Zotero_Tabs.selectedType === "library" && !Zotero_QuickFormat.citingNotes) { + selectedItems = Zotero.getActiveZoteroPane().getSelectedItems().filter(i => i.isRegularItem()); + selectedItems = selectedItems.filter(i => !options.citationItemIDs.has(i.cslItemID ? i.cslItemID : i.id)); + } + if (!searchString) { + return [selectedItems, []]; + } + else if (!searchResultIDs.length) { + return [[], []]; + } + + // Search results might be in an unloaded library, so get items asynchronously and load + // necessary data + var items = await Zotero.Items.getAsync(searchResultIDs); + await Zotero.Items.loadDataTypes(items); + + searchString = searchString.toLowerCase(); + let searchParts = Zotero.SearchConditions.parseSearchString(searchString); + var collation = Zotero.getLocaleCollation(); + + function _itemSort(a, b) { + var firstCreatorA = a.firstCreator, firstCreatorB = b.firstCreator; + + // Favor left-bound name matches (e.g., "Baum" < "Appelbaum"), + // using last name of first author + if (firstCreatorA && firstCreatorB) { + for (let part of searchParts) { + let caStartsWith = firstCreatorA.toLowerCase().startsWith(part.text); + let cbStartsWith = firstCreatorB.toLowerCase().startsWith(part.text); + if (caStartsWith && !cbStartsWith) { + return -1; + } + else if (!caStartsWith && cbStartsWith) { + return 1; + } + } + } + + var libA = a.libraryID, libB = b.libraryID; + if (libA !== libB) { + // Sort by number of cites for library + if (nCitedItemsFromLibrary[libA] && !nCitedItemsFromLibrary[libB]) { + return -1; + } + if (!nCitedItemsFromLibrary[libA] && nCitedItemsFromLibrary[libB]) { + return 1; + } + if (nCitedItemsFromLibrary[libA] !== nCitedItemsFromLibrary[libB]) { + return nCitedItemsFromLibrary[libB] - nCitedItemsFromLibrary[libA]; + } + + // Sort by ID even if number of cites is equal + return libA - libB; + } + + // Sort by last name of first author + if (firstCreatorA !== "" && firstCreatorB === "") { + return -1; + } + else if (firstCreatorA === "" && firstCreatorB !== "") { + return 1; + } + else if (firstCreatorA) { + return collation.compareString(1, firstCreatorA, firstCreatorB); + } + + // Sort by date + var yearA = a.getField("date", true, true).substr(0, 4), + yearB = b.getField("date", true, true).substr(0, 4); + return yearA - yearB; } - // Also take into account items cited in this citation. This means that the sorting isn't + function _noteSort(a, b) { + return collation.compareString( + 1, b.getField('dateModified'), a.getField('dateModified') + ); + } + + items.sort(Zotero_QuickFormat.citingNotes ? _noteSort : _itemSort); + items = items.filter(i => !options.citationItemIDs.has(i.cslItemID ? i.cslItemID : i.id)); + + // Split filtered items into selected and other bins + let matchingSelectedItems = []; + let matchingItems = []; + for (let item of items) { + if (selectedItems.findIndex(i => i.id === item.id) !== -1) { + matchingSelectedItems.push(item); + } + else { + matchingItems.push(item); + } + } + return [matchingSelectedItems, matchingItems]; + } + + /** + * Updates the item list + */ + async function _updateItemList(options = {}) { + options = Object.assign({ + citedItems: false, + citedItemsMatchingSearch: false, + searchString: "", + searchResultIDs: [], + preserveSelection: false, + nCitedItemsFromLibrary: {}, + citationItemIDs: new Set() + }, options); + + let { preserveSelection, nCitedItemsFromLibrary } = options; + let previousItemID, selectedIndex = 1; + + // Do this so we can preserve the selected item after cited items have been loaded + if (preserveSelection && referenceBox.selectedIndex !== -1 && referenceBox.selectedIndex !== 2) { + previousItemID = parseInt(referenceBox.selectedItem.getAttribute("zotero-item")); + } + + // Clear item list + _clearEntryList(); + + // Take into account items cited in this citation. This means that the sorting isn't // exactly by # of items cited from each library, but maybe it's better this way. _updateCitationObject(); - for(var citationItem of io.citation.citationItems) { + for (let citationItem of io.citation.citationItems) { var citedItem = io.customGetItem && io.customGetItem(citationItem) || Zotero.Cite.getItem(citationItem.id); - if(!citedItem.cslItemID) { - var libraryID = citedItem.libraryID; - if(libraryID in nCitedItemsFromLibrary) { + options.citationItemIDs.add(citedItem.cslItemID ? citedItem.cslItemID : citedItem.id); + if (!citedItem.cslItemID) { + let libraryID = citedItem.libraryID; + if (libraryID in nCitedItemsFromLibrary) { nCitedItemsFromLibrary[libraryID]++; - } else { + } + else { nCitedItemsFromLibrary[libraryID] = 1; } } } - - if(searchResultIDs.length && (!citedItemsMatchingSearch || citedItemsMatchingSearch.length < 50)) { - // Search results might be in an unloaded library, so get items asynchronously and load - // necessary data - var items = await Zotero.Items.getAsync(searchResultIDs); - await Zotero.Items.loadDataTypes(items); - - searchString = searchString.toLowerCase(); - let searchParts = Zotero.SearchConditions.parseSearchString(searchString); - var collation = Zotero.getLocaleCollation(); - - function _itemSort(a, b) { - var firstCreatorA = a.firstCreator, firstCreatorB = b.firstCreator; - - // Favor left-bound name matches (e.g., "Baum" < "Appelbaum"), - // using last name of first author - if (firstCreatorA && firstCreatorB) { - for (let part of searchParts) { - let caStartsWith = firstCreatorA.toLowerCase().startsWith(part.text); - let cbStartsWith = firstCreatorB.toLowerCase().startsWith(part.text); - if (caStartsWith && !cbStartsWith) { - return -1; - } - else if (!caStartsWith && cbStartsWith) { - return 1; - } - } - } - - var libA = a.libraryID, libB = b.libraryID; - if(libA !== libB) { - // Sort by number of cites for library - if(nCitedItemsFromLibrary[libA] && !nCitedItemsFromLibrary[libB]) { - return -1; - } - if(!nCitedItemsFromLibrary[libA] && nCitedItemsFromLibrary[libB]) { - return 1; - } - if(nCitedItemsFromLibrary[libA] !== nCitedItemsFromLibrary[libB]) { - return nCitedItemsFromLibrary[libB] - nCitedItemsFromLibrary[libA]; - } - - // Sort by ID even if number of cites is equal - return libA - libB; - } - - // Sort by last name of first author - if (firstCreatorA !== "" && firstCreatorB === "") { - return -1; - } else if (firstCreatorA === "" && firstCreatorB !== "") { - return 1 - } else if (firstCreatorA) { - return collation.compareString(1, firstCreatorA, firstCreatorB); - } - - // Sort by date - var yearA = a.getField("date", true, true).substr(0, 4), - yearB = b.getField("date", true, true).substr(0, 4); - return yearA - yearB; - } - - function _noteSort(a, b) { - return collation.compareString( - 1, b.getField('dateModified'), a.getField('dateModified') - ); - } - - items.sort(Zotero_QuickFormat.citingNotes ? _noteSort : _itemSort); - - var previousLibrary = -1; - for(var i=0, n=Math.min(items.length, citedItemsMatchingSearch ? 50-citedItemsMatchingSearch.length : 50); i { + if (elem.getAttribute('zotero-item') === previousItemID) { + selectedIndex = index; + return true; + } + return false; + }); + } + + referenceBox.selectedIndex = selectedIndex; + referenceBox.ensureIndexIsVisible(selectedIndex); + } /** * Builds a string describing an item. We avoid CSL here for speed. @@ -822,7 +948,8 @@ var Zotero_QuickFormat = new function () { * Converts the selected item to a bubble */ this._bubbleizeSelected = Zotero.Promise.coroutine(function* () { - if(!referenceBox.hasChildNodes() || !referenceBox.selectedItem) return false; + const panelShowing = referencePanel.state === "open" || referencePanel.state === "showing"; + if(!panelShowing || !referenceBox.hasChildNodes() || !referenceBox.selectedItem) return false; var citationItem = {"id":referenceBox.selectedItem.getAttribute("zotero-item")}; if (typeof citationItem.id === "string" && citationItem.id.indexOf("/") !== -1) { @@ -900,7 +1027,7 @@ var Zotero_QuickFormat = new function () { */ function _resize() { var childNodes = referenceBox.childNodes, numReferences = 0, numSeparators = 0, - firstReference, firstSeparator, height; + firstReference, firstSeparator, numCitationItems, editorContent; for(var i=0, n=childNodes.length; i 30) { qfe.setAttribute("multiline", true); qfs.setAttribute("multiline", true); @@ -937,7 +1067,10 @@ var Zotero_QuickFormat = new function () { } var panelShowing = referencePanel.state === "open" || referencePanel.state === "showing"; - if(numReferences || numSeparators) { + // Open the reference panel if there are references to show, except unless the user types something, and then + // backspaces to remove that text (otherwise if we don't close the reference panel in that instance it is impossible + // to accept the dialog without adding the selected reference by backspacing the search query). + if((numReferences || numSeparators) && (!numCitationItems || editorContent.length || inputIsPristine)) { if(((!referenceHeight && firstReference) || (!separatorHeight && firstSeparator) || !panelFrameHeight) && !panelShowing) { _openReferencePanel(); @@ -1229,6 +1362,7 @@ var Zotero_QuickFormat = new function () { _searchPromise = Zotero.Promise.delay(SEARCH_TIMEOUT) .then(() => _quickFormat()) .then(() => { + inputIsPristine = false; _searchPromise = null; spinner.style.visibility = 'hidden'; }); diff --git a/chrome/content/zotero/tabs.js b/chrome/content/zotero/tabs.js index cbf37d7e3b..7600c60d2b 100644 --- a/chrome/content/zotero/tabs.js +++ b/chrome/content/zotero/tabs.js @@ -132,6 +132,7 @@ var Zotero_Tabs = new function () { var o = { type, title: tab.title, + timeUnselected: tab.timeUnselected }; if (tab.data) { o.data = tab.data; diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index ddb57fcc94..a77e01abe5 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -935,6 +935,8 @@ integration.removeBibEntry.body = Are you sure you want to omit it from your bi integration.cited = Cited integration.cited.loading = Loading Cited Items… +integration.openTabs = Open Documents +integration.selectedItems = Selected Items integration.ibid = ibid integration.emptyCitationWarning.title = Blank Citation integration.emptyCitationWarning.body = The citation you have specified would be empty in the currently selected style. Are you sure you want to add it?