diff --git a/chrome/content/zotero/xpcom/duplicates.js b/chrome/content/zotero/xpcom/duplicates.js index 56f4812a8e..dafa57f760 100644 --- a/chrome/content/zotero/xpcom/duplicates.js +++ b/chrome/content/zotero/xpcom/duplicates.js @@ -211,6 +211,8 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { let row = rows[i]; let newVal = Zotero.Utilities.cleanISBN('' + row.value); if (!newVal) continue; + // Canonicalize to ISBN-13 so an ISBN-10 and its ISBN-13 equivalent match + newVal = Zotero.Utilities.toISBN13(newVal); isbnCache[row.itemID] = newVal; newRows.push({ itemID: row.itemID, @@ -221,6 +223,28 @@ Zotero.Duplicates.prototype._findDuplicates = async function () { processRows(newRows); } + // Also cache ISBNs of book sections. Their ISBN identifies the containing book rather + // than the section itself, so book sections aren't unioned directly by ISBN, but the + // cached value is still used below to veto false title/creator matches. + var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + + "JOIN itemDataValues USING (valueID) " + + "WHERE libraryID=? AND itemTypeID=? AND fieldID=? " + + "AND itemID NOT IN (SELECT itemID FROM deletedItems)"; + var rows = await Zotero.DB.queryAsync( + sql, + [ + this._libraryID, + Zotero.ItemTypes.getID('bookSection'), + Zotero.ItemFields.getID('ISBN') + ] + ); + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + let newVal = Zotero.Utilities.cleanISBN('' + row.value); + if (!newVal) continue; + isbnCache[row.itemID] = Zotero.Utilities.toISBN13(newVal); + } + // DOI var sql = "SELECT itemID, value FROM items JOIN itemData USING (itemID) " + "JOIN itemDataValues USING (valueID) " diff --git a/test/tests/duplicatesTest.js b/test/tests/duplicatesTest.js index d86d382de5..e10aeee483 100644 --- a/test/tests/duplicatesTest.js +++ b/test/tests/duplicatesTest.js @@ -110,4 +110,60 @@ describe("Duplicate Items", function () { assert.sameMembers(item3.relatedItems, [item1.key]); }); }); + + describe("ISBN matching", function () { + async function getDuplicateSets() { + var duplicates = new Zotero.Duplicates(Zotero.Libraries.userLibraryID); + var search = await duplicates.getSearchObject(); + return search.search(); + } + + it("should match books with equivalent ISBN-10 and ISBN-13", async function () { + var item1 = await createDataObject('item', { itemType: 'book', title: 'Effective Java' }); + item1.setField('ISBN', '0134685997'); + await item1.saveTx(); + + var item2 = await createDataObject('item', { itemType: 'book', title: 'Effective Java, 3rd Edition' }); + item2.setField('ISBN', '9780134685991'); + await item2.saveTx(); + + var ids = await getDuplicateSets(); + assert.include(ids, item1.id); + assert.include(ids, item2.id); + }); + + it("should not match books with different ISBNs and titles", async function () { + var item1 = await createDataObject('item', { itemType: 'book', title: 'Clean Code' }); + item1.setField('ISBN', '0062316095'); + await item1.saveTx(); + + var item2 = await createDataObject('item', { itemType: 'book', title: 'Learning JavaScript' }); + item2.setField('ISBN', '0596009089'); + await item2.saveTx(); + + var ids = await getDuplicateSets(); + assert.notInclude(ids, item1.id); + assert.notInclude(ids, item2.id); + }); + + it("should not match book sections with the same title/creator but different container ISBNs", async function () { + var item1 = await createDataObject( + 'item', + { itemType: 'bookSection', title: 'Introduction', creators: [{ lastName: 'Smith', firstName: 'John', creatorType: 'author' }] } + ); + item1.setField('ISBN', '0123456789'); + await item1.saveTx(); + + var item2 = await createDataObject( + 'item', + { itemType: 'bookSection', title: 'Introduction', creators: [{ lastName: 'Smith', firstName: 'John', creatorType: 'author' }] } + ); + item2.setField('ISBN', '0987654322'); + await item2.saveTx(); + + var ids = await getDuplicateSets(); + assert.notInclude(ids, item1.id); + assert.notInclude(ids, item2.id); + }); + }); });