diff --git a/chrome/content/zotero/xpcom/server/server_connector.js b/chrome/content/zotero/xpcom/server/server_connector.js index 301be412a3..9ed47f477f 100644 --- a/chrome/content/zotero/xpcom/server/server_connector.js +++ b/chrome/content/zotero/xpcom/server/server_connector.js @@ -289,6 +289,276 @@ Zotero.Server.Connector.Detect.prototype = { }, } +Zotero.Server.Connector.FindExistingItems = function () {}; +Zotero.Server.Endpoints["/connector/findExistingItems"] = Zotero.Server.Connector.FindExistingItems; +Zotero.Server.Connector.FindExistingItems.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: ["application/json"], + permitBookmarklet: true, + + init: async function (requestData) { + let data = requestData.data || {}; + let identifiers = Zotero.Server.Connector._getItemIdentifiers(data); + if (!identifiers.doi.length && !identifiers.url.length) { + return [400, "application/json", JSON.stringify({ error: "IDENTIFIERS_NOT_PROVIDED" })]; + } + + let target; + if (data.target) { + try { + target = Zotero.Server.Connector.resolveTarget(data.target); + } + catch (e) { + Zotero.debug(`Could not resolve duplicate lookup target '${data.target}': ${e.message}`); + } + } + let { library } = target && target.library + ? target + : Zotero.Server.Connector.getSaveTarget(); + let matches = await Zotero.Server.Connector.findExistingItemsByIdentifiers( + identifiers, + library.libraryID + ); + return [200, "application/json", JSON.stringify({ matches })]; + } +}; + +Zotero.Server.Connector._getItemIdentifiers = function (data) { + let identifiers = { + doi: new Set(), + url: new Set() + }; + let itemIdentifiers = []; + let proxy = data.proxy && new Zotero.Proxy(data.proxy); + + let addDOI = (doi) => { + doi = doi && Zotero.Utilities.cleanDOI(doi); + if (doi) { + doi = doi.toLowerCase(); + identifiers.doi.add(doi); + return doi; + } + }; + let addURL = (url) => { + if (proxy && url) { + try { + // Only deproxify when the proxy regexp actually matches. + // toProper() normalizes non-matching URLs through new URL().href, + // which can break exact-match lookups against values stored from + // the translator (e.g. https://example.com vs https://example.com/). + let normalized = new URL(url).href; + if (proxy.regexp.test(normalized)) { + url = proxy.toProper(normalized); + } + } + catch (e) { + Zotero.debug(`Could not deproxify item URL for duplicate lookup: ${e.message}`); + return; + } + } + if (url && typeof url == 'string') { + identifiers.url.add(url); + return url; + } + }; + + if (data.identifiers) { + for (let doi of data.identifiers.doi || []) { + addDOI(doi); + } + for (let url of data.identifiers.url || []) { + addURL(url); + } + } + + for (let item of data.items || []) { + let itemIdentifier = { + doi: new Set(), + url: new Set() + }; + let addItemDOI = (doi) => { + doi = addDOI(doi); + if (doi) { + itemIdentifier.doi.add(doi); + } + }; + let addItemURL = (url) => { + url = addURL(url); + if (url) { + itemIdentifier.url.add(url); + } + }; + addItemDOI(item.DOI); + if (item.extra) { + let { fields } = Zotero.Utilities.Internal.extractExtraFields(item.extra); + addItemDOI(fields.get('DOI')); + } + addItemURL(item.url); + itemIdentifiers.push({ + doi: Array.from(itemIdentifier.doi), + url: Array.from(itemIdentifier.url) + }); + } + + return { + doi: Array.from(identifiers.doi), + url: Array.from(identifiers.url), + itemIdentifiers + }; +}; + +Zotero.Server.Connector.findExistingItemsByIdentifiers = async function (identifiers, libraryID) { + let itemIDs = new Set(); + let doiSet = new Set(identifiers.doi); + let urlSet = new Set(identifiers.url); + let requestItemIdentifiers = identifiers.itemIdentifiers || []; + let matches = []; + let getMatchedItemIndexes = function (matchedDOIs, matchedURL) { + if (!requestItemIdentifiers.length) { + return []; + } + let indexes = []; + for (let i = 0; i < requestItemIdentifiers.length; i++) { + let itemIdentifiers = requestItemIdentifiers[i]; + if (matchedDOIs.some(doi => itemIdentifiers.doi.includes(doi))) { + indexes.push(i); + continue; + } + if (matchedURL && itemIdentifiers.url.includes(matchedURL)) { + indexes.push(i); + } + } + return indexes; + }; + + if (doiSet.size) { + let doiCandidateChunkSize = Math.min(100, Zotero.DB.MAX_BOUND_PARAMETERS - 2); + let getDOICandidateRows = async function (fieldID) { + let allRows = []; + await Zotero.Utilities.Internal.forEachChunkAsync( + identifiers.doi, + doiCandidateChunkSize, + async function (chunk) { + let rows = await Zotero.DB.queryAsync( + "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + + "JOIN itemDataValues USING (valueID) " + + "WHERE libraryID=? AND fieldID=? " + + "AND (" + chunk.map(() => "LOWER(value) LIKE ?").join(" OR ") + ") " + + "AND itemID NOT IN (SELECT itemID FROM deletedItems)", + [ + libraryID, + fieldID, + ...chunk.map(doi => `%${doi}%`) + ] + ); + allRows.push(...rows); + } + ); + return allRows; + }; + + let doiRows = await getDOICandidateRows(Zotero.ItemFields.getID('DOI')); + for (let row of doiRows) { + let doi = Zotero.Utilities.cleanDOI(row.value); + if (doi && doiSet.has(doi.toLowerCase())) { + itemIDs.add(row.itemID); + } + } + let extraRows = await getDOICandidateRows(Zotero.ItemFields.getID('extra')); + for (let row of extraRows) { + let { fields } = Zotero.Utilities.Internal.extractExtraFields(row.value); + let extraDOI = fields.get('DOI'); + let doi = extraDOI && Zotero.Utilities.cleanDOI(extraDOI); + if (doi && doiSet.has(doi.toLowerCase())) { + itemIDs.add(row.itemID); + } + } + } + if (urlSet.size) { + await Zotero.Utilities.Internal.forEachChunkAsync( + identifiers.url, + Zotero.DB.MAX_BOUND_PARAMETERS - 2, + async function (chunk) { + let rows = await Zotero.DB.queryAsync( + "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + + "JOIN itemDataValues USING (valueID) " + + "WHERE libraryID=? AND fieldID=? " + + "AND value IN (" + chunk.map(() => '?').join(',') + ") " + + "AND itemID NOT IN (SELECT itemID FROM deletedItems)", + [ + libraryID, + Zotero.ItemFields.getID('url'), + ...chunk + ] + ); + for (let row of rows) { + if (urlSet.has(row.value)) { + itemIDs.add(row.itemID); + } + } + } + ); + } + + for (let itemID of itemIDs) { + let item = await Zotero.Items.getAsync(itemID); + if (!item || !item.isTopLevelItem()) { + continue; + } + await item.loadDataType('itemData'); + + let matchedFields = []; + let matchedIdentifiers = {}; + let itemDOIs = [ + item.getField('DOI'), + item.getExtraField('DOI') + ] + .map(doi => Zotero.Utilities.cleanDOI(doi)) + .filter(doi => doi && doiSet.has(doi.toLowerCase())); + let matchedDOIs = Array.from(new Set(itemDOIs.map(doi => doi.toLowerCase()))); + if (itemDOIs.length) { + matchedFields.push('DOI'); + matchedIdentifiers.doi = itemDOIs[0]; + } + let itemURL = item.getField('url'); + if (itemURL && urlSet.has(itemURL)) { + matchedFields.push('url'); + matchedIdentifiers.url = itemURL; + } + if (!matchedFields.length) { + continue; + } + + let match = { + id: item.id, + key: item.key, + libraryID: item.libraryID, + title: item.getDisplayTitle(), + matchedFields, + matchedIdentifiers + }; + let matchedItemIndexes = getMatchedItemIndexes(matchedDOIs, matchedIdentifiers.url); + if (matchedItemIndexes.length) { + // Keep the singular field aligned with the identifiers older + // Connector versions receive. + let compatibilityDOIs = matchedIdentifiers.doi + ? [matchedIdentifiers.doi.toLowerCase()] + : []; + let matchedItemIndex = getMatchedItemIndexes( + compatibilityDOIs, + matchedIdentifiers.url + )[0]; + if (matchedItemIndex !== undefined) { + match.matchedItemIndex = matchedItemIndex; + } + match.matchedItemIndexes = matchedItemIndexes; + } + matches.push(match); + } + + return matches; +}; + /** * Saves items to DB * diff --git a/test/tests/server_connectorTest.js b/test/tests/server_connectorTest.js index ca19f95b97..a79efb271b 100644 --- a/test/tests/server_connectorTest.js +++ b/test/tests/server_connectorTest.js @@ -320,6 +320,588 @@ describe("Connector Server", function () { }); }); + describe("/connector/findExistingItems", function () { + it("should return matching top-level items for DOI and URL in the selected target library", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing Article"); + item.setField("DOI", "10.1234/Example.1"); + item.setField("url", "https://example.com/article"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { + doi: ["10.1234/example.1"], + url: ["https://example.com/article"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].libraryID, Zotero.Libraries.userLibraryID); + assert.sameMembers(data.matches[0].matchedFields, ["DOI", "url"]); + }); + + it("should use the requested target library for duplicate lookup", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let userItem = new Zotero.Item("journalArticle"); + userItem.setField("title", "Existing User Article"); + userItem.setField("DOI", "10.1234/targeted"); + await userItem.saveTx(); + + let group = await createGroup({ editable: true }); + let groupItem = new Zotero.Item("journalArticle"); + groupItem.libraryID = group.libraryID; + groupItem.setField("title", "Existing Group Article"); + groupItem.setField("DOI", "10.1234/targeted"); + await groupItem.saveTx(); + Zotero.Items.unload([userItem.id, groupItem.id]); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + target: group.treeViewID, + identifiers: { + doi: ["10.1234/targeted"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, groupItem.id); + assert.equal(data.matches[0].libraryID, group.libraryID); + }); + + it("should deproxify item URLs before duplicate lookup", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("webpage"); + item.setField("title", "Existing Proxied Page"); + item.setField("url", "https://duplicate.example.com/path"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + proxy: { + scheme: "https://%h.proxy.example.com/%p" + }, + items: [{ + itemType: "webpage", + title: "New Proxied Page", + url: "https://duplicate-example-com.proxy.example.com/path" + }] + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].matchedIdentifiers.url, "https://duplicate.example.com/path"); + }); + + it("should chunk large URL duplicate lookups", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("webpage"); + item.setField("title", "Existing Large Lookup Page"); + item.setField("url", "https://example.com/target-large-lookup"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let urls = []; + for (let i = 0; i < Zotero.DB.MAX_BOUND_PARAMETERS + 5; i++) { + urls.push(`https://example.com/not-${i}`); + } + urls.push("https://example.com/target-large-lookup"); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { url: urls } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + }); + + it("should chunk large DOI duplicate lookups", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing Large DOI Lookup Article"); + item.setField("DOI", "10.1234/target-large-doi-lookup"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let dois = []; + for (let i = 0; i < Zotero.DB.MAX_BOUND_PARAMETERS + 5; i++) { + dois.push(`10.1234/not-large-doi-${i}`); + } + dois.push("10.1234/target-large-doi-lookup"); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { doi: dois } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + }); + + it("should extract DOI from translated item Extra before duplicate lookup", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing Extra DOI Article"); + item.setField("DOI", "10.1234/extra-doi"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + items: [{ + itemType: "journalArticle", + title: "New Extra DOI Article", + extra: "DOI: 10.1234/extra-doi" + }] + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].matchedItemIndex, 0); + assert.equal(data.matches[0].matchedIdentifiers.doi, "10.1234/extra-doi"); + }); + + it("should return every translated item index sharing an Extra DOI match", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing Shared Extra DOI Article"); + item.setField("DOI", "10.1234/shared-extra-doi"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + items: [ + { + itemType: "webpage", + title: "First Shared Extra DOI Page", + extra: "DOI: 10.1234/shared-extra-doi" + }, + { + itemType: "webpage", + title: "Second Shared Extra DOI Page", + extra: "DOI: 10.1234/shared-extra-doi" + } + ] + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].matchedItemIndex, 0); + assert.deepEqual(data.matches[0].matchedItemIndexes, [0, 1]); + }); + + it("should return item indexes for every matching DOI stored on one item", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing Article with Two DOI Values"); + item.setField("DOI", "10.1234/primary-doi"); + item.setField("extra", "DOI: 10.1234/extra-doi"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + items: [ + { + itemType: "webpage", + title: "Translated Extra DOI Page", + extra: "DOI: 10.1234/extra-doi" + }, + { + itemType: "journalArticle", + title: "Translated Primary DOI Article", + DOI: "10.1234/primary-doi" + } + ] + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].matchedIdentifiers.doi, "10.1234/primary-doi"); + assert.equal(data.matches[0].matchedItemIndex, 1); + assert.deepEqual(data.matches[0].matchedItemIndexes, [0, 1]); + }); + + it("should match saved DOI values with resolver prefixes", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing Prefixed DOI Article"); + item.setField("DOI", "https://doi.org/10.1234/prefixed-doi"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { + doi: ["10.1234/prefixed-doi"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].matchedIdentifiers.doi, "10.1234/prefixed-doi"); + }); + + it("should match existing item DOI values stored in Extra", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("webpage"); + item.setField("title", "Existing Extra DOI Page"); + item.setField("extra", "DOI: 10.1234/existing-extra-doi"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { + doi: ["10.1234/existing-extra-doi"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].matchedIdentifiers.doi, "10.1234/existing-extra-doi"); + }); + + it("should filter DOI lookup candidates before parsing values", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let unrelatedDOIItem = new Zotero.Item("journalArticle"); + unrelatedDOIItem.setField("title", "Unrelated DOI Article"); + unrelatedDOIItem.setField("DOI", "10.9999/unrelated-doi-candidate"); + await unrelatedDOIItem.saveTx(); + + let unrelatedExtraItem = new Zotero.Item("webpage"); + unrelatedExtraItem.setField("title", "Unrelated Extra Page"); + unrelatedExtraItem.setField("extra", "UNRELATED_EXTRA_SHOULD_NOT_PARSE"); + await unrelatedExtraItem.saveTx(); + + let doiItem = new Zotero.Item("journalArticle"); + doiItem.setField("title", "Existing Filtered DOI Article"); + doiItem.setField("DOI", "https://doi.org/10.1234/filtered-doi-candidate"); + await doiItem.saveTx(); + + let extraItem = new Zotero.Item("webpage"); + extraItem.setField("title", "Existing Filtered Extra Page"); + extraItem.setField("extra", "DOI: 10.1234/filtered-extra-candidate"); + await extraItem.saveTx(); + Zotero.Items.unload([unrelatedDOIItem.id, unrelatedExtraItem.id, doiItem.id, extraItem.id]); + + let originalCleanDOI = Zotero.Utilities.cleanDOI; + let originalExtractExtraFields = Zotero.Utilities.Internal.extractExtraFields; + let cleanedUnrelatedDOI = false; + let parsedUnrelatedExtra = false; + sinon.stub(Zotero.Utilities, "cleanDOI").callsFake(function (doi) { + if (doi && doi.includes("unrelated-doi-candidate")) { + cleanedUnrelatedDOI = true; + } + return originalCleanDOI.apply(this, arguments); + }); + sinon.stub(Zotero.Utilities.Internal, "extractExtraFields").callsFake(function (extra) { + if (extra && extra.includes("UNRELATED_EXTRA_SHOULD_NOT_PARSE")) { + parsedUnrelatedExtra = true; + } + return originalExtractExtraFields.apply(this, arguments); + }); + + try { + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { + doi: [ + "10.1234/filtered-doi-candidate", + "10.1234/filtered-extra-candidate" + ] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.sameMembers(data.matches.map(match => match.id), [doiItem.id, extraItem.id]); + assert.isFalse(cleanedUnrelatedDOI); + assert.isFalse(parsedUnrelatedExtra); + } + finally { + Zotero.Utilities.cleanDOI.restore(); + Zotero.Utilities.Internal.extractExtraFields.restore(); + } + }); + + it("should ignore free-text Extra DOI candidates without failing the lookup", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let freeTextExtraItem = new Zotero.Item("webpage"); + freeTextExtraItem.setField("title", "Free Text Extra Page"); + freeTextExtraItem.setField("extra", "See https://doi.org/10.1234/free-text-extra for related work"); + await freeTextExtraItem.saveTx(); + + let doiItem = new Zotero.Item("journalArticle"); + doiItem.setField("title", "Existing DOI Article"); + doiItem.setField("DOI", "10.1234/free-text-extra"); + await doiItem.saveTx(); + Zotero.Items.unload([freeTextExtraItem.id, doiItem.id]); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { + doi: ["10.1234/free-text-extra"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.sameMembers(data.matches.map(match => match.id), [doiItem.id]); + }); + + it("should keep DOI lookup working when proxied item URL normalization fails", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing DOI With Bad URL Payload"); + item.setField("DOI", "10.1234/bad-proxy-url"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + proxy: { + scheme: "https://%h.proxy.example.com/%p" + }, + items: [{ + itemType: "journalArticle", + title: "New DOI With Bad URL Payload", + DOI: "10.1234/bad-proxy-url", + url: "/relative/path" + }] + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + }); + + it("should fall back to the current save target when requested collection no longer exists", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let collection = await createDataObject('collection'); + let targetID = collection.treeViewID; + await collection.eraseTx(); + + let item = new Zotero.Item("journalArticle"); + item.setField("title", "Existing User Library Article"); + item.setField("DOI", "10.1234/deleted-target"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + target: targetID, + identifiers: { + doi: ["10.1234/deleted-target"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].libraryID, Zotero.Libraries.userLibraryID); + }); + + it("should return display titles for base-mapped title fields", async function () { + await selectLibrary(win, Zotero.Libraries.userLibraryID); + await waitForItemsLoad(win); + + let item = new Zotero.Item("case"); + item.setField("caseName", "Existing Case Name"); + item.setField("url", "https://example.com/case"); + await item.saveTx(); + Zotero.Items.unload(item.id); + + let response = await httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + identifiers: { + url: ["https://example.com/case"] + } + }) + } + ); + + let data = JSON.parse(response.response); + assert.lengthOf(data.matches, 1); + assert.equal(data.matches[0].id, item.id); + assert.equal(data.matches[0].title, "Existing Case Name"); + }); + + it("should reject requests without identifiers", async function () { + let error = await getPromiseError(httpRequest( + "POST", + connectorServerPath + "/connector/findExistingItems", + { + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + } + )); + assert.instanceOf(error, Zotero.HTTP.UnexpectedStatusException); + assert.include(error.message, "400"); + }); + }); + describe("/connector/saveSingleFile", function () { it("should save a webpage item with /saveSnapshot", async function () { var collection = await createDataObject('collection');