Match duplicate books by ISBN-10/13 equivalence, veto book sections by container ISBN

ISBNs were compared as raw cleaned strings, so a book catalogued with
ISBN-10 and a duplicate catalogued with the equivalent ISBN-13 never
matched. Canonicalize to ISBN-13 (Zotero.Utilities.toISBN13) before
comparing.

Also cache ISBNs for book sections. A section's ISBN identifies its
containing book, not the section, so sections aren't unioned directly
by ISBN, but the cached value now lets the title/creator match veto
false positives between same-titled sections from different books.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Nylin Nilsson 2026-08-24 18:24:03 +02:00
parent 786cdea884
commit ae36d93653
2 changed files with 80 additions and 0 deletions

View file

@ -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) "

View file

@ -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);
});
});
});