diff --git a/chrome/content/zotero/xpcom/bibliographyMatcher.mjs b/chrome/content/zotero/xpcom/bibliographyMatcher.mjs new file mode 100644 index 0000000000..ccc52e5f33 --- /dev/null +++ b/chrome/content/zotero/xpcom/bibliographyMatcher.mjs @@ -0,0 +1,1084 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://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 ***** +*/ + + +/** + * Match one visible bibliography record against library or transient items. + * + * The matcher deliberately has no citation-style grammar. It retrieves a bounded + * candidate union, aligns each candidate's own metadata to the visible record, + * rejects explicit contradictions, and returns only a confident, unique match. + * Shared retrieval and metadata helpers also support stricter identity checks + * before saving provider candidates. + */ +ChromeUtils.defineESModuleGetters(globalThis, { + Zotero: 'chrome://zotero/content/zotero.mjs', +}); + +const ISBN_PATTERN = /(?} + */ + async matchLibrary(reference, libraryID, { signal } = {}) { + this._throwIfAborted(signal); + let parsed = this.prepareReference(reference); + if (!parsed || !libraryID) return { status: 'unmatched', reason: 'invalid-input' }; + if (parsed.tokens.length > this.MAX_REFERENCE_TOKENS) { + return { status: 'incomplete', reason: 'reference-token-limit' }; + } + let identifiers = await this._searchByIdentifiers(parsed.identifiers, libraryID, signal); + this._throwIfAborted(signal); + if (identifiers.incomplete) return { status: 'incomplete', reason: 'retrieval-limit' }; + let evaluations = new Map(); + let identifierItems = identifiers.ids.length ? await this._loadLibraryCandidates(identifiers.ids, libraryID, signal) : []; + for (let item of identifierItems) { + evaluations.set(item.id, this._evaluate(item, parsed, false)); + } + let exact = this._exactIdentifierResult([...evaluations.values()]); + if (exact) return exact; + + let titles = await this._searchTitleCandidates(parsed, libraryID, { signal }); + if (titles.incomplete) return { status: 'incomplete', reason: 'retrieval-limit' }; + let ids = titles.ids.filter(id => !evaluations.has(id)); + let started = Date.now(), lastYield = started; + for (let offset = 0; offset < ids.length; offset += this.CANDIDATE_BATCH_SIZE) { + let items = await this._loadLibraryCandidates(ids.slice(offset, offset + this.CANDIDATE_BATCH_SIZE), libraryID, signal); + for (let item of items) { + this._throwIfAborted(signal); + if (Date.now() - started >= this.MAX_EVALUATION_DURATION_MS) { + return { status: 'incomplete', reason: 'evaluation-time-limit' }; + } + evaluations.set(item.id, this._evaluate(item, parsed, false)); + if (Date.now() - lastYield >= 16) { + await Zotero.Promise.delay(0); + lastYield = Date.now(); + } + } + } + this._throwIfAborted(signal); + return this._rankEvaluations([...evaluations.values()], parsed); + } + + /** + * Evaluate provider candidates with the same confidence requirements, retaining + * title-space repair and input order for equivalent publication records. + * + * @param {String|Array|Object} reference + * @param {Array} candidates + * @return {Object} + */ + matchCandidates(reference, candidates) { + let parsed = this.prepareReference(reference); + if (!parsed || !Array.isArray(candidates)) { + return { status: 'unmatched', reason: 'invalid-input' }; + } + if (parsed.tokens.length > this.MAX_REFERENCE_TOKENS) { + return { status: 'incomplete', reason: 'reference-token-limit' }; + } + if (candidates.length > this.MAX_CANDIDATES) { + return { status: 'incomplete', reason: 'candidate-overflow' }; + } + + let items; + try { + items = candidates.map((candidate) => { + if (candidate instanceof Zotero.Item) return candidate; + let item = new Zotero.Item(candidate.itemType); + item.fromJSON(candidate); + return item; + }); + } + catch { + // An unreadable competitor must not turn a partial pool into a winner. + return { status: 'incomplete', reason: 'invalid-candidate' }; + } + return this._rankEvaluations(items.map(item => this._evaluate(item, parsed)), parsed, { preferFirst: true }); + } + + /** Validate and join visible text without extracting matching evidence. */ + getReferenceText(reference) { + let parts = typeof reference === 'string' + ? [{ text: reference }] + : Array.isArray(reference) ? reference : reference?.referenceParts || reference?.textParts; + if (!Array.isArray(parts) || !parts.length || parts.length > this.MAX_REFERENCE_PARTS + || parts.some(part => typeof part?.text !== 'string')) return null; + let text = parts.map(part => part.text).join(''); + return text.trim() && text.length <= this.MAX_MATCH_TEXT_LENGTH ? text : null; + } + + /** Parse only visible text, never hyperlink attributes, as matching evidence. */ + prepareReference(reference) { + let text = this.getReferenceText(reference); + if (text === null) return null; + let normalizedText = Zotero.Utilities.Internal.normalizeForSearch(text); + let identifiers = this.extractIdentifiers(text); + let spans = [...normalizedText.matchAll(/[\p{L}\p{M}\p{N}]+/gu)]; + let tokens = spans.map(match => match[0]); + let alignmentTerms = new Set(tokens); + for (let i = 1; i < tokens.length; i++) alignmentTerms.add(tokens[i - 1] + tokens[i]); + return { + text, normalizedText, identifiers, tokens, alignmentTerms, + titleProbes: [...new Set(tokens)].filter(token => [...token].length >= 4 && /\p{L}/u.test(token)), + tokenSpans: spans.map(match => ({ start: match.index, end: match.index + match[0].length })), + numericEvidenceText: this._maskIdentifierAndURLSpans(normalizedText, identifiers), + etAlPositions: this._sequencePositions(tokens, ['et', 'al']), + }; + } + + extractIdentifiers(text) { + text = String(text || ''); + let identifiers = this._emptyIdentifiers(); + for (let value of this._extractISBNs(text)) identifiers.ISBN.add(value); + for (let identifier of Zotero.Utilities.extractIdentifiers(text)) { + if (identifier.DOI) { + let value = Zotero.Utilities.cleanDOI(identifier.DOI); + if (value) identifiers.DOI.add(value.toLowerCase()); + } + if (identifier.ISBN) { + let value = Zotero.Utilities.cleanISBN(identifier.ISBN); + if (value) identifiers.ISBN.add(Zotero.Utilities.toISBN13(value)); + } + if (identifier.arXiv) identifiers.arXiv.add(String(identifier.arXiv).toLowerCase()); + if (identifier.adsBibcode) identifiers.adsBibcode.add(String(identifier.adsBibcode)); + } + // Keep explicitly named identifiers even when Utilities finds a DOI first. + for (let [type, pattern] of [ + ['arXiv', /\b(?:arxiv(?:\s*:\s*|\s+)|(?:https?:\/\/)?(?:www\.)?arxiv\.org\/(?:abs|pdf)\/)([^\s<>]+)/giu], + ['adsBibcode', /\b(?:ads\s*bibcode\s*:\s*|(?:https?:\/\/)?(?:ui\.)?adsabs\.harvard\.edu\/abs\/)([^\s<>]+)/giu], + ]) { + for (let match of text.matchAll(pattern)) { + for (let identifier of Zotero.Utilities.extractIdentifiers(match[1])) { + let value = identifier[type]; + if (value) identifiers[type].add(type === 'arXiv' ? String(value).toLowerCase() : String(value)); + } + } + } + // Utilities intentionally accepts bare PMID-like numbers and returns only the + // first identifier type it finds. Extract labelled PMIDs independently so a + // DOI cannot mask them and unrelated citation numbers can never become PMIDs. + for (let match of text.matchAll(/\bPMID\s*:?\s*(\d{1,9})(?!\d)/giu)) { + identifiers.PMID.add(match[1]); + } + identifiers.adsBibcode = new Set(new Map([...identifiers.adsBibcode] + .map(value => [value.toLowerCase(), value])).values()); + return identifiers; + } + + /** + * Extract supported identifiers from a Zotero item, transient item, plain + * translator object, or getField-compatible function. Parse first-class + * identifier fields separately so one identifier type cannot mask another. + */ + extractItemIdentifiers(source) { + let getField = field => (typeof source === 'function' + ? source(field) + : typeof source?.getField === 'function' ? source.getField(field, false, true) : source?.[field]); + let texts = ['DOI', 'ISBN', 'PMID', 'extra', 'url'].flatMap((field) => { + let value = getField(field); + if (!value) return []; + return ['extra', 'url'].includes(field) ? String(value).split(/\r?\n/u).filter(Boolean) : `${field}: ${value}`; + }); + let identifiers = this._emptyIdentifiers(); + for (let text of texts) { + for (let [type, values] of Object.entries(this.extractIdentifiers(text))) { + for (let value of values) identifiers[type].add(value); + } + } + return identifiers; + } + + // Structured item identity is deliberately stricter than citation matching. + // The caller supplies a canonical snapshot from createCandidateSnapshot(). + async checkForDuplicate(candidate, libraryID) { + if (['DOI', 'ISBN', 'PMID', 'extra', 'url'].some(field => ( + (candidate[field]?.length || 0) > this.MAX_MATCH_TEXT_LENGTH + ))) return { status: 'unresolved', reason: 'incomplete' }; + let identifiers = this.extractItemIdentifiers(candidate); + let route = await this._searchByIdentifiers(identifiers, libraryID); + if (route.incomplete) return { status: 'unresolved', reason: 'incomplete' }; + let identifierMatches = (await this._loadLibraryCandidates(route.ids, libraryID)).map(item => ({ + item, evidence: this._identifierEvidence(identifiers, this.extractItemIdentifiers(item)), + })).filter(result => result.evidence.match); + if (identifierMatches.length) { + return identifierMatches.length === 1 && !identifierMatches[0].evidence.conflict + ? { status: 'matched', item: identifierMatches[0].item } + : { status: 'unresolved', reason: 'ambiguous' }; + } + let rawTitle = this._candidateField(candidate, 'title'); + let titles = await this._searchByExactTitle(rawTitle, libraryID); + if (titles.incomplete) return { status: 'unresolved', reason: 'incomplete' }; + // Container identifiers still retrieve records for structured inspection, + // even when they do not establish record identity by themselves. + let ids = [...new Set([...route.ids, ...titles.ids])]; + if (ids.length > this.MAX_CANDIDATES) return { status: 'unresolved', reason: 'incomplete' }; + let items = await this._loadLibraryCandidates(ids, libraryID); + let identity = this._candidateField(candidate, 'date') && candidate.creators?.length && this._getIdentityKey(candidate); + let title = this._normalizeMatchText(rawTitle); + let matches = []; + let unresolved = false; + for (let item of items) { + let evidence = this._identifierEvidence(identifiers, this.extractItemIdentifiers(item)); + if (evidence.conflict) { + // A shared identifier plus a different identifier is unresolved identity. + if (evidence.match) unresolved = true; + continue; + } + if (evidence.match) { + matches.push(item); + continue; + } + // Local identity is not an incoming provider payload. In particular, + // descriptive fields must not impose a transport-size limit on this check. + let json = { + itemType: item.itemType, + ...Object.fromEntries(item.getUsedFields(true).map(field => [field, item.getField(field)])), + creators: item.getCreatorsJSON(), + }; + if (identity && this._getIdentityKey(json) === identity) { + matches.push(item); + } + else if (this._normalizeMatchText(this._candidateField(json, 'title')) === title + && !this._knownDifferentIdentity(json, candidate)) { + unresolved = true; + } + } + if (unresolved || matches.length > 1) return { status: 'unresolved', reason: 'ambiguous' }; + return matches.length ? { status: 'matched', item: matches[0] } : { status: 'absent' }; + } + + createCandidateSnapshot(input) { + let item = input instanceof Zotero.Item ? input : null; + if (item) { + if (!item.isRegularItem()) throw new Error('Candidate is not a bibliographic item'); + input = item.toJSON(); + } + if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('Invalid candidate'); + let size = 0; + let copyFields = (source, fields) => { + let output = {}; + size += 2; + for (let field of new Set(fields)) { + let value = source?.[field]; + if (typeof value === 'string' && value) { + size += value.length; + output[field] = value; + } + if (size > this.MAX_CANDIDATE_JSON_LENGTH) throw new Error('Candidate exceeds the candidate payload limit'); + } + return output; + }; + let output = copyFields(input, ['itemType', ...Zotero.ItemFields.getAll().map(field => field.name)]); + if (!output.itemType || !this._candidateField(output, 'title')) throw new Error('Candidate is missing required bibliographic fields'); + if (Array.isArray(input.creators)) { + output.creators = input.creators.map((creator) => { + let safe = copyFields(creator, ['creatorType', 'firstName', 'lastName', 'name']); + if (creator?.fieldMode === 1) safe.fieldMode = 1; + return safe; + }).filter(creator => creator.name || creator.lastName); + } + if (JSON.stringify(output).length > this.MAX_CANDIDATE_JSON_LENGTH) { + throw new Error('Candidate exceeds the candidate payload limit'); + } + if (item) return output; + item = new Zotero.Item(output.itemType); + item.fromJSON(output); + return this.createCandidateSnapshot(item); + } + + getCandidateKey(candidate) { + // Without record identifiers, deduplicate only complete equivalent metadata. + let fields = Object.keys(candidate).filter(key => key !== 'creators').sort() + .map(key => [key, candidate[key]]); + return JSON.stringify([fields, this._getIdentityKey({ creators: candidate.creators })]); + } + + deduplicateCandidates(candidates) { + let records = []; + for (let candidate of candidates) { + let json; + try { + json = typeof candidate?.toJSON === 'function' ? candidate.toJSON() : candidate; + } + catch { + return candidates; + } + // Leave unreadable candidates in the pool so verification can reject it. + if (!json || typeof json !== 'object') return candidates; + let identifiers = this.extractItemIdentifiers(json); + let key = this.getCandidateKey(json); + if (records.some((record) => { + let evidence = this._identifierEvidence(identifiers, record.identifiers); + return !evidence.conflict && (evidence.match || key === record.key); + })) continue; + records.push({ candidate, key, identifiers }); + } + return records.map(record => record.candidate); + } + + _throwIfAborted(signal) { + if (signal?.aborted) throw new DOMException('Cancelled', 'AbortError'); + } + + async _loadLibraryCandidates(ids, libraryID, signal) { + this._throwIfAborted(signal); + let items = (await Zotero.Items.getAsync(ids)).filter(item => ( + item && item.libraryID === libraryID && item.isRegularItem() && !item.deleted + )); + this._throwIfAborted(signal); + await Zotero.Items.loadDataTypes(items, ['itemData', 'creators']); + this._throwIfAborted(signal); + return items; + } + + _rankEvaluations(evaluations, reference, { preferFirst = false } = {}) { + let exact = this._exactIdentifierResult(evaluations); + if (exact) return exact; + let all = evaluations.filter(candidate => !candidate.conflict).sort((a, b) => b.score - a.score); + let candidates = all; + // Ignore weak records whose full title repeats an independently supported + // work's containing publication at the same citation position. + let containers = new Set(); + if (all.some(candidate => candidate.titleKey)) { + for (let leader of all) { + if (!leader.strict) continue; + let { title, container, containerKey } = leader.titleContext; + if (!(container.start >= title.end)) continue; + let key = JSON.stringify([container.start, container.end, containerKey]); + if (!containers.has(key) && this._hasIndependentTitle(leader, reference)) containers.add(key); + } + if (containers.size) { + candidates = all.filter(candidate => !containers.has(candidate.titleKey)); + } + } + let [first, second] = candidates; + let competitive = first?.strict ? first.score - this.MIN_MATCH_MARGIN : this.MIN_STRICT_SCORE; + // Excluded containers still count towards metadata completeness. + if (all.some(candidate => candidate.incomplete && candidate.scoreUpperBound >= competitive)) { + return { status: 'incomplete', reason: 'metadata-limit' }; + } + let unique = !second || first.score - second.score >= this.MIN_MATCH_MARGIN; + if (first?.strict && !unique && preferFirst) { + // Provider records can describe the same work in different publications. + // Use their input order, not their venue or DOI, to resolve that choice. + let workKey = (candidate) => { + let { title, creators, years, pageRanges } = this._prepareMatchMetadata(candidate.item); + if (years.size !== 1 || pageRanges.size !== 1) return null; + return JSON.stringify([ + title.tokens, + creators.map(({ family, given }) => [family.tokens, given.tokens]), + [...years], + [...pageRanges], + ]); + }; + let key = workKey(first); + unique = key && candidates.slice(1).every(candidate => first.score - candidate.score >= this.MIN_MATCH_MARGIN + || (candidate.strict && workKey(candidate) === key)); + } + if (first?.strict && unique) { + // Reject a winning title fragment without promoting another candidate. + if (!this._hasIndependentTitle(first, reference)) { + return { status: 'unmatched', reason: 'insufficient-evidence' }; + } + return { status: 'matched', item: first.item }; + } + return { status: 'unmatched', reason: first && second && first.score - second.score < this.MIN_MATCH_MARGIN + ? 'ambiguous' + : 'insufficient-evidence' }; + } + + _exactIdentifierResult(evaluations) { + let exact = evaluations.filter(candidate => candidate.exact); + if (!exact.length) return null; + return exact.length === 1 + ? { status: 'matched', item: exact[0].item } + : { status: 'unmatched', reason: 'ambiguous-identifiers' }; + } + + _evaluate(item, reference, allowTitleSplits = true) { + let metadata = this._prepareMatchMetadata(item); + let identifier = this._identifierEvidence(reference.identifiers, metadata.identifiers); + if (identifier.match) return { item, exact: !identifier.conflict, conflict: identifier.conflict, score: 1 }; + let title = this._alignmentEvidence(metadata.title, reference.tokens, allowTitleSplits); + let container = this._alignmentEvidence(metadata.container, reference.tokens); + let creator = this._creatorEvidence(metadata.creators, reference, [title, container]); + if (creator.positions.size) { + title = this._alignmentEvidence(metadata.title, + reference.tokens.map((token, i) => (creator.positions.has(i) ? '' : token)), allowTitleSplits); + } + // Numbers within a matched title/container are not publication dates or pages. + let numericText = reference.numericEvidenceText; + for (let field of [title, container]) { + if (field.similarity !== 1 || field.start === undefined) continue; + let start = reference.tokenSpans[field.start].start; + let end = reference.tokenSpans[field.end - 1].end; + numericText = numericText.slice(0, start) + ' '.repeat(end - start) + numericText.slice(end); + } + let yearText = numericText.replace(new RegExp(PAGE_LABEL_PATTERN + + `(?:${PAGE_RANGE_PATTERN}|\\p{L}?\\d+(?![\\p{L}\\p{N}]))`, 'giu'), ' '); + let year = this._overlapEvidence(this._extractYears(yearText), metadata.years); + let pages = this._overlapEvidence(this._extractPageRanges(numericText, { requirePageLabel: true }), metadata.pageRanges); + let titleLengthFactor = Math.min(1, metadata.title.alphanumericLength / 16); + // Local title evidence must be reachable by the fast query. Titles needing + // split-word repair or lacking a usable word anchor are left to Find Online. + if (!allowTitleSplits) { + let searchTitle = Zotero.Utilities.Internal.normalizeForSearch(String(item.getField('title', false, true) || '')); + if (!reference.titleProbes.some(probe => searchTitle.includes(probe))) titleLengthFactor = 0; + } + let score = title.similarity * titleLengthFactor; + let strict = score >= this.MIN_STRICT_SCORE && creator.supported && year.match; + return { + item, score, + strict, + titleKey: strict || score < this.MIN_STRICT_SCORE - this.MIN_MATCH_MARGIN || title.start === undefined + ? null + : JSON.stringify([title.start, title.end, metadata.title.tokens.join(' ')]), + titleContext: strict + ? { title, container, creator, creators: metadata.creators, + titleTokens: metadata.title.tokens, containerKey: metadata.container.tokens.join(' ') } + : null, + conflict: identifier.conflict || creator.conflict || year.conflict || pages.conflict, + incomplete: metadata.incomplete || metadata.incompleteCreators || title.incomplete || container.incomplete, + scoreUpperBound: metadata.incomplete ? 1 : this._alignmentUpperBound(metadata.title.tokens, reference.alignmentTerms, allowTitleSplits) * titleLengthFactor, + }; + } + + _hasIndependentTitle({ item, titleContext }, reference) { + let { title, container, creator, creators, titleTokens } = titleContext; + let creatorPositions = creator.positions; + let covered = index => creatorPositions.has(index) || /^\p{N}+$/u.test(reference.tokens[index] || '') + || (container.similarity === 1 && index >= container.start && index < container.end); + // Quotes/brackets and sentence separators delimit a field. A colon or + // dash alone does not: it commonly continues the same title/subtitle. + let separated = (left, right) => { + let gap = reference.normalizedText.slice(reference.tokenSpans[left].end, reference.tokenSpans[right].start); + if (/[\p{Ps}\p{Pe}\p{Pi}\p{Pf}".,;!?\n\r]/u.test(gap)) return true; + if (!/\s\/|\/\s/u.test(gap)) return false; + // A double slash separates a container. A single slash can also be + // part of a title, so require the following text to identify a creator. + return gap.includes('//') || creator.occurrences.some((starts, index) => { + let position = starts.find(position => position >= right); + if (position === undefined) return false; + let given = creators[index].given.tokens; + let visible = reference.tokens.slice(right, position); + return !visible.length || (visible.length <= given.length && this._creatorGivenCompatible(given, visible)); + }); + }; + let before = index => index === 0 || covered(index - 1) || separated(index - 1, index); + let after = index => index === reference.tokens.length || covered(index) || separated(index - 1, index); + let { start, end } = title; + if (start === undefined || end === undefined) return false; + // An approximate alignment can omit a differing edge word. Account for + // those item-title words, not arbitrary extra words around an exact fragment. + if (!before(start)) start -= title.leadingMissing || 0; + if (!after(end)) end += title.trailingMissing || 0; + if (start < 0 || end > reference.tokens.length || !before(start) || !after(end)) return false; + + // Matching editors inside a chapter reference does not identify its book + // as the cited work. Account for known metadata before those creators, + // allowing only a short label rather than an unexplained preceding work. + // A creator can occur first as a chapter author and again near the book + // title. Use the occurrence nearest before this title, not the first one. + let preceding = creator.occurrences.map(starts => starts.findLast(position => position < start)) + .filter(position => position !== undefined); + let prefixEnd = Math.min(...(preceding.length ? preceding : creatorPositions)); + let prefix = reference.tokens.slice(0, prefixEnd).filter((token, i) => { + if (creatorPositions.has(i)) return false; + let span = reference.tokenSpans[i]; + return /\p{L}/u.test(token) && reference.numericEvidenceText.slice(span.start, span.end).trim(); + }); + if (prefix.length <= 2) return true; + let known = new Set(titleTokens); + if (prefix.filter(token => !known.has(token)).length <= 2) return true; + for (let field of [ + 'publicationTitle', + 'seriesTitle', + 'repository', + 'publisher', + 'place', + 'university', + 'proceedingsTitle', + 'conferenceName', + 'bookTitle', + 'edition', + ]) { + let value = String(item.getField(field, false, true) || ''); + if (value.length > this.MAX_MATCH_TEXT_LENGTH) continue; + for (let token of this._prepareField(value).tokens) known.add(token); + } + return prefix.filter(token => !known.has(token)).length <= 2; + } + + _prepareMatchMetadata(item) { + let incomplete = false; + let getField = (field) => { + let value = String(item.getField(field, false, true) || ''); + if (value.length <= this.MAX_MATCH_TEXT_LENGTH) return value; + incomplete = true; + return ''; + }; + let title = this._prepareField(getField('title')); + let container = this._prepareField(['publicationTitle', 'seriesTitle', 'repository'].map(getField).find(Boolean) || ''); + let itemCreators = item.getCreators(); + // Use the same creator-role precedence as Zotero's firstCreator display. + for (let type of [Zotero.CreatorTypes.getPrimaryIDForType(item.itemTypeID), + ...['editor', 'director', 'contributor'].map(role => Zotero.CreatorTypes.getID(role))]) { + let primaryCreators = itemCreators.filter(creator => creator.creatorTypeID === type); + if (primaryCreators.length) { + itemCreators = primaryCreators; + break; + } + } + let creators = itemCreators.slice(0, 100).map(creator => ({ + family: this._prepareField(creator.lastName || creator.name || ''), + given: this._prepareField(creator.firstName || ''), + })).filter(creator => creator.family.tokens.length); + let years = this._extractYears(getField('date') || getField('year')); + let pageRanges = this._extractPageRanges(getField('pages')); + let identifiers = this.extractItemIdentifiers(getField); + return { incomplete, incompleteCreators: itemCreators.length > 100, title, container, creators, years, pageRanges, identifiers }; + } + + _identifierEvidence(referenceIdentifiers, itemIdentifiers) { + let match = false, conflict = false; + for (let type of Object.keys(referenceIdentifiers)) { + let referenceValues = referenceIdentifiers[type]; + let itemValues = itemIdentifiers[type]; + if (!referenceValues.size || !itemValues.size) continue; + let normalizedItems = new Set([...itemValues].map(value => value.toLowerCase())); + let intersects = [...referenceValues].some(value => normalizedItems.has(value.toLowerCase())); + if (intersects) { + match ||= type !== 'ISBN'; + } + else { + conflict = true; + } + } + // ISBN identifies an edition/container, not necessarily the cited record + // (for example, its chapters share the same ISBN). Keep it for retrieval + // and contradiction evidence, but never grant record-identity authority. + return { match, conflict }; + } + + _overlapEvidence(referenceValues, itemValues) { + let match = [...referenceValues].some(value => itemValues.has(value)); + return { match, conflict: !match && referenceValues.size === 1 && itemValues.size > 0 }; + } + + _alignmentEvidence(field, referenceTokens, allowTitleSplits = true) { + if (!field.tokens.length) return { similarity: 0 }; + let target = field.tokens.slice(0, this.MAX_ALIGNMENT_FIELD_TOKENS); + let incomplete = target.length < field.tokens.length; + // Exact matches are token sequences, not arbitrary string substrings (for + // example, a title "Net" must not match the word "Internet"). + let positions = this._sequencePositions(referenceTokens, field.tokens); + if (positions.length) { + return { similarity: 1, start: positions[0], end: positions[0] + field.tokens.length }; + } + let alignment = this._boundedTokenAlignment(target, referenceTokens, allowTitleSplits); + // Dice overlap combines full field coverage and local precision. Unlike + // their arithmetic mean, a dense one-word fragment cannot start at 0.5. + let similarity = 2 * alignment.matches + / (field.tokens.length + alignment.matches + (alignment.sourceGaps || 0)); + return { similarity, incomplete, start: alignment.start, end: alignment.end, + leadingMissing: alignment.targetStart || 0, + trailingMissing: field.tokens.length - (alignment.targetEnd || 0) }; + } + + _alignmentUpperBound(target, source, allowTitleSplits = true) { + let matches = 0; + for (let i = 0; i < target.length; i++) { + if (source.has(target[i]) || (allowTitleSplits + && ((i > 0 && source.has(target[i - 1] + target[i])) + || (i + 1 < target.length && source.has(target[i] + target[i + 1]))))) matches++; + } + return target.length ? 2 * matches / (target.length + matches) : 0; + } + + _boundedTokenAlignment(target, source, allowTitleSplits = true) { + let index = new Map(); + for (let i = 0; i < target.length; i++) { + // Only complete provider pools can credit splitting one reference word. + for (let units of (allowTitleSplits ? [2, 1] : [1])) { + if (i + units > target.length) continue; + let token = target.slice(i, i + units).join(''); + if (!index.has(token)) index.set(token, []); + index.get(token).push({ position: i, units }); + } + } + let maxSpan = Math.min(this.MAX_REFERENCE_TOKENS, target.length + this.MAX_ALIGNMENT_EXTRA_TOKENS); + let best = { matches: 0, sourceGaps: 0, targetGaps: 0 }; + for (let start = 0; start < source.length; start++) { + let targetIndex = 0, targetStart = 0, matches = 0, sourceGaps = 0, previousSource = start - 1; + for (let i = start; i < Math.min(source.length, start + maxSpan); i++) { + let minimum = targetIndex; + let found = index.get(source[i])?.find(entry => entry.position >= minimum); + let sourceUnits = 1; + // A joined source pair may match one target token, not two target tokens. + let pair = i + 1 < source.length + ? index.get(source[i] + source[i + 1])?.find(entry => entry.units === 1 && entry.position >= minimum) + : null; + if (pair && (!found || pair.position < found.position)) { + found = pair; + sourceUnits = 2; + } + if (!found) { + if (!matches) break; + continue; + } + sourceGaps += i - previousSource - 1; + if (!matches) targetStart = found.position; + matches += found.units; + targetIndex = found.position + found.units; + previousSource = i + sourceUnits - 1; + i = previousSource; + let targetGaps = targetIndex - matches; + if (matches > best.matches || (matches === best.matches + && sourceGaps + targetGaps < best.sourceGaps + best.targetGaps)) { + best = { matches, sourceGaps, targetGaps, start, end: previousSource + 1, + targetStart, targetEnd: targetIndex }; + } + if (targetIndex >= target.length) break; + } + } + return best; + } + + _creatorEvidence(creators, reference, fields) { + let fieldPositions = new Set(); + for (let field of fields) { + if (field.similarity === 1) { + for (let i = field.start; i < field.end; i++) fieldPositions.add(i); + } + } + let occurrences = creators.map(creator => this._sequencePositions(reference.tokens, creator.family.tokens)); + let blocked = new Set(fieldPositions); + for (let [index, starts] of occurrences.entries()) { + for (let start of starts) { + for (let i = start; i < start + creators[index].family.tokens.length; i++) blocked.add(i); + } + } + let matched = 0, cursor = 0, prefixLength = 0, prefixEnd = -1; + let conflict = false, missingGiven = false; + let positions = new Set(); + for (let [index, creator] of creators.entries()) { + let minimum = cursor; + let start = occurrences[index].find(position => position >= minimum && !fieldPositions.has(position)); + if (start === undefined) continue; + let end = start + creator.family.tokens.length; + for (let i = start; i < end; i++) positions.add(i); + cursor = end; + matched++; + if (index === prefixLength) { + prefixLength++; + prefixEnd = end; + } + let given = creator.given.tokens; + if (!given.length) continue; + let visible = this._creatorGivenSpan(reference, { start, end }, blocked, Math.max(3, given.length)); + for (let position of visible.positions) { + positions.add(position); + blocked.add(position); + } + if (!visible.tokens.length) { + missingGiven = true; + } + else if (!this._creatorGivenCompatible(given, visible.tokens)) { + conflict = true; + } + } + let etAl = prefixEnd >= 0 && reference.etAlPositions.some(position => position >= prefixEnd && position <= prefixEnd + 3); + return { positions, occurrences, conflict, + supported: matched > 0 && !conflict && (etAl || (matched === creators.length && !missingGiven)) }; + } + + _creatorGivenSpan(reference, family, blocked, maxLength) { + let collect = (direction) => { + let result = []; + let positions = []; + let previous = direction < 0 ? family.start : family.end - 1; + for (let i = previous + direction; i >= 0 && i < reference.tokens.length; i += direction) { + let token = reference.tokens[i]; + if (blocked.has(i) || /\p{N}/u.test(token) || ['et', 'al', 'and'].includes(token)) break; + let left = Math.min(i, previous); + let right = Math.max(i, previous); + let separator = reference.normalizedText.slice( + reference.tokenSpans[left].end, reference.tokenSpans[right].start + ); + if (/[;:&]/u.test(separator) + || (separator.includes('.') && reference.tokens[left].length > 1) + || (separator.includes(',') && (direction < 0 || result.length))) break; + result.push(token); + positions.push(i); + previous = i; + if (result.length === maxLength) break; + } + return { tokens: direction < 0 ? result.reverse() : result, positions }; + }; + let before = collect(-1); + return before.tokens.length ? before : collect(1); + } + + _creatorGivenCompatible(givenTokens, nearby) { + let initials = givenTokens.map(token => token[0]).join(''); + if (!initials) return false; + if (nearby.length === 1 && initials.startsWith(nearby[0])) return true; + return nearby.slice(0, givenTokens.length).every((token, index) => ( + givenTokens[index] === token + || (token.length === 1 && givenTokens[index]?.startsWith(token)) + || (givenTokens[index]?.length === 1 && token.startsWith(givenTokens[index])) + )); + } + + _sequencePositions(source, target) { + if (!target.length || target.length > source.length) return []; + let positions = []; + outer: for (let i = 0; i <= source.length - target.length; i++) { + for (let j = 0; j < target.length; j++) { + if (source[i + j] !== target[j]) continue outer; + } + positions.push(i); + } + return positions; + } + + _extractISBNs(text) { + let forms = new Set(); + let bounded = String(text || '').slice(0, this.MAX_MATCH_TEXT_LENGTH); + // Match only bounded ISBN-shaped spans using the same separators that + // cleanISBN() accepts, and leave checksum validation to that utility. + for (let match of bounded.matchAll(ISBN_PATTERN)) { + let value = Zotero.Utilities.cleanISBN(match[0]); + if (!value) continue; + forms.add(Zotero.Utilities.toISBN13(value)); + } + return forms; + } + + _emptyIdentifiers() { + return { DOI: new Set(), ISBN: new Set(), PMID: new Set(), arXiv: new Set(), adsBibcode: new Set() }; + } + + _extractYears(text) { + return new Set([...String(text || '').normalize('NFKC').matchAll( + /(? match[1])); + } + + _extractPageRanges(text, { requirePageLabel = false } = {}) { + // Reference text requires a page label; a stored pages field does not. + let prefix = requirePageLabel ? PAGE_LABEL_PATTERN : String.raw`(?]*)?\s*>/gi, ''); + let normalized = this._normalizeMatchText(raw); + return { + raw, + tokens: normalized.match(/[\p{L}\p{M}\p{N}]+/gu) || [], + alphanumericLength: [...normalized].filter(char => /[\p{L}\p{N}]/u.test(char)).length, + }; + } + + _normalizeMatchText(value) { + return Zotero.Utilities.Internal.normalizeForSearch(String(value || '')) + .replace(/[\p{P}\p{S}\p{Z}\s]+/gu, ' ') + .trim(); + } + + _maskIdentifierAndURLSpans(value, identifiers = this.extractIdentifiers(value)) { + let text = Zotero.Utilities.Internal.normalizeForSearch(String(value || '')) + .replace(/\b(?:https?|ftp):\/\/[^\s<>]+/giu, match => ' '.repeat(match.length)); + // Mask exactly the identifiers accepted by the parser, including bare and + // legacy arXiv forms. Preserve character positions for candidate alignment. + for (let type of ['DOI', 'arXiv', 'adsBibcode']) { + for (let identifier of identifiers[type]) { + identifier = Zotero.Utilities.Internal.normalizeForSearch(identifier); + text = text.replaceAll(identifier, ' '.repeat(identifier.length)); + } + } + return text + .replace(/\bPMID\s*:?\s*(\d{1,9})(?!\d)/giu, + (match, number) => (identifiers.PMID.has(number) ? ' '.repeat(match.length) : match)) + .replace(ISBN_PATTERN, + match => (Zotero.Utilities.cleanISBN(match) ? ' '.repeat(match.length) : match)); + } + + _regularItemConditions(alias) { + return ['itemNotes', 'itemAttachments', 'itemAnnotations', 'deletedItems'] + .map(table => `NOT EXISTS (SELECT 1 FROM ${table} WHERE ${table}.itemID=${alias}.itemID)`) + .join(' AND '); + } + + async _searchByIdentifiers(identifiers, libraryID, signal) { + this._throwIfAborted(signal); + if (Object.values(identifiers).reduce((sum, values) => sum + values.size, 0) > this.MAX_IDENTIFIER_PROBES) { + return { ids: [], incomplete: true }; + } + let comparisonIdentifiers = Object.fromEntries(Object.entries(identifiers) + .map(([type, values]) => [type, new Set([...values].map(value => value.toLowerCase()))])); + let clauses = [], params = [libraryID]; + let fieldNames = new Map(); + let textProbes = [...identifiers.DOI, ...identifiers.PMID, ...identifiers.arXiv, ...identifiers.adsBibcode]; + for (let [field, values] of [ + ['DOI', identifiers.DOI], + ['PMID', identifiers.PMID], + ['extra', textProbes], + ['url', textProbes], + ['ISBN', identifiers.ISBN.size ? textProbes : []], + ]) { + let fields = [Zotero.ItemFields.getID(field), + ...(field === 'ISBN' ? [] : Zotero.ItemFields.getTypeFieldsFromBase(field) || [])]; + let conditions = []; + for (let value of values) { + let normalized = Zotero.Utilities.Internal.normalizeForSearch(value); + conditions.push('instr(COALESCE(V.valueNormalized, lower(V.value)), ?) > 0'); + params.push(normalized); + } + // cleanDOI() can decode URLs, but the resulting DOI must start with 10. + // Include every raw/encoded form of that prefix, not unrelated escaped URLs. + if (identifiers.DOI.size && field !== 'PMID' && (field !== 'ISBN' || identifiers.ISBN.size)) { + conditions.push("(instr(V.value, '%') > 0 AND (V.value GLOB '*10[./%]*' " + + "OR V.value GLOB '*1%30*' OR V.value GLOB '*%31*' OR instr(V.value, char(0)) > 0))"); + } + if (field === 'ISBN' && identifiers.ISBN.size) { + // ISBN-10 and ISBN-13 share these nine digits. Strip common separators; + // leave other separators to the parser, which verifies the complete ISBN. + for (let value of identifiers.ISBN) { + conditions.push("instr(replace(replace(V.value, '-', ''), ' ', ''), ?) > 0"); + params.push(value.slice(3, 12)); + } + // GLOB stops at NUL, while the identifier parser does not. + conditions.push("(V.value GLOB '*[^ -~]*' OR instr(V.value, char(0)) > 0)"); + } + if (conditions.length) { + for (let id of fields) fieldNames.set(id, field); + clauses.push(`(D.fieldID IN (${fields.join(',')}) AND (${conditions.join(' OR ')}))`); + } + } + if (!clauses.length) return { ids: [], incomplete: false }; + let ids = new Set(); + // SQL retrieves a superset; the matcher's parser verifies identifier equality + // before applying the candidate limit, including mixed-content ISBN fields. + // CROSS JOIN keeps value filtering ahead of item and deletion checks. + await Zotero.DB.queryAsync( + 'SELECT D.itemID AS itemID, D.fieldID AS fieldID, V.value AS value ' + + 'FROM itemData D INDEXED BY itemData_fieldID ' + + 'CROSS JOIN itemDataValues V ON V.valueID=D.valueID CROSS JOIN items I ON I.itemID=D.itemID ' + + 'WHERE I.libraryID=? AND (' + clauses.join(' OR ') + ') AND ' + this._regularItemConditions('I'), + params, + { + noCache: true, + onRow: (row, cancel) => { + if (signal?.aborted) { + cancel(); + return; + } + let field = fieldNames.get(row.getResultByName('fieldID')); + let stored = this.extractItemIdentifiers({ [field]: row.getResultByName('value') }); + if (!Object.entries(stored).some(([type, values]) => [...values] + .some(value => comparisonIdentifiers[type].has(value.toLowerCase())))) return; + ids.add(row.getResultByName('itemID')); + if (ids.size > this.MAX_CANDIDATES) cancel(); + }, + } + ); + this._throwIfAborted(signal); + return { ids: [...ids], incomplete: ids.size > this.MAX_CANDIDATES }; + } + + async _searchByExactTitle(title, libraryID) { + let fields = [Zotero.ItemFields.getID('title'), ...Zotero.ItemFields.getTypeFieldsFromBase('title')]; + let tokens = this._normalizeMatchText(title).split(' ').filter(Boolean); + if (!tokens.length) return { ids: [], incomplete: false }; + let value = '%' + tokens.map(token => Zotero.DB.escapeSQLExpression(token)).join('%') + '%'; + // Retrieve a superset of normalized title equality, including punctuation + // and spacing variants. Keep an overflow witness before structured inspection. + let ids = await Zotero.DB.columnQueryAsync( + 'SELECT I.itemID FROM items I WHERE I.libraryID=? AND ' + this._regularItemConditions('I') + + ' AND I.itemID IN (SELECT D.itemID FROM itemData D JOIN itemDataValues V ON V.valueID=D.valueID ' + + `WHERE D.fieldID IN (${fields.join(',')}) AND COALESCE(V.valueNormalized, V.value) LIKE ? ESCAPE '\\') LIMIT ?`, + [libraryID, value, this.MAX_CANDIDATES + 1], { noCache: true } + ); + return { ids, incomplete: ids.length > this.MAX_CANDIDATES }; + } + + async _searchTitleCandidates(reference, libraryID, { signal } = {}) { + this._throwIfAborted(signal); + let probes = reference.titleProbes; + if (!probes.length) return { ids: [], incomplete: false }; + let fields = [Zotero.ItemFields.getID('title'), ...Zotero.ItemFields.getTypeFieldsFromBase('title')]; + // Search title values once for all probes. Project every mapped title, + // including aliases that did not themselves contain a probe. + let sql = 'SELECT itemID FROM items WHERE libraryID=? AND itemID IN (' + + 'SELECT D.itemID FROM itemData D JOIN itemDataValues V ON V.valueID=D.valueID ' + + `WHERE D.fieldID IN (${fields.join(',')}) AND (` + + probes.map(() => "COALESCE(V.valueNormalized, V.value) LIKE ? ESCAPE '\\'").join(' OR ') + '))'; + let params = [libraryID, ...probes.map((probe) => { + let value = Zotero.DB.escapeSQLExpression(probe); + return '%' + value + '%'; + })]; + let ids = new Set(); + let incomplete = false, rows = 0, started = Date.now(); + let expired = () => rows > this.MAX_RETRIEVAL_ROWS + || Date.now() - started > this.MAX_RETRIEVAL_DURATION_MS; + await Zotero.DB.queryAsync( + `SELECT R.itemID AS itemID, V.value AS value FROM (${sql}) R ` + + `LEFT JOIN itemData D ON D.itemID=R.itemID AND D.fieldID IN (${fields.join(',')}) ` + + 'LEFT JOIN itemDataValues V ON V.valueID=D.valueID ' + + 'WHERE ' + this._regularItemConditions('R'), + params, + { + noCache: true, + onRow: (row, cancel) => { + if (signal?.aborted) { + cancel(); + return; + } + rows++; + let field = this._prepareField(row.getResultByName('value')); + // An optimistic bound is only a retrieval filter, never a match score. + if (this._alignmentUpperBound(field.tokens, reference.alignmentTerms, false) * Math.min(1, field.alphanumericLength / 16) + >= this.MIN_STRICT_SCORE - this.MIN_MATCH_MARGIN) ids.add(row.getResultByName('itemID')); + if (expired()) { + incomplete = true; + cancel(); + } + }, + } + ); + this._throwIfAborted(signal); + return { ids: [...ids], incomplete: incomplete || expired() }; + } + + _candidateField(candidate, baseField) { + let fieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(candidate.itemType, baseField); + return (fieldID && candidate[Zotero.ItemFields.getName(fieldID)]) || candidate[baseField] || ''; + } + + _normalizeIdentityText(value) { + return String(value || '').normalize('NFKC').toLowerCase() + .replace(/\s+/gu, ' ') + .trim(); + } + + _getIdentityKey(candidate) { + // Bibliographic identity excludes descriptive/cataloguing additions. Every + // creator, role, and remaining schema field participates without truncation. + let excluded = new Set([ + 'abstractNote', + 'extra', + 'language', + 'rights', + 'shortTitle', + 'url', + 'accessDate', + 'libraryCatalog', + 'callNumber', + 'DOI', + 'ISBN', + 'PMID', + 'ISSN', + ]); + let fields = Object.keys(candidate).filter(key => key !== 'creators' && !excluded.has(key)) + .sort() + .map(key => [key, this._normalizeIdentityText(candidate[key])]) + .filter(([, value]) => value); + let creators = (candidate.creators || []).map(creator => [ + this._normalizeIdentityText(creator.creatorType || 'author'), + creator.name || creator.fieldMode === 1 ? 'single' : 'person', + this._normalizeIdentityText(creator.name || creator.lastName), + this._normalizeIdentityText(creator.firstName), + ]); + return JSON.stringify([fields, creators]); + } + + _knownDifferentIdentity(a, b) { + let normalize = value => Zotero.Utilities.Internal.normalizeForSearch(String(value || '')).trim(); + if (a.itemType !== b.itemType) return true; + for (let field of ['edition', 'volume', 'issue', 'pages']) { + let left = this._candidateField(a, field), right = this._candidateField(b, field); + if (left && right && normalize(left) !== normalize(right)) return true; + } + let yearsA = this._extractYears(this._candidateField(a, 'date')); + let yearsB = this._extractYears(this._candidateField(b, 'date')); + if (yearsA.size && yearsB.size && ![...yearsA].some(year => yearsB.has(year))) return true; + // Full structured names can prove distinct works with a shared title. + // Initials, partial creator lists and missing names remain unresolved. + let complete = creators => creators?.length && creators.every(creator => ( + creator.name || creator.fieldMode === 1 + || (creator.lastName && /^[\p{L}]{2,}(?:[\s-][\p{L}]{2,})*$/u.test(creator.firstName || '') + && !/^[\p{Lu}]{2,3}$/u.test(creator.firstName)) + )); + if (!complete(a.creators) || !complete(b.creators) + || a.creators.length !== b.creators.length) return false; + return a.creators.some(left => !b.creators.some((right) => { + if (normalize(left.creatorType || 'author') !== normalize(right.creatorType || 'author') + || normalize(left.name || left.lastName) !== normalize(right.name || right.lastName)) return false; + let givenA = normalize(left.firstName).split(/[\s-]+/u); + let givenB = normalize(right.firstName).split(/[\s-]+/u); + return givenA.slice(0, Math.min(givenA.length, givenB.length)) + .every((token, index) => token === givenB[index]); + })); + } +}; diff --git a/chrome/content/zotero/xpcom/reader.js b/chrome/content/zotero/xpcom/reader.js index 13bea81fae..1f77bde50f 100644 --- a/chrome/content/zotero/xpcom/reader.js +++ b/chrome/content/zotero/xpcom/reader.js @@ -23,6 +23,8 @@ ***** END LICENSE BLOCK ***** */ +var { ReaderReferenceSession } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/readerReferences.mjs'); + var { FilePicker } = ChromeUtils.importESModule('chrome://zotero/content/modules/filePicker.mjs'); const { BlockingObserver } = ChromeUtils.importESModule("chrome://zotero/content/BlockingObserver.mjs"); @@ -243,6 +245,9 @@ class ReaderInstance { } } + this._referenceSession?.dispose(); + this._referenceSession = new ReaderReferenceSession(this._item, this); + this._internalReader = this._iframeWindow.wrappedJSObject.createReader(Components.utils.cloneInto({ type: this._type, data, @@ -266,6 +271,7 @@ class ReaderInstance { customThemes: getCustomThemes(), lightTheme: Zotero.Prefs.get('reader.lightTheme'), darkTheme: Zotero.Prefs.get('reader.darkTheme'), + citationAndLinkPreviews: Zotero.Prefs.get('reader.citationAndLinkPreviews'), fontFamily: Zotero.Prefs.get('reader.ebookFontFamily'), hyphenate: Zotero.Prefs.get('reader.ebookHyphenate'), autoDisableNoteTool: Zotero.Prefs.get('reader.autoDisableTool.note'), @@ -279,6 +285,7 @@ class ReaderInstance { readAloudRemoteInterface: this._getReadAloudRemoteInterface(this._iframeWindow), readAloudHighlightGranularity: Zotero.Prefs.get('reader.readAloud.highlightGranularity'), getSDTPack: this._createGetSDTPack(this._iframeWindow), + ...this._referenceSession.readerOptions, loggedIn: Zotero.Sync.Runner.enabled, onOpenContextMenu: () => { // Functions can only be passed over wrappedJSObject (we call back onClick for context menu items) @@ -639,6 +646,9 @@ class ReaderInstance { onSetDarkTheme: (themeName) => { Zotero.Prefs.set('reader.darkTheme', themeName || false); }, + onSetCitationAndLinkPreviews: (enabled) => { + Zotero.Prefs.set('reader.citationAndLinkPreviews', enabled); + }, onSetReadAloudVoice: this._setReadAloudVoice.bind(this), onSetReadAloudEnabledVoices: this._setReadAloudEnabledVoices.bind(this), onSetReadAloudStatus: this._setReadAloudStatus.bind(this), @@ -673,6 +683,7 @@ class ReaderInstance { Zotero.Prefs.registerObserver('reader.textSelectionAnnotationMode', this._handleTextSelectionAnnotationModeChange), Zotero.Prefs.registerObserver('reader.lightTheme', this._handleLightThemeChange), Zotero.Prefs.registerObserver('reader.darkTheme', this._handleDarkThemeChange), + Zotero.Prefs.registerObserver('reader.citationAndLinkPreviews', this._handleCitationAndLinkPreviewsChange), Zotero.Prefs.registerObserver('reader.ebookFontFamily', this._handleEbookPrefChange), Zotero.Prefs.registerObserver('reader.ebookHyphenate', this._handleEbookPrefChange), Zotero.Prefs.registerObserver('reader.autoDisableTool.note', this._handleAutoDisableToolPrefChange), @@ -709,6 +720,8 @@ class ReaderInstance { return; } this._isUninitialized = true; + this._referenceSession?.dispose(); + this._referenceSession = null; if (this._customEventHandler && this._iframeWindow) { try { this._iframeWindow.removeEventListener('customEvent', this._customEventHandler); @@ -1231,6 +1244,12 @@ class ReaderInstance { this._internalReader.setDarkTheme(Zotero.Prefs.get('reader.darkTheme')); }; + _handleCitationAndLinkPreviewsChange = () => { + this._internalReader.setCitationAndLinkPreviews( + Zotero.Prefs.get('reader.citationAndLinkPreviews') + ); + }; + _handleEbookPrefChange = () => { this._internalReader.setFontFamily(Zotero.Prefs.get('reader.ebookFontFamily')); this._internalReader.setHyphenate(Zotero.Prefs.get('reader.ebookHyphenate')); diff --git a/chrome/content/zotero/xpcom/readerReferences.mjs b/chrome/content/zotero/xpcom/readerReferences.mjs new file mode 100644 index 0000000000..f2cee7f4c6 --- /dev/null +++ b/chrome/content/zotero/xpcom/readerReferences.mjs @@ -0,0 +1,695 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2026 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://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 ***** +*/ + +ChromeUtils.defineESModuleGetters(globalThis, { + Zotero: 'chrome://zotero/content/zotero.mjs', + setTimeout: 'resource://gre/modules/Timer.sys.mjs', + clearTimeout: 'resource://gre/modules/Timer.sys.mjs', +}); + +import { BibliographyMatcher } from './bibliographyMatcher.mjs'; + +const CROSSREF_TRANSLATOR_ID = '0a61e167-de9a-4f93-a68a-628b48855909'; +const MAX_IDENTIFIER_LOOKUPS = 3; +const MAX_TRANSLATORS_PER_IDENTIFIER = 3; +const MAX_TRANSLATOR_SELECTIONS = 5; +const MAX_PROVIDER_CANDIDATES = 20; + +/** Bibliographic reference lookup. Library lookup never starts network work. */ +export const onlineReferenceLookup = new class { + constructor() { + this.ONLINE_LOOKUP_TIMEOUT_MS = 20000; + this.PROVIDER_TIMEOUT_MS = 7000; + this.ABSTRACT_TIMEOUT_MS = 2000; + this._providerFlights = new Map(); + } + + /** Return a single verified item, or an unsuccessful lookup status. */ + async findOnline(reference, { signal = new AbortController().signal } = {}) { + let parsed = BibliographyMatcher.prepareReference(reference); + if (signal.aborted) throw new Zotero.HTTP.CancelledException(); + if (!parsed) return { status: 'invalid' }; + let searches = this._identifierSearches(parsed); + if (searches.length > MAX_IDENTIFIER_LOOKUPS) return { status: 'incomplete' }; + let deadline = Date.now() + this.ONLINE_LOOKUP_TIMEOUT_MS; + let failures = [], items = []; + for (let identifier of searches) { + try { + let found = await this._runProvider(() => this._translateIdentifier(identifier), + signal, this._getProviderTimeout(deadline)); + if (signal.aborted) throw new Zotero.HTTP.CancelledException(); + // Check raw pools before deduplication, which could hide an overflow witness. + if (found.length > MAX_PROVIDER_CANDIDATES) return { status: 'incomplete' }; + items = BibliographyMatcher.deduplicateCandidates([...items, ...found]); + } + catch (e) { + if (signal.aborted) throw e; + // A failed identifier route can hide a competing work. + if (searches.length > 1 || e?.name === 'IncompleteCandidatePoolError') return { status: 'incomplete' }; + failures.push(this.errorStatus(e)); + } + } + if (items.length) { + let match = BibliographyMatcher.matchCandidates(reference, items); + if (match.status === 'matched' || match.reason === 'ambiguous-identifiers') { + return this._addPublisherAbstract(match, signal, deadline); + } + } + try { + let found = await this._runProvider(() => this._translateSearch({ + search: { itemType: 'document', query: parsed.text.replace(/\s+/g, ' ').trim() }, + translator: CROSSREF_TRANSLATOR_ID, + }), signal, this._getProviderTimeout(deadline)); + if (signal.aborted) throw new Zotero.HTTP.CancelledException(); + if (found.length > MAX_PROVIDER_CANDIDATES) return { status: 'incomplete' }; + items = BibliographyMatcher.deduplicateCandidates([...items, ...found]); + } + catch (e) { + if (signal.aborted) throw e; + if (e?.name === 'IncompleteCandidatePoolError') return { status: 'incomplete' }; + failures.push(this.errorStatus(e)); + // Keep incomplete candidate verification distinct from a failed fallback. + if (!items.length) return { status: this._bestFailure(failures) }; + } + let match = BibliographyMatcher.matchCandidates(reference, items); + return match.status === 'unmatched' && match.reason !== 'ambiguous-identifiers' && failures.length + ? { status: this._bestFailure(failures) } + : this._addPublisherAbstract(match, signal, deadline); + } + + async _addPublisherAbstract(match, signal, deadline) { + let item = match.item; + if (match.status !== 'matched' || item.id || item.getField('abstractNote')?.trim()) return match; + deadline = Math.min(deadline, Date.now() + this.ABSTRACT_TIMEOUT_MS); + let timeout = deadline - Date.now(); + if (timeout <= 0) return match; + try { + // Website URL suffixes are not additional declared DOIs. + let dois = BibliographyMatcher.extractItemIdentifiers({ + DOI: item.getField('DOI'), extra: item.getField('extra'), + }).DOI; + if (dois.size !== 1) return match; + let [doi] = dois; + let results = await this._runProvider(() => this._translateLandingPage(doi), signal, timeout); + if (signal.aborted) throw new Zotero.HTTP.CancelledException(); + if (Date.now() >= deadline || results.length !== 1) return match; + let { DOI, extra, abstractNote: abstract } = results[0]; + let resultDOIs = BibliographyMatcher.extractItemIdentifiers({ DOI, extra }).DOI; + if (resultDOIs.size !== 1 || !resultDOIs.has(doi) + || typeof abstract !== 'string' || !abstract.trim()) return match; + // Optional enrichment must not make the verified result too large to cache or save. + BibliographyMatcher.createCandidateSnapshot({ ...item.toJSON(), abstractNote: abstract }); + item.setField('abstractNote', abstract); + } + catch (e) { + if (signal.aborted) throw e; + } + return match; + } + + _translateLandingPage(doi) { + return this._shareProvider(JSON.stringify(['landing-page', doi]), async () => { + let xhr = await Zotero.HTTP.request('GET', `https://doi.org/${encodeURIComponent(doi)}`, { + responseType: 'document', timeout: this.ABSTRACT_TIMEOUT_MS, errorDelayMax: 0, + }); + if (!xhr.response || !['text/html', 'application/xhtml+xml'].includes(xhr.response.contentType)) return []; + let translate = new Zotero.Translate.Web(); + translate.setDocument(Zotero.HTTP.wrapDocument(xhr.response, xhr.responseURL)); + let translators = await translate.getTranslators(); + if (!translators.length) return []; + translate.setTranslator(translators[0]); + translate.setHandler('select', (obj, items, callback) => callback(false)); + return translate.translate({ libraryID: false, saveAttachments: false }); + }); + } + + errorStatus(error) { + if (error instanceof Zotero.HTTP.CancelledException || error?.name === 'AbortError') return 'cancelled'; + if (error instanceof Zotero.HTTP.TimeoutException) return 'timeout'; + if (error instanceof Zotero.HTTP.BrowserOfflineException) return 'offline'; + if (error?.status === 429) return 'rate-limited'; + if (error?.status === 408 || error?.name === 'TimeoutError') return 'timeout'; + return 'error'; + } + + _identifierSearches(parsed) { + // Additional labelled identifiers are conflict evidence, not new provider + // routes. Retain Utilities' primary-type routing for arXiv and ADS. + let primary = Zotero.Utilities.extractIdentifiers(parsed.text); + return Object.entries(parsed.identifiers).flatMap(([type, values]) => ( + [...values].filter(value => !['arXiv', 'adsBibcode'].includes(type) + || primary.some(identifier => identifier[type]?.toLowerCase() === value.toLowerCase())) + .map(value => ({ [type]: value })) + )); + } + + _shareProvider(key, run) { + // Standard translators cannot be cancelled. Reuse identical outstanding + // work even if an earlier caller has stopped waiting for it. + if (!this._providerFlights.has(key)) { + let pending = Promise.resolve().then(run) + .finally(() => this._providerFlights.delete(key)); + this._providerFlights.set(key, pending); + } + return this._providerFlights.get(key); + } + + _translateIdentifier(identifier) { + return this._shareProvider(JSON.stringify(['identifier', identifier]), async () => { + let translate = new Zotero.Translate.Search(); + translate.setIdentifier(identifier); + let translators = await translate.getTranslators(); + if (!translators.length) return []; + translate.setTranslator(translators.slice(0, MAX_TRANSLATORS_PER_IDENTIFIER)); + return this._runTranslation(translate); + }); + } + + _translateSearch({ search, translator }) { + return this._shareProvider(JSON.stringify(['search', translator, search]), () => { + let translate = new Zotero.Translate.Search(); + translate.setSearch(search); + translate.setTranslator(translator); + return this._runTranslation(translate); + }); + } + + async _runTranslation(translate) { + let incompleteError; + translate.setHandler('select', (obj, items, callback) => { + let keys = Object.keys(items); + if (keys.length > MAX_TRANSLATOR_SELECTIONS) { + // Never certify a subset of translator choices as a complete pool. + incompleteError = new Error('Translator selection exceeds the candidate limit'); + incompleteError.name = 'IncompleteCandidatePoolError'; + callback(false); + return; + } + callback(items); + }); + let items = await translate.translate({ libraryID: false, saveAttachments: false }).catch((e) => { + // Search uses ERROR_NO_RESULTS for both empty and failed searches. + // Neither establishes a successfully searched empty pool. + throw incompleteError || e; + }); + if (incompleteError) throw incompleteError; + // Keep one overflow witness instead of certifying a silently truncated pool. + return Array.isArray(items) ? items.slice(0, MAX_PROVIDER_CANDIDATES + 1) : []; + } + + async _runProvider(run, signal, timeout) { + if (signal.aborted) throw new Zotero.HTTP.CancelledException(); + let promise = Promise.resolve().then(() => { + if (signal.aborted) throw new Zotero.HTTP.CancelledException(); + return run(); + }); + let timeoutID, onAbort; + try { + // Stop this caller's wait, not the translator's shared network work. + return await Promise.race([promise, new Promise((resolve, reject) => { + onAbort = () => reject(new Zotero.HTTP.CancelledException()); + signal.addEventListener('abort', onAbort, { once: true }); + timeoutID = setTimeout(() => reject(new Zotero.HTTP.TimeoutException(timeout)), timeout); + })]); + } + finally { + clearTimeout(timeoutID); + signal.removeEventListener('abort', onAbort); + } + } + + _getProviderTimeout(deadline) { + let remaining = deadline - Date.now(); + if (remaining <= 0) throw new Zotero.HTTP.TimeoutException(remaining); + return Math.min(this.PROVIDER_TIMEOUT_MS, remaining); + } + + _bestFailure(statuses) { + for (let status of ['offline', 'rate-limited', 'timeout', 'error', 'cancelled']) { + if (statuses.includes(status)) return status; + } + return 'error'; + } +}; + + +// Shared across Reader sessions so simultaneous Adds cannot create duplicates. +export const referenceSaver = new class { + constructor() { + this._metadataSaveTails = new Map(); + this._attachmentFlights = new Map(); + } + + async saveCandidate(metadata, { libraryID } = {}) { + let library = Zotero.Libraries.get(libraryID); + if (!library?.editable) { + throw new Error('Target library is not editable'); + } + let candidate = BibliographyMatcher.createCandidateSnapshot(metadata); + let previous = this._metadataSaveTails.get(libraryID) || Promise.resolve(); + let metadataSave = previous.catch(() => {}).then(async () => { + // Serialize only identity verification and metadata persistence per library. + // File acquisition runs after releasing this queue. + let duplicate = await BibliographyMatcher.checkForDuplicate(candidate, libraryID); + if (duplicate.status === 'matched') return { item: duplicate.item, existing: true }; + if (duplicate.status !== 'absent') { + let error = new Error('Duplicate identity could not be resolved'); + error.name = 'DuplicateCheckUnresolvedError'; + throw error; + } + if (!Zotero.Libraries.get(libraryID)?.editable) { + throw new Error('Target library is not editable'); + } + let itemSaver = new Zotero.Translate.ItemSaver({ + libraryID, + attachmentMode: Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE, + saveOptions: { skipSelect: true }, + }); + let items = await itemSaver.saveItems([candidate]); + let item = items?.[0]; + if (!item || item.libraryID !== libraryID || !item.isRegularItem() || item.deleted) { + throw new Error('ItemSaver returned an invalid bibliographic item'); + } + return { item, existing: false }; + }); + this._track(this._metadataSaveTails, libraryID, metadataSave); + let saved = await metadataSave; + // Metadata is ready now; optional PDF acquisition completes independently. + let fileAcquisitionPromise = this._attachmentFlights.get(saved.item.id); + if (!saved.existing && !fileAcquisitionPromise) { + fileAcquisitionPromise = this._acquireAssociatedFile(saved.item, libraryID); + this._track(this._attachmentFlights, saved.item.id, fileAcquisitionPromise); + } + return { ...saved, fileAcquisitionPromise }; + } + + getFileAcquisitionPromise(itemID) { + return this._attachmentFlights.get(itemID); + } + + _track(map, key, promise) { + map.set(key, promise); + let release = () => { + if (map.get(key) === promise) map.delete(key); + }; + promise.then(release, release); + } + + async _acquireAssociatedFile(item, libraryID) { + try { + if (!item.deleted && item.libraryID === libraryID + && Zotero.Libraries.get(libraryID)?.filesEditable + && Zotero.Prefs.get('downloadAssociatedFiles') + && Zotero.Attachments.canFindFileForItem(item)) { + let resolvers = Zotero.Attachments.getFileResolvers(item, ['oa', 'custom'], true); + await Zotero.Attachments.addFileFromURLs(item, resolvers); + } + } + catch (e) { + // A missing PDF (including shutdown during acquisition) never rolls + // back a committed item or leaves an unhandled background rejection. + Zotero.logError(e); + } + } +}; + +function formatDisplayText(value, maxLength) { + // Bound plain display text without deleting bibliographic content that + // resembles URLs or markup. Operational URLs remain validated and host-owned. + return String(value ?? '') + .slice(0, maxLength) + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') + .replace(/\s+/gu, ' ') + .trim(); +} + +/** One document session owns references, action tokens, and successful online metadata. */ +export class ReaderReferenceSession { + static MAX_REFERENCES = 128; + + static MAX_SOURCE_KEY_LENGTH = 512; + + static MAX_REQUEST_FIELD_LENGTH = 128; + + static MAX_DISPLAY_FIELD_LENGTH = 2000; + + static MAX_ABSTRACT_LENGTH = 20000; + + constructor(attachment, reader) { + if (!attachment?.id || !attachment.isAttachment()) throw new Error('Expected a Reader attachment'); + this._reader = reader; + this._attachmentID = attachment.id; + this._libraryID = attachment.libraryID; + this._references = new Map(); + this._operations = new Map(); + this._onlineMatchCache = new Map(); + this._disposed = false; + } + + get readerOptions() { + return { + onMatchReference: (request, callback) => this._handleRequest('matchReference', request, callback), + onFindReferenceOnline: (request, callback) => this._handleRequest('findOnline', request, callback), + onCancelReferenceLookup: request => this.cancel(request), + onPerformReferenceAction: (request, callback) => this._handleRequest('performAction', request, callback), + }; + } + + async _handleRequest(method, request, callback) { + let reader = this._reader; + if (this._disposed || !reader || typeof callback !== 'function') return; + let response; + try { + response = await this[method](JSON.parse(JSON.stringify(request))); + } + catch (e) { + Zotero.logError(e); + response = this._response(request, { status: method === 'performAction' ? 'failed' : 'error' }); + } + if (this._disposed || reader._isUninitialized || !reader._iframeWindow) return; + try { + callback(Components.utils.cloneInto(response, reader._iframeWindow)); + // Deliver saved metadata before reporting a completed background download. + let result = response.result; + if (result?.libraryState === 'existing') { + let update = await this.waitForAttachmentUpdate(response.referenceKey, result.resultID); + if (update && !this._disposed && !reader._isUninitialized && reader._iframeWindow) { + reader._internalReader.updateReferenceResult(Components.utils.cloneInto(update, reader._iframeWindow)); + } + } + } + catch (e) { + Zotero.logError(e); + } + } + + matchReference(request) { + let text = Array.isArray(request?.referenceParts) && BibliographyMatcher.getReferenceText(request.referenceParts); + if (this._disposed || this._operations.has(request?.requestID) + || !this._validRequest(request, ['sourceKey']) || !text) return this._response(request, { status: 'invalid' }); + return this._lookup(request, this._replaceReference(request.sourceKey, text), false); + } + + findOnline(request) { + if (!this._validRequest(request, ['referenceKey'])) return this._response(request, { status: 'invalid' }); + if (!this._references.has(request.referenceKey)) return this._response(request, { status: 'expired' }); + return this._lookup(request, request.referenceKey, true); + } + + async _lookup(request, referenceKey, online) { + let reference = this._references.get(referenceKey); + let controller = new AbortController(); + let operation = { controller, referenceKey }; + let restored = this._onlineMatchCache.has(reference.text); + let respond = data => this._response(request, { referenceKey, + ...(!online && restored ? { lookupSource: 'online' } : {}), ...data }); + if (this._disposed || this._operations.has(request.requestID)) return respond({ status: 'invalid' }); + for (let pending of this._operations.values()) { + if (pending.referenceKey === referenceKey) pending.controller.abort(); + } + this._operations.set(request.requestID, operation); + try { + let item, candidateJSON; + let existing = !online && !restored; + if (restored) { + candidateJSON = JSON.parse(this._onlineMatchCache.get(reference.text)); + } + else { + if (online && Zotero.HTTP.browserIsOffline()) return respond({ status: 'offline' }); + let match = await (online + ? onlineReferenceLookup.findOnline(reference.text, { signal: controller.signal }) + : BibliographyMatcher.matchLibrary(reference.text, this._libraryID, { signal: controller.signal })); + if (!this._isCurrent(operation)) return respond({ status: 'cancelled' }); + if (match.status !== 'matched') return respond({ status: this._lookupStatus(match) }); + item = match.item; + } + if (!existing) { + candidateJSON ||= BibliographyMatcher.createCandidateSnapshot(item); + let duplicate = await BibliographyMatcher.checkForDuplicate(candidateJSON, this._libraryID); + existing = duplicate.status === 'matched'; + if (existing) { + item = duplicate.item; + } + else if (restored) { + item = new Zotero.Item(candidateJSON.itemType); + item.fromJSON(candidateJSON); + } + } + if (!this._isCurrent(operation)) return respond({ status: 'cancelled' }); + let binding = { resultID: this._createOpaqueID(), referenceKey }; + let result; + if (existing) { + if (!this._isValidItem(item)) throw new Error('Item is outside the session library'); + binding.itemID = item.id; + binding.fileAcquisitionPromise = referenceSaver.getFileAcquisitionPromise(item.id); + result = await this._createLibraryResult(binding.resultID, item); + } + else { + binding.candidateJSON = candidateJSON; + binding.viewURL = this._viewURL(item); + let actions = []; + if (Zotero.Libraries.get(this._libraryID)?.editable) actions.push('add-to-library'); + if (binding.viewURL) actions.push('view-online'); + result = { resultID: binding.resultID, libraryState: 'unsaved', + actions, display: this._getDisplayMetadata(item) }; + } + if (!this._isCurrent(operation)) return respond({ status: 'cancelled' }); + if (existing && !this._isValidItem(item)) return respond({ status: 'expired' }); + binding.actions = result.actions; + reference.result = binding; + if (online && !restored) { + // Snapshot validation already bounds the complete serialized metadata. + this._onlineMatchCache.set(reference.text, JSON.stringify(candidateJSON)); + if (this._onlineMatchCache.size > ReaderReferenceSession.MAX_REFERENCES) { + this._onlineMatchCache.delete(this._onlineMatchCache.keys().next().value); + } + } + return respond({ status: 'strong', result }); + } + catch (e) { + let cancelled = !this._isCurrent(operation) || e?.name === 'AbortError'; + if (!cancelled) Zotero.logError(online ? new Error('Reference provider request failed') : e); + return respond({ status: cancelled ? 'cancelled' : online ? onlineReferenceLookup.errorStatus(e) : 'error' }); + } + finally { + this._operations.delete(request.requestID); + } + } + + async performAction(request) { + let respond = (status, result) => this._response(request, { status, result }); + if (!this._validRequest(request, ['referenceKey', 'resultID', 'action'])) return respond('failed'); + let { referenceKey, resultID, action } = request; + let reference = this._references.get(referenceKey); + let binding = reference?.result; + if (binding?.resultID !== resultID) return respond('stale'); + if (!binding || this._disposed) return respond('stale'); + if (!binding.actions.includes(action) || reference.saving) return respond('failed'); + if (action === 'add-to-library') reference.saving = request.requestID; + try { + let item = binding.itemID ? await Zotero.Items.getAsync(binding.itemID) : null; + if (!this._isBoundResult(binding) || (binding.itemID && !this._isValidItem(item))) return respond('stale'); + if (action === 'add-to-library') { + let source = await Zotero.Items.getAsync(this._attachmentID); + if (!this._isBoundResult(binding) || !source?.isAttachment() || source.deleted + || source.libraryID !== this._libraryID) return respond('stale'); + if (item || !Zotero.Libraries.get(this._libraryID)?.editable) return respond('failed'); + // A delegated save belongs to the library, not the lifetime of the popup. + let saved = await referenceSaver.saveCandidate(binding.candidateJSON, { libraryID: this._libraryID }); + binding.itemID = saved.item.id; + binding.fileAcquisitionPromise = saved.fileAcquisitionPromise; + delete binding.candidateJSON; + delete binding.viewURL; + let result = await this._createLibraryResult(resultID, saved.item); + binding.actions = result.actions; + return respond(saved.existing ? 'existing' : 'saved', result); + } + let win = Zotero.getMainWindow(); + if (!win) throw new Error('No Zotero window is available'); + if (action === 'view-online') { + let url = item ? this._viewURL(item) : this._validHTTPURL(binding.viewURL); + if (!url) return respond(item ? 'failed' : 'stale'); + win.ZoteroPane.loadURI(url); + } + else if (action === 'show-in-library' && item) { + await win.ZoteroPane.selectItem(item.id); + } + else if (action === 'open-attachment' && item) { + let attachment = await item.getBestAttachment(); + if (!this._isBoundResult(binding) || !this._isValidItem(item)) return respond('stale'); + if (!attachment || attachment.deleted || attachment.libraryID !== this._libraryID + || attachment.parentID !== item.id) return respond('stale'); + await win.ZoteroPane.viewAttachment(attachment.id); + } + else { + return respond('failed'); + } + return respond(this._isBoundResult(binding) ? 'ok' : 'stale'); + } + catch (e) { + Zotero.logError(e); + return respond(e?.name === 'DuplicateCheckUnresolvedError' ? 'duplicate-conflict' : 'failed'); + } + finally { + if (action === 'add-to-library') delete reference.saving; + } + } + + async waitForAttachmentUpdate(referenceKey, resultID) { + let binding = this._references.get(referenceKey)?.result; + if (binding?.resultID !== resultID || !binding.fileAcquisitionPromise) return null; + let completion = binding.fileAcquisitionPromise; + delete binding.fileAcquisitionPromise; + await completion; + if (!this._isBoundResult(binding)) return null; + let item = binding.itemID && await Zotero.Items.getAsync(binding.itemID); + if (!this._isBoundResult(binding) || !this._isValidItem(item)) return null; + let result = await this._createLibraryResult(resultID, item); + if (!this._isBoundResult(binding) || !this._isValidItem(item)) return null; + let changed = result.actions.join() !== binding.actions.join(); + binding.actions = result.actions; + return changed ? { referenceKey, result } : null; + } + + cancel(request) { + this._operations.get(request?.requestID)?.controller.abort(); + } + + dispose() { + this._disposed = true; + this._reader = null; + for (let pending of this._operations.values()) pending.controller.abort(); + this._operations.clear(); + this._references.clear(); + this._onlineMatchCache.clear(); + } + + _validRequest(request, fields) { + return ['requestID', ...fields].every(field => typeof request?.[field] === 'string' + && request[field].length > 0 && request[field].length <= (field === 'sourceKey' + ? ReaderReferenceSession.MAX_SOURCE_KEY_LENGTH + : ReaderReferenceSession.MAX_REQUEST_FIELD_LENGTH)); + } + + _isCurrent(operation) { + return !this._disposed && !operation.controller.signal.aborted && this._references.has(operation.referenceKey); + } + + _replaceReference(sourceKey, text) { + for (let [key, reference] of this._references) { + if (reference.sourceKey === sourceKey) this._removeReference(key); + } + let referenceKey = this._createOpaqueID(); + this._references.set(referenceKey, { sourceKey, text }); + if (this._references.size > ReaderReferenceSession.MAX_REFERENCES) { + this._removeReference(this._references.keys().next().value); + } + return referenceKey; + } + + _removeReference(referenceKey) { + this._references.delete(referenceKey); + for (let pending of this._operations.values()) { + if (pending.referenceKey === referenceKey) pending.controller.abort(); + } + } + + _isBoundResult(binding) { + return !this._disposed && this._references.get(binding.referenceKey)?.result === binding; + } + + async _createLibraryResult(resultID, item) { + let actions = ['show-in-library']; + if (await item.getBestAttachment()) actions.unshift('open-attachment'); + if (this._viewURL(item)) actions.push('view-online'); + return { resultID, libraryState: 'existing', actions, display: this._getDisplayMetadata(item) }; + } + + _getDisplayMetadata(item) { + let getField = (field, maxLength = ReaderReferenceSession.MAX_DISPLAY_FIELD_LENGTH) => { + let value = item.getField(field, false, true); + let clipped = field === 'abstractNote' && String(value ?? '').length > maxLength; + return formatDisplayText(value, clipped ? maxLength - 1 : maxLength) + (clipped ? '…' : ''); + }; + let display = { + itemType: Zotero.ItemTypes.getName(item.itemTypeID) || 'document', + title: Zotero.Utilities.Internal.renderItemTitle(getField('title')), + firstCreator: getField('firstCreator'), + year: getField('year'), + }; + let abstract = getField('abstractNote', ReaderReferenceSession.MAX_ABSTRACT_LENGTH); + if (abstract) display.abstract = abstract; + return display; + } + + _isValidItem(item) { + return !!item && item.libraryID === this._libraryID && item.isRegularItem() && !item.deleted; + } + + _viewURL(item) { + let url = this._validHTTPURL(item.getField('url')); + if (url) return url; + let doi = Zotero.Utilities.cleanDOI(item.getField('DOI')); + return doi ? `https://doi.org/${encodeURIComponent(doi)}` : null; + } + + _validHTTPURL(value) { + try { + let url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password + ? url.href + : null; + } + catch { + return null; + } + } + + _response(request, { referenceKey, status, result, lookupSource }) { + let echo = field => (typeof request?.[field] === 'string' ? request[field].slice(0, ReaderReferenceSession.MAX_REQUEST_FIELD_LENGTH) : ''); + let response = { requestID: echo('requestID'), referenceKey: referenceKey || echo('referenceKey'), status }; + if (result) response.result = result; + if (lookupSource === 'online') response.lookupSource = lookupSource; + return response; + } + + _lookupStatus(match) { + switch (match?.status) { + case 'invalid': + case 'incomplete': + case 'offline': + case 'rate-limited': + case 'timeout': + case 'cancelled': + case 'error': return match.status; + case 'unmatched': return match.reason === 'invalid-input' ? 'invalid' : 'no-match'; + default: return 'error'; + } + } + + _createOpaqueID() { + return Services.uuid.generateUUID().toString().replace(/[{}]/g, ''); + } +} diff --git a/chrome/locale/en-US/zotero/reader.ftl b/chrome/locale/en-US/zotero/reader.ftl index 9dbc769ff9..caf2cec6b7 100644 --- a/chrome/locale/en-US/zotero/reader.ftl +++ b/chrome/locale/en-US/zotero/reader.ftl @@ -119,6 +119,7 @@ reader-spread-mode = Spreads reader-flow-mode = Page Layout reader-columns = Columns reader-split-view = Split View +reader-citation-and-link-previews = Citation and Link Previews reader-themes = Themes reader-vertical = Vertical reader-horizontal = Horizontal @@ -204,6 +205,17 @@ reader-selected-pages = { $count -> } reader-page-options = Page Options +reader-reference-popup = Reference +reader-reference-expand = Expand reference +reader-reference-collapse = Collapse reference +reader-reference-no-match = No match found +reader-reference-error = Could not complete this request. +reader-reference-retry = Retry +reader-reference-find-online = Find Online +reader-reference-existing = In your library +reader-reference-action-add-to-library = Add to Library +reader-reference-action-open-attachment = Open Attachment + reader-read-aloud = Read Aloud reader-read-aloud-from-here = Read Aloud from Here reader-read-aloud-options = Options diff --git a/defaults/preferences/zotero.js b/defaults/preferences/zotero.js index c51de7cda2..4ac30e2818 100644 --- a/defaults/preferences/zotero.js +++ b/defaults/preferences/zotero.js @@ -242,6 +242,7 @@ pref("extensions.zotero.tabs.title.reader", "titleCreatorYear"); pref("extensions.zotero.reader.textSelectionAnnotationMode", "highlight"); pref("extensions.zotero.reader.lightTheme", ""); pref("extensions.zotero.reader.darkTheme", "dark"); +pref("extensions.zotero.reader.citationAndLinkPreviews", true); pref("extensions.zotero.reader.ebookFontFamily", "Georgia, serif"); pref("extensions.zotero.reader.ebookHyphenate", true); pref("extensions.zotero.reader.autoDisableTool.note", true); diff --git a/document-worker b/document-worker index 8fb70af400..a8bab24c82 160000 --- a/document-worker +++ b/document-worker @@ -1 +1 @@ -Subproject commit 8fb70af4000873561c82b2f4eb88be190efb9979 +Subproject commit a8bab24c82a41435f125a17a4865951b74518117 diff --git a/reader b/reader index e7a93982ba..da93cf8bc8 160000 --- a/reader +++ b/reader @@ -1 +1 @@ -Subproject commit e7a93982baaee820e1bf5604dbf7f86400c98272 +Subproject commit da93cf8bc8555952454a741d626781aa599c502b diff --git a/test/tests/bibliographyMatcherTest.js b/test/tests/bibliographyMatcherTest.js new file mode 100644 index 0000000000..cc7c216cb5 --- /dev/null +++ b/test/tests/bibliographyMatcherTest.js @@ -0,0 +1,2792 @@ +"use strict"; + +var { BibliographyMatcher } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/bibliographyMatcher.mjs'); +var { onlineReferenceLookup } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/readerReferences.mjs'); + +describe("BibliographyMatcher", function () { + let items = []; + let observations; + beforeEach(function () { + observations = sinon.createSandbox(); + }); + afterEach(function () { + observations.restore(); + }); + + describe('cooperative cancellation', function () { + it('should reject pre-aborted matching before parsing or querying', async function () { + let controller = new AbortController(); + controller.abort(); + let prepareReference = observations.spy(BibliographyMatcher, 'prepareReference'); + let error = await getPromiseError(BibliographyMatcher.matchLibrary('Reference', 1, { signal: controller.signal })); + assert.equal(error?.name, 'AbortError'); + assert.isFalse(prepareReference.called); + }); + + it('should stop before the following route after retrieval aborts', async function () { + let controller = new AbortController(); + observations.stub(BibliographyMatcher, '_searchByIdentifiers').callsFake(async () => { + controller.abort(); + return { ids: [], incomplete: false }; + }); + let title = observations.spy(BibliographyMatcher, '_searchTitleCandidates'); + let error = await getPromiseError(BibliographyMatcher.matchLibrary('Reference 2020', 1, { signal: controller.signal })); + assert.equal(error?.name, 'AbortError'); + assert.isFalse(title.called); + }); + + it('should reject a cancelled real row stream without returning partial candidates or logging an error', async function () { + let records = []; + for (let i = 0; i < 3; i++) records.push(await createItem({ itemType: 'journalArticle', title: `Cancellation fixture ${i}` })); + let controller = new AbortController(); + let matcher = BibliographyMatcher; + let original = matcher._prepareField; + let field = observations.stub(matcher, '_prepareField').callsFake(function (...args) { + let result = original.apply(this, args); + controller.abort(); + return result; + }); + let log = observations.spy(Zotero, 'logError'); + let error = await getPromiseError(matcher._searchTitleCandidates(matcher.prepareReference('Cancellation fixture'), + records[0].libraryID, { signal: controller.signal })); + assert.equal(error?.name, 'AbortError'); + assert.isTrue(field.calledOnce); + assert.isFalse(log.called); + }); + + it('should not evaluate candidates when cancellation occurs after loading', async function () { + let controller = new AbortController(); + let matcher = BibliographyMatcher; + let item = await createItem({ itemType: 'journalArticle', title: 'Cancellation loaded candidate' }); + observations.stub(matcher, '_searchByIdentifiers').resolves({ ids: [], incomplete: false }); + observations.stub(matcher, '_searchTitleCandidates').resolves({ ids: [item.id], incomplete: false }); + observations.stub(Zotero.Items, 'loadDataTypes').callsFake(async () => controller.abort()); + let evaluate = observations.spy(matcher, '_rankEvaluations'); + let error = await getPromiseError(matcher.matchLibrary('Cancellation loaded candidate', item.libraryID, { signal: controller.signal })); + assert.equal(error?.name, 'AbortError'); + assert.isFalse(evaluate.called); + }); + + it('should stop an ISBN stream and reject its partial matching rows', async function () { + for (let i = 0; i < 3; i++) await createItem({ itemType: 'book', title: `ISBN cancellation ${i}`, ISBN: '9780306406157' }); + let controller = new AbortController(); + let matcher = BibliographyMatcher; + let reference = matcher.prepareReference('ISBN 9780306406157'); + let original = matcher.extractItemIdentifiers; + let extract = observations.stub(matcher, 'extractItemIdentifiers').callsFake(function (...args) { + let result = original.apply(this, args); + controller.abort(); + return result; + }); + let log = observations.spy(Zotero, 'logError'); + let error = await getPromiseError(matcher._searchByIdentifiers(reference.identifiers, Zotero.Libraries.userLibraryID, + controller.signal)); + assert.equal(error?.name, 'AbortError'); + assert.isTrue(extract.called); + assert.isFalse(log.called); + }); + + it('should stop loading metadata if item retrieval aborts', async function () { + let controller = new AbortController(); + observations.stub(Zotero.Items, 'getAsync').callsFake(async () => { + controller.abort(); + return []; + }); + let load = observations.spy(Zotero.Items, 'loadDataTypes'); + let error = await getPromiseError(BibliographyMatcher._loadLibraryCandidates([], 1, controller.signal)); + assert.equal(error?.name, 'AbortError'); + assert.isFalse(load.called); + }); + + it('should preserve uncancelled output with an optional signal', async function () { + let item = await createItem({ itemType: 'journalArticle', title: 'Cancellation equivalence', DOI: '10.1234/cancel-equivalent' }); + let matcher = BibliographyMatcher; + let without = await matcher.matchLibrary('doi:10.1234/cancel-equivalent', item.libraryID); + let withSignal = await matcher.matchLibrary('doi:10.1234/cancel-equivalent', item.libraryID, { signal: new AbortController().signal }); + assert.deepEqual(withSignal, without); + }); + }); + + afterEach(async function () { + for (let item of items.splice(0)) { + if (item.id && Zotero.Items.exists(item.id)) { + await item.eraseTx(); + } + } + }); + + async function createItem(data, libraryID = Zotero.Libraries.userLibraryID) { + let item = new Zotero.Item(data.itemType || 'journalArticle'); + item.libraryID = libraryID; + item.fromJSON(data); + await item.saveTx(); + items.push(item); + return item; + } + + function evaluate(reference, candidates) { + return BibliographyMatcher.matchCandidates(reference, candidates); + } + + describe('real PDF citations', function () { + // Source PDFs and licenses are cited alongside the cases below. + // Candidate metadata comes from the publication records linked below. + // https://aclanthology.org/L08-1005/ + let bird = { + itemType: "conferencePaper", + title: "The ACL Anthology Reference Corpus: A Reference Dataset for Bibliographic Research in Computational Linguistics", + creators: [ + { creatorType: "author", firstName: "Steven", lastName: "Bird" }, + { creatorType: "author", firstName: "Robert", lastName: "Dale" }, + { creatorType: "author", firstName: "Bonnie J.", lastName: "Dorr" }, + { creatorType: "author", firstName: "Bryan", lastName: "Gibson" }, + { creatorType: "author", firstName: "Mark T.", lastName: "Joseph" }, + { creatorType: "author", firstName: "Min-Yen", lastName: "Kan" }, + { creatorType: "author", firstName: "Dongwon", lastName: "Lee" }, + { creatorType: "author", firstName: "Brett", lastName: "Powley" }, + { creatorType: "author", firstName: "Dragomir R.", lastName: "Radev" }, + { creatorType: "author", firstName: "Yee Fan", lastName: "Tan" }, + ], + date: "2008", + proceedingsTitle: "Proceedings of the Sixth International Conference on Language Resources and Evaluation", + publisher: "European Language Resources Association", + }; + // https://www.duo.uio.no/handle/10852/8893 + let berg = { + itemType: "thesis", + title: "High Precision Text Extraction from PDF Documents", + creators: [ + { creatorType: "author", firstName: "Øyvind Raddum", lastName: "Berg" }, + ], + date: "2011", + university: "University of Oslo", + }; + // https://aclanthology.org/2020.lrec-1.494/ + let ccnet = { + itemType: "conferencePaper", + title: "CCNet: Extracting High Quality Monolingual Datasets from Web Crawl Data", + creators: [ + { creatorType: "author", firstName: "Guillaume", lastName: "Wenzek" }, + { creatorType: "author", firstName: "Marie-Anne", lastName: "Lachaux" }, + { creatorType: "author", firstName: "Alexis", lastName: "Conneau" }, + { creatorType: "author", firstName: "Vishrav", lastName: "Chaudhary" }, + { creatorType: "author", firstName: "Francisco", lastName: "Guzmán" }, + { creatorType: "author", firstName: "Armand", lastName: "Joulin" }, + { creatorType: "author", firstName: "Edouard", lastName: "Grave" }, + ], + date: "2020", + proceedingsTitle: "Proceedings of the Twelfth Language Resources and Evaluation Conference", + pages: "4003-4012", + }; + // https://proceedings.neurips.cc/paper/2020/hash/1457c0d6bfcb4967418bfb8ac142f64a-Abstract.html + let brown = { + itemType: "conferencePaper", + title: "Language Models are Few-Shot Learners", + creators: [ + { creatorType: "author", firstName: "Tom B.", lastName: "Brown" }, + { creatorType: "author", firstName: "Benjamin", lastName: "Mann" }, + { creatorType: "author", firstName: "Nick", lastName: "Ryder" }, + ], + date: "2020", + proceedingsTitle: "Advances in Neural Information Processing Systems", + volume: "33", + pages: "1877-1901", + }; + // https://proceedings.neurips.cc/paper/1994/hash/3cef96dcc9b8035d23f69e30bb19218a-Abstract.html + let smyth = { + itemType: "conferencePaper", + title: "Inferring Ground Truth from Subjective Labelling of Venus Images", + creators: [ + { creatorType: "author", firstName: "Padhraic", lastName: "Smyth" }, + { creatorType: "author", firstName: "Usama M.", lastName: "Fayyad" }, + { creatorType: "author", firstName: "Michael C.", lastName: "Burl" }, + { creatorType: "author", firstName: "Pietro", lastName: "Perona" }, + { creatorType: "author", firstName: "Pierre", lastName: "Baldi" }, + ], + date: "1994", + proceedingsTitle: "Advances in Neural Information Processing Systems", + volume: "7", + pages: "1085-1092", + DOI: "10.5555/2998687.2998822", + }; + // https://rss.onlinelibrary.wiley.com/doi/10.2307/2346806 + let dawid = { + itemType: "journalArticle", + title: "Maximum Likelihood Estimation of Observer Error-Rates Using the EM Algorithm", + creators: [ + { creatorType: "author", firstName: "A. Philip", lastName: "Dawid" }, + { creatorType: "author", firstName: "Allan M.", lastName: "Skene" }, + ], + date: "1979", + publicationTitle: "Journal of the Royal Statistical Society. Series C (Applied Statistics)", + volume: "28", + issue: "1", + pages: "20-28", + DOI: "10.2307/2346806", + }; + // https://www.nature.com/articles/nature11148 + let cardinale = { + itemType: "journalArticle", + title: "Biodiversity Loss and Its Impact on Humanity", + creators: [ + { creatorType: "author", firstName: "Bradley J.", lastName: "Cardinale" }, + { creatorType: "author", firstName: "J. Emmett", lastName: "Duffy" }, + { creatorType: "author", firstName: "Andrew", lastName: "Gonzalez" }, + { creatorType: "author", firstName: "David U.", lastName: "Hooper" }, + { creatorType: "author", firstName: "Charles", lastName: "Perrings" }, + { creatorType: "author", firstName: "Patrick", lastName: "Venail" }, + ], + date: "2012", + publicationTitle: "Nature", + volume: "486", + pages: "59-67", + DOI: "10.1038/nature11148", + extra: "PMID: 22678280", + }; + // https://www.nature.com/articles/nature05202 + let trophic = { + itemType: "journalArticle", + title: "Effects of Biodiversity on the Functioning of Trophic Groups and Ecosystems", + creators: [ + { creatorType: "author", firstName: "Bradley J.", lastName: "Cardinale" }, + { creatorType: "author", firstName: "Diane S.", lastName: "Srivastava" }, + { creatorType: "author", firstName: "J. Emmett", lastName: "Duffy" }, + ], + date: "2006", + publicationTitle: "Nature", + volume: "443", + pages: "989-992", + DOI: "10.1038/nature05202", + extra: "PMID: 17066035", + }; + // https://pmc.ncbi.nlm.nih.gov/articles/PMC10749071/ + let webpage = { + itemType: "webpage", + title: "What is decentralisation?", + websiteTitle: "Center for International Earth Science Information Network", + }; + // https://revistas.usp.br/revusp/article/view/13868 + let packer = { + itemType: "journalArticle", + title: "Os periódicos brasileiros e a comunicação da pesquisa nacional", + creators: [ + { creatorType: "author", firstName: "Abel L.", lastName: "Packer" }, + ], + date: "2011", + publicationTitle: "Revista USP", + issue: "89", + pages: "26-61", + DOI: "10.11606/issn.2316-9036.v0i89p26-61", + }; + // https://www.repository.cam.ac.uk/items/dc4c1be0-9eab-474d-bc7c-0b9f4df615ca + let bordg = { + itemType: "journalArticle", + title: "Simple Type Theory is Not Too Simple: Grothendieck’s Schemes Without Dependent Types", + creators: [ + { creatorType: "author", firstName: "Anthony", lastName: "Bordg" }, + { creatorType: "author", firstName: "Lawrence", lastName: "Paulson" }, + { creatorType: "author", firstName: "Wenda", lastName: "Li" }, + ], + date: "2022", + publicationTitle: "Experimental Mathematics", + volume: "31", + issue: "2", + pages: "364-382", + DOI: "10.1080/10586458.2022.2062073", + }; + // https://academic.oup.com/mnras/article/482/4/4570/5162857 + let dr2 = { + itemType: "journalArticle", + title: "A Gaia Data Release 2 Catalogue of White Dwarfs and a Comparison with SDSS", + creators: [ + { creatorType: "author", firstName: "Nicola Pietro", lastName: "Gentile Fusillo" }, + { creatorType: "author", firstName: "Pier-Emmanuel", lastName: "Tremblay" }, + { creatorType: "author", firstName: "Boris T.", lastName: "Gänsicke" }, + ], + date: "2019", + publicationTitle: "Monthly Notices of the Royal Astronomical Society", + volume: "482", + issue: "4", + pages: "4570-4591", + DOI: "10.1093/mnras/sty3016", + }; + // https://academic.oup.com/mnras/article/508/3/3877/6373953 + let edr3 = { + itemType: "journalArticle", + title: "A Catalogue of White Dwarfs in Gaia EDR3", + creators: [ + { creatorType: "author", firstName: "Nicola Pietro", lastName: "Gentile Fusillo" }, + { creatorType: "author", firstName: "Pier-Emmanuel", lastName: "Tremblay" }, + { creatorType: "author", firstName: "Boris T.", lastName: "Gänsicke" }, + ], + date: "2021", + publicationTitle: "Monthly Notices of the Royal Astronomical Society", + volume: "508", + issue: "3", + pages: "3877-3896", + DOI: "10.1093/mnras/stab2672", + }; + // https://www.aanda.org/articles/aa/abs/2023/06/aa43709-22/aa43709-22.html + let gaia = { + itemType: "journalArticle", + title: "Gaia Data Release 3: The Galaxy in Your Preferred Colours: Synthetic Photometry from Gaia Low-Resolution Spectra", + creators: [ + { creatorType: "author", name: "Gaia Collaboration" }, + { creatorType: "author", firstName: "C.", lastName: "Jordi" }, + { creatorType: "author", firstName: "P.", lastName: "Montegriffo" }, + ], + date: "2023", + publicationTitle: "Astronomy & Astrophysics", + volume: "674", + pages: "A33", + DOI: "10.1051/0004-6361/202243709", + }; + + let cases = [ + // https://aclanthology.org/W12-3213/ (CC BY-NC-SA 3.0). + { + name: "acl-bird-long-authors-library", + parts: [ + { text: "Steven Bird, Robert Dale, Bonnie J. Dorr, Bryan Gibson, Mark T. Joseph, Min yen Kan, Dongwon Lee, Brett Powley, Dragomir R. Radev, and Yee Fan Tan. 2008. The acl anthology reference corpus: A reference dataset for bibliographic research in computational linguistics." }, + ], + items: [bird, berg, ccnet], + library: true, + match: bird, + }, + { + // Berg is the intended work; the damaged given name currently prevents a safe match. + name: "acl-berg-damaged-initial", + parts: [ + { text: "φyvind Raddum Berg. 2011. High precision text extraction from PDF documents." }, + ], + items: [berg, bird], + reason: "insufficient-evidence", + }, + // https://aclanthology.org/2024.acl-long.192/ (CC BY 4.0). + { + // CCNet is the intended work; its extracted title is too short to verify. + name: "acl-ccnet-truncated-title-library", + parts: [ + { text: "Guillaume Wenzek, Marie-Anne Lachaux, Alexis Conneau, Vishrav Chaudhary, Francisco Guzmán, Armand Joulin, and Edouard Grave. 2020.C" }, + { text: "CNet:", attributes: { url: "https://www.aclweb.org/anthology/2020.lrec-1.494" } }, + ], + items: [ccnet, brown, bird], + library: true, + }, + { + name: "acl-body-node-not-reference", + parts: [ + { text: "ing and generation (Brown et al.,2020;Wei et al., 2022;Shi et al.,2022). Much research has been conducted to understand in-context learning. Some suggest that the models secretly perform gradient descent on the exemplars (Dai et al.,2022), while others demonstrate that most of the knowledge is learned during pre-training, and the exemplars are only to provide evidence for the model to locate the intended task via a Bayesian inference process (Xie et al.,2021;Min et al.,2022;Zhou et al.,2023)." }, + ], + items: [brown, ccnet], + reason: "insufficient-evidence", + }, + // https://jmlr.org/papers/v25/19-301.html (CC BY 4.0). + { + name: "jmlr-smyth-provider-replay", + parts: [ + { text: "Padhraic Smyth, Usama Fayyad, Michael Burl, Pietro Perona, and Pierre Baldi. Inferring ground truth from subjective labelling of Venus images. In Neural Information Processing Systems (NeurIPS), pages 1085–1092. MIT Press, January 1994. URL https://dl.acm. " }, + { text: "org/doi/10.5555/2998687.2998822", attributes: { url: "https://dl.acm.org/doi/10.5555/2998687.2998822" } }, + { text: "." }, + ], + items: [dawid, smyth, bird], + match: smyth, + }, + { + name: "jmlr-dawid-malformed-canonical-links", + parts: [ + { text: "A", attributes: { url: "https://doi.org/10.1002%2F047174882X.A" } }, + { text: ". Philip Dawid and Allan M. Skene. Maximum likelihood estimation of observer errorrates using the EM algorithm. Journal of the Royal Statistical Society. Series C (Applied Statistics), 28(1):20–28, 1979. doi: " }, + { text: "10.2307/2346806.", attributes: { url: "https://doi.org/10.2307%2F2346806.Arthur" } }, + ], + items: [dawid, smyth], + library: true, + match: dawid, + }, + // https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0236550 (CC BY 4.0). + { + name: "plos-cardinale-trophic-library", + parts: [ + { text: "5. Cardinale BJ, Srivastava DS, Duffy JE, Wright JP, Downing AL, Sankaran M, et al. Effects of biodiversity on the functioning of trophic groups and ecosystems. Nature. 2006; 443: 989–992. " }, + { text: "https://doi.org/", attributes: { url: "https://doi.org/" } }, + { text: " " }, + { text: "10.1038/nature05202", attributes: { url: "https://doi.org/10.1038%2Fnature05202" } }, + { text: " PMID: " }, + { text: "17066035", attributes: { url: "http://www.ncbi.nlm.nih.gov/pubmed/17066035" } }, + ], + items: [trophic, cardinale], + library: true, + match: trophic, + }, + { + name: "plos-cardinale-doi-pmid-library", + parts: [ + { text: "7. Cardinale BJ, Duffy JE, Gonzalez A, Hooper DU, Perrings C, Venail P, et al. Biodiversity loss and its impact on humanity. Nature. 2012; 486: 59–67. " }, + { text: "https://doi.org/10.1038/nature11148", attributes: { url: "https://doi.org/10.1038/nature11148" } }, + { text: " PMID: " }, + { text: "22678280", attributes: { url: "http://www.ncbi.nlm.nih.gov/pubmed/22678280" } }, + ], + items: [cardinale, trophic], + library: true, + match: cardinale, + }, + // https://europepmc.org/articles/PMC10749071 (CC BY 4.0). + { + // The decentralisation webpage is the intended work, but has insufficient metadata. + name: "pmc-webpage-omitted-fields-library", + parts: [ + { text: "3 What is decentralisation?, Available: " }, + { text: "http://www.ciesin.org/", attributes: { url: "http://www.ciesin.org/" } }, + { text: " decentralization/English/General/Different_forms.html [Accessed 20 Jun 2023]." }, + ], + items: [webpage, brown], + library: true, + }, + // https://www.scielo.br/j/tinf/a/ytxYxh6Mbb4qq76FGJVsDbs/?lang=pt (CC BY 4.0). + { + name: "scielo-packer-diacritics-library", + parts: [ + { text: "PACKER, A. L. Os periódicos brasileiros e a comunicação da pesquisa nacional. Revista USP, n. 89, p. 26-61, 2011. Disponível em: <" }, + { text: "http://rusp.scielo.br/scielo.php?script=sci_arttext&", attributes: { url: "http://rusp.scielo.br/scielo.php?script=sci_arttext&" } }, + { text: " pid=S0103-99892011000200004&lng=pt&nrm=iso>. Acesso em: 25 abr. 2016." }, + ], + items: [packer, bird], + library: true, + match: packer, + }, + // https://drops.dagstuhl.de/entities/document/10.4230/LIPIcs.ITP.2023.35 (CC BY 4.0). + { + name: "lipics-bordg-doi-library", + parts: [ + { text: "1 Anthony Bordg, Lawrence Paulson, and Wenda Li. Simple type theory is not too simple: Grothendieck’s schemes without dependent types. Experimental Mathematics, 31(2):364–382, 2022. " }, + { text: "doi:10.1080/10586458.2022.2062073", attributes: { url: "https://doi.org/10.1080/10586458.2022.2062073" } }, + { text: "." }, + ], + items: [bordg, dawid], + library: true, + match: bordg, + }, + // https://discovery.ucl.ac.uk/id/eprint/10173976/ (CC BY 4.0). + { + // DR2 is the intended work, not EDR3 or the linked Gaia paper; its visible title is missing. + name: "aanda-gentile-publisher-link-library", + parts: [ + { text: "Gentile Fusillo, N. P., Tremblay, P.-E., Gänsicke, B. T., et al. 2019,MNRAS, 482, " }, + { text: "4570", attributes: { url: "http://linker.aanda.org/10.1051/0004-6361/202243709/74" } }, + ], + items: [dr2, edr3, gaia], + library: true, + }, + ]; + let network; + + beforeEach(function () { + network = [ + ...['request', 'doGet', 'doPost'].map(method => observations.stub(Zotero.HTTP, method)), + observations.stub(Zotero.Translate.Search.prototype, 'translate'), + observations.stub(onlineReferenceLookup, 'findOnline'), + ]; + for (let call of network) call.throws(new Error('Local matching must not use the network')); + }); + + for (let testCase of cases) { + it(testCase.name, async function () { + let group; + try { + if (testCase.library) group = await createGroup(); + let candidates = [], expected; + for (let data of testCase.items) { + let item = new Zotero.Item(data.itemType); + if (group) item.libraryID = group.libraryID; + item.fromJSON(JSON.parse(JSON.stringify(data))); + if (group) await item.saveTx(); + candidates.push(item); + if (data === testCase.match) expected = item; + } + let result = group + ? await BibliographyMatcher.matchLibrary(testCase.parts, group.libraryID) + : BibliographyMatcher.matchCandidates(testCase.parts, candidates); + assert.equal(result.status, testCase.match ? 'matched' : 'unmatched'); + if (testCase.match) assert.strictEqual(result.item, expected); + else { + assert.notProperty(result, 'item'); + if (testCase.reason) assert.equal(result.reason, testCase.reason); + } + assert.isTrue(network.every(call => !call.called)); + } + finally { + if (group) await group.eraseTx(); + } + }); + } + }); + + async function retrieveISBN(reference, libraryID = Zotero.Libraries.userLibraryID) { + return BibliographyMatcher._searchByIdentifiers( + BibliographyMatcher.prepareReference(reference).identifiers, libraryID); + } + + function article(overrides = {}) { + return { + itemType: 'journalArticle', + title: 'Reliable matching across diverse bibliographies', + creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + ], + date: '2021', + publicationTitle: 'Journal of Bibliographic Systems', + volume: '12', + issue: '3', + pages: '120-139', + ...overrides, + }; + } + + // References 11 and 19 in https://doi.org/10.1145/1542476.1542528. + // Crossref returns each paper alongside a creator-less proceedings record. + for (let { title, creators, date, pages, ordinal, DOI, volumeDOI, reference } of [ + { + title: 'HotpathVM: an effective JIT compiler for resource-constrained devices', + creators: [['Andreas', 'Gal'], ['Christian W.', 'Probst'], ['Michael', 'Franz']], + date: '2006-06-14', + pages: '144-153', + ordinal: '2nd', + DOI: '10.1145/1134760.1134780', + volumeDOI: '10.1145/1134760', + reference: '[11] A. Gal, C. W. Probst, and M. Franz. HotpathVM: An effective JIT compiler for resource-constrained devices. ' + + 'In Proceedings of the International Conference on Virtual Execution Environments, pages 144–153. ACM Press, 2006.', + }, + { + title: 'YETI: a graduallY extensible trace interpreter', + creators: [['Mathew', 'Zaleski'], ['Angela Demke', 'Brown'], ['Kevin', 'Stoodley']], + date: '2007-06-13', + pages: '83-93', + ordinal: '3rd', + DOI: '10.1145/1254810.1254823', + volumeDOI: '10.1145/1254810', + reference: '[19] M. Zaleski, A. D. Brown, and K. Stoodley. YETI: A graduallY Extensible Trace Interpreter. ' + + 'In Proceedings of the International Conference on Virtual Execution Environments, pages 83–93. ACM Press, 2007.', + }, + ]) { + it(`should match ${title} rather than its weak proceedings record`, async function () { + let proceedingsTitle = `Proceedings of the ${ordinal} international conference on Virtual execution environments`; + let paper = await createItem({ itemType: 'conferencePaper', title, proceedingsTitle, date, pages, DOI, + creators: creators.map(([firstName, lastName]) => ({ creatorType: 'author', firstName, lastName })) }); + let volume = await createItem({ itemType: 'conferencePaper', title: proceedingsTitle, date, DOI: volumeDOI }); + for (let candidates of [[paper, volume], [volume, paper]]) { + assert.strictEqual(evaluate(reference, candidates).item, paper); + assert.equal(evaluate(reference, candidates.map(item => item.toJSON())).item.getField('DOI'), DOI); + } + assert.strictEqual((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).item, paper); + }); + } + + it("should preserve strict rivals, identifier authority and conflicts beside a weak container", async function () { + let paper = await createItem(article({ DOI: '10.1234/paper' })); + let volume = await createItem({ itemType: 'book', title: paper.getField('publicationTitle'), date: '2021', DOI: '10.1234/volume' }); + let reference = `Lovelace, Ada. 2021. ${paper.getField('title')}. ${volume.getField('title')}. pp. 120-139.`; + assert.strictEqual(evaluate(reference, [volume, paper]).item, paper); + assert.strictEqual((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).item, paper); + let rival = await createItem(article({ publicationTitle: 'Collected Research on Bibliographic Methods', DOI: '10.1234/rival', + creators: [{ creatorType: 'author', firstName: 'Ada Augusta', lastName: 'Lovelace' }] })); + assert.equal(evaluate(reference, [volume, paper, rival]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).reason, 'ambiguous'); + await rival.eraseTx(); + for (let text of [reference.replace('2021', '2020'), reference.replace('120-139', '200-219')]) { + assert.equal(evaluate(text, [paper, volume]).status, 'unmatched'); + assert.equal((await BibliographyMatcher.matchLibrary(text, paper.libraryID)).status, 'unmatched'); + } + let identified = reference + ' doi:10.1234/volume'; + assert.strictEqual(evaluate(identified, [paper, volume]).item, volume); + assert.strictEqual((await BibliographyMatcher.matchLibrary(identified, paper.libraryID)).item, volume); + volume.setCreators(paper.getCreators()); + await volume.saveTx(); + assert.equal(evaluate(reference, [paper, volume]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).reason, 'ambiguous'); + }); + + // Alternate conference/journal records returned for references 7, 9 and 15 + // in https://doi.org/10.1145/1542476.1542528. + for (let { title, creators, year, dates, pages, conferenceDOI, journalDOI, reference } of [ + { + title: 'Dynamo: a transparent dynamic optimization system', + creators: [['Vasanth', 'Bala'], ['Evelyn', 'Duesterwald'], ['Sanjeev', 'Banerjia']], + year: '2000', + dates: ['05/2000', '05/2000'], + pages: '1-12', + conferenceDOI: '10.1145/349299.349303', + journalDOI: '10.1145/358438.349303', + reference: '[7] V. Bala, E. Duesterwald, and S. Banerjia. Dynamo: A transparent dynamic optimization system. ' + + 'In Proceedings of the ACM SIGPLAN Conference on Programming Language Design and Implementation, pages 1–12. ACM Press, 2000.', + }, + { + title: 'Customization: optimizing compiler technology for SELF, a dynamically-typed object-oriented programming language', + creators: [['C.', 'Chambers'], ['D.', 'Ungar']], + year: '1989', + dates: ['1989-06-21', '07/1989'], + pages: '146-160', + conferenceDOI: '10.1145/73141.74831', + journalDOI: '10.1145/74818.74831', + reference: '[9] C. Chambers and D. Ungar. Customization: Optimizing Compiler Technology for SELF, a Dynamically-Typed O bject-Oriented Programming Language. ' + + 'In Proceedings of the ACM SIGPLAN 1989 Conference on Programming Language Design and Implementation, pages 146–160. ACM New York, NY, USA, 1989.', + }, + { + title: 'Optimizing direct threaded code by selective inlining', + creators: [['Ian', 'Piumarta'], ['Fabio', 'Riccardi']], + year: '1998', + dates: ['05/1998', '05/1998'], + pages: '291-300', + conferenceDOI: '10.1145/277650.277743', + journalDOI: '10.1145/277652.277743', + reference: '[15] I. Piumarta and F. Riccardi. Optimizing direct threaded code by selective inlining. ' + + 'In Proceedings of the ACM SIGPLAN 1998 conference on Programming language design and implementation, pages 291300. ACM New York, NY, USA, 1998.', + }, + ]) { + it(`should use the first equivalent online publication record for ${title}`, async function () { + let data = { title, pages, + creators: creators.map(([firstName, lastName]) => ({ creatorType: 'author', firstName, lastName })) }; + let conference = await createItem({ ...data, itemType: 'conferencePaper', date: dates[0], DOI: conferenceDOI, + proceedingsTitle: `Proceedings of the ACM SIGPLAN ${year} conference on Programming language design and implementation` }); + let journal = await createItem({ ...data, itemType: 'journalArticle', date: dates[1], DOI: journalDOI, publicationTitle: 'ACM SIGPLAN Notices' }); + for (let candidates of [[conference, journal], [journal, conference]]) { + assert.strictEqual(evaluate(reference, candidates).item, candidates[0]); + assert.equal(evaluate(reference, candidates.map(item => item.toJSON())).item.getField('DOI'), candidates[0].getField('DOI')); + } + assert.equal((await BibliographyMatcher.matchLibrary(reference, conference.libraryID)).reason, 'ambiguous'); + let identified = reference + ' doi:' + journalDOI; + assert.strictEqual(evaluate(identified, [conference, journal]).item, journal); + }); + } + + it("should not use provider order to resolve different or insufficiently described works", function () { + let reference = `Lovelace, A. 2020/2021. ${article().title}.`; + for (let change of [ + { creators: [{ creatorType: 'author', firstName: 'Augusta', lastName: 'Lovelace' }] }, + { date: '2020' }, + { date: '2020/2021' }, + { pages: '120-140' }, + { pages: '' }, + ]) { + let candidates = [article(), article({ publicationTitle: 'Another publication', ...change })]; + for (let order of [candidates, [...candidates].reverse()]) { + assert.equal(evaluate(reference, order).reason, 'ambiguous', JSON.stringify(change)); + } + } + let candidates = [article(), article({ extra: 'x'.repeat(BibliographyMatcher.MAX_MATCH_TEXT_LENGTH + 1) })]; + assert.equal(evaluate(reference, candidates).status, 'incomplete'); + }); + + it("should require the whole container title at the same span after the cited work", async function () { + let container = 'Proceedings of the international symposium on reliable computational methods for scientific bibliographic matching and reproducible evidence evaluation'; + let paper = await createItem(article({ publicationTitle: container })); + let volume = await createItem({ itemType: 'book', title: container, date: '2021' }); + let title = paper.getField('title'); + for (let reference of [ + `${container}. Lovelace, Ada. 2021. ${title}. pp. 120-139.`, + `Lovelace, Ada. 2021. ${container}. Includes: Lovelace, Ada. ${title}.`, + ]) { + assert.equal(evaluate(reference, [paper, volume]).status, 'unmatched'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).status, 'unmatched'); + } + assert.strictEqual(evaluate(`${container}. Lovelace, Ada. 2021. ${title}.`, [paper]).item, paper); + volume.setField('title', container + ' archives'); + await volume.saveTx(); + let reference = `Lovelace, Ada. 2021. ${title}. ${container}.`; + assert.equal(evaluate(reference, [paper, volume]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).reason, 'ambiguous'); + paper.setField('title', 'Methods for robust approaches to bibliographic matching'); + paper.setField('publicationTitle', 'Robust approaches to bibliographic matching'); + volume.setField('title', paper.getField('publicationTitle')); + await paper.saveTx(); + await volume.saveTx(); + reference = `Lovelace, Ada. 2021. ${paper.getField('title')}.`; + assert.equal(evaluate(reference, [paper, volume]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).reason, 'ambiguous'); + }); + + it("should retain the completeness veto for an excluded containing record", async function () { + let paper = await createItem(article()); + let volume = await createItem({ itemType: 'book', title: paper.getField('publicationTitle'), date: '2021', + extra: 'x'.repeat(BibliographyMatcher.MAX_MATCH_TEXT_LENGTH + 1) }); + let reference = `Lovelace, Ada. 2021. ${paper.getField('title')}. ${volume.getField('title')}.`; + assert.equal(evaluate(reference, [paper, volume]).status, 'incomplete'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, paper.libraryID)).status, 'incomplete'); + }); + + it("should validate visible text without doing citation analysis", function () { + let matcher = BibliographyMatcher; + let text = 'Visible reference. DOI: 10.1234/example'; + let parts = [{ text: 'Visible reference. ' }, { text: 'DOI: 10.1234/example', + attributes: { url: 'https://doi.org/10.1234/unrelated' } }]; + let identifiers = observations.spy(matcher, 'extractIdentifiers'); + for (let input of [text, parts, { referenceParts: parts }, { textParts: parts }]) { + assert.strictEqual(matcher.getReferenceText(input), text); + } + assert.isFalse(identifiers.called); + for (let input of ['', + ' \n ', + null, + [], + [{ text: 42 }], + 'x'.repeat(matcher.MAX_MATCH_TEXT_LENGTH + 1), + Array(matcher.MAX_REFERENCE_PARTS + 1).fill({ text: 'x' })]) { + assert.isNull(matcher.getReferenceText(input)); + assert.isNull(matcher.prepareReference(input)); + } + let parsed = matcher.prepareReference(parts); + assert.equal(parsed.text, text); + assert.deepEqual([...parsed.identifiers.DOI], ['10.1234/example']); + }); + + it("should match an exact visible identifier without requiring stored metadata fields", function () { + let result = evaluate('doi: 10.1234/General.42', [article({ + title: '', + creators: [], + date: '', + DOI: '10.1234/general.42', + })]); + + assert.equal(result.status, 'matched'); + }); + + it("should extract both DOI and explicitly labelled PMID from the real PLOS reference", function () { + let parsed = BibliographyMatcher.prepareReference({ + referenceParts: [ + { + text: '7. Cardinale BJ, Duffy JE, Gonzalez A, Hooper DU, Perrings C, ' + + 'Venail P, et al. Biodiversity loss and its impact on humanity. ' + + 'Nature. 2012; 486: 59–67. ', + }, + { + text: 'https://doi.org/10.1038/nature11148', + attributes: { url: 'https://doi.org/10.1038/nature11148' }, + }, + { text: ' PMID: ' }, + { + text: '22678280', + attributes: { url: 'http://www.ncbi.nlm.nih.gov/pubmed/22678280' }, + }, + ], + }); + + assert.deepEqual([...parsed.identifiers.DOI], ['10.1038/nature11148']); + assert.deepEqual([...parsed.identifiers.PMID], ['22678280']); + }); + + it("should extract only explicitly labelled PMID values", function () { + let identifiers = BibliographyMatcher.extractIdentifiers( + '7 Cardinale et al. 2012, volume 486, pages 59, 67, PMID: 22678280' + ); + + assert.deepEqual([...identifiers.PMID], ['22678280']); + }); + + it("should normalize repeated explicitly labelled PMIDs", function () { + let identifiers = BibliographyMatcher.extractIdentifiers( + 'PMID: 22678280, PMID 22678280; pmid:22678280; PMID22678280' + ); + + assert.deepEqual([...identifiers.PMID], ['22678280']); + }); + + it("should not extract unlabeled, PMCID, or PubMed URL numbers as PMID", function () { + for (let text of [ + '7 2012, volume 486, pages 59, 67, 22678280', + 'PMCID: PMC1234567; https://pubmed.ncbi.nlm.nih.gov/22678280/', + ]) { + let identifiers = BibliographyMatcher.extractIdentifiers(text); + assert.deepEqual([...identifiers.PMID], [], text); + } + }); + + it("should preserve utility extraction for non-PMID identifier types", function () { + let cases = [ + ['DOI: 10.1234/General.42', 'DOI', '10.1234/general.42'], + ['ISBN 978-0-306-40615-7', 'ISBN', '9780306406157'], + ['arXiv:0706.0044v1', 'arXiv', '0706.0044'], + ['2021wfc..rept....8D', 'adsBibcode', '2021wfc..rept....8D'], + ]; + for (let [text, type, expected] of cases) { + let identifiers = BibliographyMatcher.extractIdentifiers(text); + assert.include([...identifiers[type]], expected, type); + assert.deepEqual([...identifiers.PMID], [], type); + } + }); + + it("should parse only populated item identifier fields", function () { + let extract = observations.spy(BibliographyMatcher, 'extractIdentifiers'); + let identifiers = BibliographyMatcher.extractItemIdentifiers({ ISBN: '0-306-40615-2' }); + assert.isTrue(extract.calledOnceWithExactly('ISBN: 0-306-40615-2')); + assert.deepEqual([...identifiers.ISBN], ['9780306406157']); + extract.resetHistory(); + BibliographyMatcher.extractItemIdentifiers({ DOI: '', ISBN: null, PMID: false, extra: '\n\r\n', url: '' }); + assert.isFalse(extract.called); + }); + + it("should retain explicit arXiv evidence beside a DOI without inferring it from DOI digits", function () { + for (let visible of ['arXiv:2101.12345v2', 'https://arxiv.org/abs/2101.12345v2']) { + let reference = `doi:10.1234/example ${visible}`; + let identifiers = BibliographyMatcher.extractIdentifiers(reference); + assert.deepEqual([...identifiers.arXiv], ['2101.12345']); + assert.equal(evaluate(reference, [article({ DOI: '10.1234/example', extra: 'arXiv:2101.12345' })]).status, + 'matched'); + assert.equal(evaluate(reference, [article({ DOI: '10.1234/example', extra: 'arXiv:2101.99999' })]).status, + 'unmatched'); + } + assert.equal(BibliographyMatcher.extractIdentifiers('doi:10.1234/2101.12345').arXiv.size, 0); + }); + + it("should preserve independent identifier fields and lines when skipping empty values", function () { + let identifiers = BibliographyMatcher.extractItemIdentifiers({ + DOI: 'https://doi.org/10.1234/General.42', + ISBN: '0-306-40615-2', + PMID: '22678280', + extra: '\narXiv:0706.0044v1\r\n\r\nPMID: 22678281\n', + url: 'https://doi.org/10.1234/other', + }); + assert.sameMembers([...identifiers.DOI], ['10.1234/general.42', '10.1234/other']); + assert.deepEqual([...identifiers.ISBN], ['9780306406157']); + assert.sameMembers([...identifiers.PMID], ['22678280', '22678281']); + assert.deepEqual([...identifiers.arXiv], ['0706.0044']); + }); + + it("should reject a candidate with an explicitly conflicting identifier", function () { + let candidate = article({ DOI: '10.1234/different' }); + let reference = 'Lovelace, Ada. 2021. Reliable matching across diverse bibliographies.'; + assert.equal(evaluate(reference, [candidate]).status, 'matched'); + + let result = evaluate(reference + ' doi:10.1234/expected', [candidate]); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + }); + + it("should use uniform title, creator, and year evidence", function () { + let result = evaluate( + 'Lovelace, Ada. (2021). Reliable matching across diverse bibliographies.', + [article()] + ); + + assert.equal(result.status, 'matched'); + }); + + it("should accept Unicode normalization, case transformations, and punctuation changes", function () { + let candidate = article({ + title: 'Étude: Über reliable systems', + creators: [{ creatorType: 'author', firstName: 'Zoë', lastName: 'Ångström' }], + }); + let result = evaluate( + 'ANGSTROM, Z. 2021. ETUDE — UBER RELIABLE SYSTEMS.', + [candidate] + ); + + assert.equal(result.status, 'matched'); + }); + + it("should share search normalization for non-decomposing Latin letters", function () { + let positive = evaluate( + 'Bordg, Bolette. 2021. Lodz, Tromso and Dorde in bibliographic systems.', + [article({ + title: 'Łódź, Tromsø and Đorđe in bibliographic systems', + creators: [{ creatorType: 'author', firstName: 'Bolette', lastName: 'Børdg' }], + })] + ); + let negative = evaluate( + 'Bordg, Bolette. 2021. Lodaz, Tromso and Dorde in bibliographic systems.', + [article({ + title: 'Łódź, Tromsø and Đorđe in bibliographic systems', + creators: [{ creatorType: 'author', firstName: 'Bolette', lastName: 'Børdg' }], + })] + ); + + assert.equal(positive.status, 'matched'); + assert.equal(negative.status, 'unmatched'); + }); + + it("should align missing and pairwise-merged title tokens only within a bounded span", function () { + let candidate = article({ + title: 'Reliable local matching across diverse bibliographies', + }); + let missing = evaluate( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies. ' + + 'Journal of Bibliographic Systems, pp. 120-139.', + [candidate] + ); + let merged = evaluate( + 'Lovelace, A. 2021. Reliable local matching across diversebibliographies. ' + + 'Journal of Bibliographic Systems, pp. 120-139.', + [candidate] + ); + let separated = evaluate( + 'Lovelace, A. 2021. Reliable local. ' + + 'one two three four five six seven eight nine ten eleven twelve ' + + 'matching across diverse bibliographies.', + [candidate] + ); + + assert.equal(missing.status, 'unmatched'); + assert.equal(merged.status, 'matched'); + assert.equal(separated.status, 'unmatched'); + }); + + it("should retain approximate automatic matches and competition for long titles", function () { + let title = 'Reliable matching across diverse bibliographies with efficient retrieval of existing research ' + + 'records from large scientific libraries and digital scholarly collections'; + let shortened = title.replace('efficient ', ''); + let candidate = article({ title }); + let reference = `Lovelace, Ada. 2021. ${shortened}.`; + let unique = evaluate(reference, [candidate]); + assert.equal(unique.status, 'matched'); + + assert.equal(evaluate(reference, [candidate, article({ title: shortened })]).status, 'unmatched'); + }); + + it("should retain near-title words at either boundary in local and online matching", async function () { + for (let title of [ + 'Behavior patterns in scholarly digital libraries and their implications for reliable citation matching', + 'Behavior patterns in large scholarly digital libraries and archives with implications for reliable bibliographic citation matching', + 'Reliable citation matching for scholarly digital libraries and comprehensive studies of human behavior', + 'Reliable bibliographic citation matching for large scholarly digital libraries and comprehensive longitudinal studies of human behavior', + ]) { + let candidate = await createItem(article({ title })); + let reference = `Lovelace, Ada. 2021. ${title.replace(/behavior/i, 'behaviour')}.`; + assert.equal(evaluate(reference, [candidate]).item?.id, candidate.id, title); + assert.equal((await BibliographyMatcher.matchLibrary(reference, candidate.libraryID)).item?.id, candidate.id, title); + await candidate.eraseTx(); + } + }); + + it("should recognize normalized double quotes as title boundaries without relying on container metadata", async function () { + let candidate = await createItem(article({ publicationTitle: '' })); + for (let [open, close] of [['“', '”'], ['"', '"']]) { + let reference = `Lovelace, Ada. 2021. ${open}${candidate.getField('title')}${close} Uncatalogued Periodical 3.`; + assert.equal(evaluate(reference, [candidate]).item?.id, candidate.id); + assert.equal((await BibliographyMatcher.matchLibrary(reference, candidate.libraryID)).item?.id, candidate.id); + } + }); + + it("should recognize slash-delimited creator and container fields in local and online matching", async function () { + for (let [data, references] of [ + [article({ + title: 'Методы анализа библиографических ссылок', date: '2020', + publicationTitle: 'Вестник Московского университета', creators: [ + { creatorType: 'author', firstName: 'Иван Иванович', lastName: 'Иванов' }, + { creatorType: 'author', firstName: 'Пётр Петрович', lastName: 'Петров' }, + ], + }), [ + 'Иванов, И. И. Методы анализа библиографических ссылок / И. И. Иванов, П. П. Петров ' + + '// Вестник Московского университета. – 2020. – № 1. – С. 10–20.', + 'Иванов И. И., Петров П. П. Методы анализа библиографических ссылок ' + + '// Вестн. Моск. ун-та. 2020. № 1. С. 10–20.', + ]], + [article({ publicationTitle: 'J. Bib. Syst.', creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Grace', lastName: 'Hopper' }, + ] }), [ + 'Lovelace, A. Reliable matching across diverse bibliographies / A. Lovelace, G. Hopper ' + + '// Journal of Bibliographic Systems. – 2021. – Vol. 12. – P. 120–139.', + ]], + ]) { + let candidate = await createItem(data); + for (let reference of references) { + assert.equal(evaluate(reference, [candidate]).item?.id, candidate.id, reference); + assert.equal((await BibliographyMatcher.matchLibrary(reference, candidate.libraryID)).item?.id, candidate.id, reference); + } + await candidate.eraseTx(); + } + }); + + it("should not treat a slash within a longer title as an independent field", async function () { + let candidate = await createItem(article()); + for (let slash of ['/', ' / ']) { + let reference = `Lovelace, Ada. 2021. Reliable matching across diverse bibliographies${slash}catalogues for libraries.`; + assert.equal(evaluate(reference, [candidate]).status, 'unmatched'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, candidate.libraryID)).status, 'unmatched'); + } + }); + + it("should reject title fragments without changing competition or identifier authority", async function () { + for (let [title, citation] of [ + ['Social Network Analysis', 'Teaching social network analysis to sociology undergraduates'], + ['Machine learning for medical imaging', 'Machine learning for medical imaging: a systematic review of deep learning in radiology'], + ]) { + let partial = await createItem(article({ title })); + let reference = `Lovelace, Ada. 2021. ${citation}.`; + assert.equal(evaluate(reference, [partial]).status, 'unmatched', title); + assert.equal((await BibliographyMatcher.matchLibrary(reference, partial.libraryID)).status, 'unmatched', title); + let complete = await createItem(article({ title: citation })); + // The veto must not discard the fragment and promote the other item. + assert.equal(evaluate(reference, [partial, complete]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, partial.libraryID)).reason, 'ambiguous'); + partial.setField('DOI', '10.1234/title-fragment'); + await partial.saveTx(); + let identified = reference + ' doi:10.1234/title-fragment'; + assert.equal(evaluate(identified, [partial, complete]).item.id, partial.id); + assert.equal((await BibliographyMatcher.matchLibrary(identified, partial.libraryID)).item.id, partial.id); + await partial.eraseTx(); + await complete.eraseTx(); + } + }); + + it("should distinguish a chapter's title and creators from its containing book", async function () { + let book = await createItem({ + itemType: 'book', title: 'The Ethics of Belief', date: '2014', publisher: 'Oxford University Press', + creators: [ + { creatorType: 'editor', firstName: 'Jonathan', lastName: 'Matheson' }, + { creatorType: 'editor', firstName: 'Rico', lastName: 'Vitz' }, + ], + }); + let reference = 'Whitcomb, D. (2014). Can There Be a Knowledge-First Ethics of Belief? ' + + 'In: Matheson, J.; Vitz, R. (eds.), The Ethics of Belief. Oxford: Oxford University Press. pp. 93-112.'; + assert.equal(evaluate(reference, [book]).status, 'unmatched'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, book.libraryID)).status, 'unmatched'); + for (let citation of [ + 'Matheson, J.; Vitz, R. (eds.), The Ethics of Belief. Oxford: Oxford University Press. 2014.', + 'The Ethics of Belief (Oxford: Oxford University Press, 2014), Matheson, J.; Vitz, R.', + ]) { + assert.equal(evaluate(citation, [book]).item.id, book.id); + assert.equal((await BibliographyMatcher.matchLibrary(citation, book.libraryID)).item.id, book.id); + } + let chapter = await createItem({ + itemType: 'bookSection', title: 'Can There Be a Knowledge-First Ethics of Belief?', + bookTitle: book.getField('title'), date: '2014', pages: '93-112', + creators: [{ creatorType: 'author', firstName: 'Dennis', lastName: 'Whitcomb' }], + }); + assert.equal(evaluate(reference, [chapter]).item.id, chapter.id); + // Preserve the original competition decision instead of reranking after the veto. + assert.equal(evaluate(reference, [book, chapter]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, book.libraryID)).reason, 'ambiguous'); + for (let data of [{ ...book.toJSON(), date: '' }, { ...book.toJSON(), creators: [] }]) { + book.fromJSON(data); + await book.saveTx(); + assert.strictEqual(evaluate(reference, [book, chapter]).item, chapter); + assert.strictEqual((await BibliographyMatcher.matchLibrary(reference, book.libraryID)).item, chapter); + } + await book.eraseTx(); + assert.equal((await BibliographyMatcher.matchLibrary(reference, chapter.libraryID)).item.id, chapter.id); + }); + + it("should reject a containing book when its editors also wrote the cited chapter", async function () { + let book = await createItem({ + itemType: 'book', title: 'The Ethics of Belief', date: '2014', publisher: 'Oxford University Press', + creators: [ + { creatorType: 'editor', firstName: 'Jonathan', lastName: 'Matheson' }, + { creatorType: 'editor', firstName: 'Rico', lastName: 'Vitz' }, + ], + }); + for (let reference of [ + 'Matheson, J. (2014). Evidence and epistemic duty in recent debates. ' + + 'In J. Matheson & R. Vitz (Eds.), The Ethics of Belief (pp. 11–30). Oxford University Press.', + 'Matheson, J., & Vitz, R. (2014). Introduction. ' + + 'In J. Matheson & R. Vitz (Eds.), The Ethics of Belief (pp. 1–10). Oxford University Press.', + ]) { + assert.equal(evaluate(reference, [book]).status, 'unmatched', reference); + assert.equal((await BibliographyMatcher.matchLibrary(reference, book.libraryID)).status, 'unmatched', reference); + } + for (let reference of [ + 'Matheson, J., & Vitz, R. (Eds.). (2014). The ethics of belief. Oxford University Press.', + 'Matheson, Jonathan, and Rico Vitz, editors. The Ethics of Belief. Oxford UP, 2014.', + 'Jonathan Matheson and Rico Vitz, eds., The Ethics of Belief (Oxford: Oxford University Press, 2014), 12.', + 'The Ethics of Belief (Oxford: Oxford University Press, 2014), Matheson, J.; Vitz, R.', + ]) { + assert.equal(evaluate(reference, [book]).item?.id, book.id, reference); + assert.equal((await BibliographyMatcher.matchLibrary(reference, book.libraryID)).item?.id, book.id, reference); + } + }); + + it("should not automatically match a generic short title using author and year alone", function () { + let result = evaluate('Lovelace, Ada. 2021. Sun.', [article({ title: 'Sun' })]); + assert.equal(result.status, 'unmatched'); + }); + + it("should keep close title alternatives ambiguous even when only one title is exact", function () { + let title = 'The influence of seasonal environmental variation on coastal ecosystems and marine species diversity'; + let reference = `Lovelace, Ada. 2021. ${title}.`; + let result = evaluate(reference, [article({ title }), article({ title: title.replace('coastal', 'freshwater') })]); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + }); + + it("should not stitch title evidence through creator and container text", function () { + let result = evaluate( + 'Reliable Lovelace matching Journal across of diverse Bibliographic ' + + 'bibliographies Systems 2021.', + [article()] + ); + + assert.notEqual(result.status, 'matched'); + }); + + it("should keep near-limit alignment bounded across a full candidate pool", function () { + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + let title = ['anchor'].concat( + Array.from({ length: 63 }, (_, index) => `target${index}`) + ).join(' '); + let reference = Array.from({ length: 128 }, () => 'anchor noise').join(' '); + let candidates = Array.from( + { length: BibliographyMatcher.MAX_CANDIDATES }, + () => article({ title, creators: [], date: '', publicationTitle: '', pages: '' }) + ); + let durations = []; + for (let i = 0; i < 7; i++) { + scoring.resetHistory(); + let started = Date.now(); + evaluate(reference, candidates); + durations.push(Date.now() - started); + assert.equal(scoring.callCount, BibliographyMatcher.MAX_CANDIDATES); + } + assert.isBelow(Math.max(...durations), 1000); + }); + + it("should require token boundaries for exact title containment", function () { + let candidate = article({ title: 'Net theory for reliable bibliographies' }); + let positive = evaluate( + 'Lovelace, Ada. 2021. Net theory for reliable bibliographies.', + [candidate] + ); + let negative = evaluate( + 'Lovelace, Ada. 2021. Internet theory for reliable bibliographies.', + [candidate] + ); + + assert.equal(positive.status, 'matched'); + assert.notEqual(negative.status, 'matched'); + }); + + it("should uniformly align materially different unknown reference layouts", function () { + let cases = [ + { + name: 'creator and year before title', + reference: 'Lovelace, Ada (2021). Reliable matching across diverse bibliographies.', + candidate: article(), + status: 'matched', + }, + { + name: 'title first with legitimately omitted year and given name', + reference: 'Reliable matching across diverse bibliographies. ' + + 'Lovelace. Journal of Bibliographic Systems.', + candidate: article(), + status: 'unmatched', + }, + { + name: 'initial, quoted title, container, pages, then year', + reference: 'A. Lovelace, “Reliable matching across diverse bibliographies,” ' + + 'Journal of Bibliographic Systems, 12(3), pp. 120–139, 2021.', + candidate: article(), + status: 'matched', + }, + { + name: 'compact creator title container date volume and pages', + reference: 'Lovelace A. Reliable matching across diverse bibliographies. ' + + 'Journal of Bibliographic Systems. 2021;12(3):120-139.', + candidate: article(), + status: 'matched', + }, + { + name: 'multiline punctuation noise', + reference: 'Reliable—matching\nacross diverse bibliographies!!!\nLovelace, A. [2021]', + candidate: article(), + status: 'matched', + }, + { + name: 'corporate creator', + reference: 'World Health Organization. 2021. ' + + 'Reliable matching across diverse bibliographies.', + candidate: article({ + creators: [{ + creatorType: 'author', name: 'World Health Organization', fieldMode: 1, + }], + }), + status: 'matched', + }, + { + name: 'non-ASCII names and diacritics', + reference: 'ÅNGSTRÖM, Z. (2021). ÉTUDE — ÜBER RELIABLE SYSTEMS.', + candidate: article({ + title: 'Étude: Über reliable systems', + creators: [{ creatorType: 'author', firstName: 'Zoë', lastName: 'Ångström' }], + }), + status: 'matched', + }, + ]; + + for (let testCase of cases) { + let result = evaluate(testCase.reference, [testCase.candidate]); + assert.equal(result.status, testCase.status, testCase.name); + } + }); + + it("should recognize compatible given-name initials", function () { + let result = evaluate( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.', + [article()] + ); + + assert.equal(result.status, 'matched'); + }); + + it("should match the primary creator group without requiring secondary roles", function () { + let author = { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }; + let other = { firstName: 'Grace', lastName: 'Hopper' }; + let reference = 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.'; + for (let [itemType, creators, status] of [ + ['bookSection', [author, { ...other, creatorType: 'editor' }], 'matched'], + ['book', [author, { ...other, creatorType: 'translator' }], 'matched'], + ['book', [{ ...author, creatorType: 'editor' }, { ...other, creatorType: 'translator' }], 'matched'], + ['bookSection', [{ ...other, creatorType: 'author' }, { ...author, creatorType: 'editor' }], 'unmatched'], + ['book', [{ ...author, creatorType: 'editor' }, { ...other, creatorType: 'editor' }], 'unmatched'], + ['book', [{ ...author, creatorType: 'translator' }], 'matched'], + ]) { + let candidate = { itemType, creators, date: '2021', title: article().title }; + assert.equal(evaluate(reference, [candidate]).status, status, `${itemType}: ${JSON.stringify(creators)}`); + } + }); + + it("should use primary creators in library matching as well as provider matching", async function () { + let item = await createItem({ itemType: 'bookSection', title: article().title, date: '2021', creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'editor', firstName: 'Grace', lastName: 'Hopper' }, + ] }); + let result = await BibliographyMatcher.matchLibrary( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.', item.libraryID); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, item.id); + }); + + it("should bind visible given names to their own surnames even with complete supporting metadata", function () { + let candidate = article({ creators: [ + { creatorType: 'author', firstName: 'Bob', lastName: 'Smith' }, + { creatorType: 'author', firstName: 'Alice', lastName: 'Jones' }, + ] }); + for (let names of ['Smith, Alice; Jones, Bob.', 'Alice Smith, Bob Jones.']) { + let reference = `${names} 2021. ${candidate.title}. ${candidate.publicationTitle}. pp. 120-139.`; + let result = evaluate(reference, [candidate]); + assert.equal(result.status, 'unmatched', names); + let item = new Zotero.Item('journalArticle'); + item.fromJSON(candidate); + assert.isTrue(BibliographyMatcher._evaluate( + item, BibliographyMatcher.prepareReference(reference) + ).conflict); + } + }); + + it("should not erase a visible wrong given name with et al", function () { + let candidate = article({ creators: [ + { creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }, + { creatorType: 'author', firstName: 'Bob', lastName: 'Jones' }, + ] }); + let suffix = `2021. ${candidate.title}. ${candidate.publicationTitle}. pp. 120-139.`; + assert.equal(evaluate(`Smith, Wrongname; et al. ${suffix}`, [candidate]).status, 'unmatched'); + assert.equal(evaluate(`Smith, A.; et al. ${suffix}`, [candidate]).status, 'matched'); + }); + + it("should align compact adjacent creators once and allow unstored middle names", function () { + let candidate = article({ creators: [ + { creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }, + { creatorType: 'author', firstName: 'Bob', lastName: 'Jones' }, + ] }); + for (let names of ['Smith A Jones B', 'Alice Helen Smith; Bob James Jones']) { + let result = evaluate(`${names}. 2021. ${candidate.title}.`, [candidate]); + assert.equal(result.status, 'matched', names); + } + candidate.creators[0].firstName = 'A.'; + candidate.creators[1].firstName = 'B.'; + assert.equal(evaluate(`Alice Smith; Bob Jones. 2021. ${candidate.title}.`, [candidate]).status, 'matched'); + }); + + it("should abstain on damaged given names instead of guessing OCR corrections", function () { + let candidate = article({ creators: [ + { creatorType: 'author', firstName: 'Marianne Helen', lastName: 'Smith' }, + ] }); + let result = evaluate(`Smith, Mariannx Helen. 2021. ${candidate.title}.`, [candidate]); + assert.equal(result.status, 'unmatched'); + }); + + it("should recognize compact merged initials without reusing one family occurrence", function () { + let candidate = article({ + creators: [ + { creatorType: 'author', firstName: 'Barbara Jean', lastName: 'Smith' }, + { creatorType: 'author', firstName: 'Benjamin James', lastName: 'Smith' }, + ], + }); + let positive = evaluate( + 'Smith, BJ; Smith, B. J. 2021. Reliable matching across diverse bibliographies.', + [candidate] + ); + let negative = evaluate( + 'Smith, BJ. 2021. Reliable matching across diverse bibliographies.', + [candidate] + ); + + assert.equal(positive.status, 'matched'); + + assert.notEqual(negative.status, 'matched'); + }); + + it("should withhold a title-only match when required evidence is missing", function () { + let result = evaluate( + 'Lovelace. Reliable matching across diverse bibliographies.', + [article()] + ); + + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + }); + + it("should require completeness only for a candidate capable of changing automatic matching", function () { + let matcher = BibliographyMatcher; + let rival = { score: 0.6, incomplete: true, scoreUpperBound: 0.99 }; + let evaluations = [{ item: { id: 1 }, score: 0.73 }, rival]; + assert.equal(matcher._rankEvaluations(evaluations).status, 'incomplete'); + rival.scoreUpperBound = 0.64; + assert.equal(matcher._rankEvaluations(evaluations).status, 'unmatched'); + }); + + it("should withhold tied candidates", function () { + let first = { item: { id: 1 }, score: 0.8 }; + let second = { item: { id: 2 }, score: 0.8 }; + let lower = { item: { id: 3 }, score: 0.4 }; + let evaluations = [lower, first, second]; + let result = BibliographyMatcher._rankEvaluations(evaluations); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + assert.equal(result.reason, 'ambiguous'); + }); + + it("should reuse complete reference terms for split and joined title bounds", function () { + let matcher = BibliographyMatcher; + for (let [text, title, expected, local] of [ + ['bio diversity', 'biodiversity', 1, 1], + ['biodiversity', 'bio diversity', 1, 0], + ['alpha beta', 'alpha gamma', 2 / 3, 2 / 3], + ]) { + let reference = matcher.prepareReference(text); + let terms = [...reference.alignmentTerms]; + for (let i = 0; i < 2; i++) { + assert.equal(matcher._alignmentUpperBound(matcher._prepareField(title).tokens, reference.alignmentTerms), expected); + assert.equal(matcher._alignmentUpperBound(matcher._prepareField(title).tokens, reference.alignmentTerms, false), local); + assert.deepEqual([...reference.alignmentTerms], terms); + } + } + }); + + it("should withhold both unsupported titles and competing titles", function () { + let candidate = article({ + creators: [], + date: '', + publicationTitle: '', + pages: '', + }); + let unique = evaluate('Reliable matching across diverse bibliographies.', [candidate]); + let duplicate = evaluate( + 'Reliable matching across diverse bibliographies.', + [candidate, { ...candidate }] + ); + + assert.equal(unique.status, 'unmatched'); + + + assert.equal(duplicate.status, 'unmatched'); + assert.notProperty(duplicate, 'item'); + }); + + it("should not return an unsupported candidate simply because its title scores highest", function () { + let target = article({ creators: [], date: '', publicationTitle: '', pages: '' }); + let other = article({ + title: 'Reliable matching across unfamiliar historic catalogues and their editions', + publicationTitle: 'Journal of Bibliographic Systems', + }); + let reference = `Lovelace, Ada. 2021. ${target.title}. ${other.publicationTitle}.`; + let result = evaluate(reference, [target, other]); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + }); + + it("should never certify a bounded title prefix or clipped reference as complete", function () { + let matcher = BibliographyMatcher; + let prefix = Array.from({ length: matcher.MAX_ALIGNMENT_FIELD_TOKENS }, (_, i) => `word${i}`).join(' '); + let field = matcher._prepareField(`${prefix} a different final conclusion`); + let evidence = matcher._alignmentEvidence(field, matcher.prepareReference(prefix).tokens); + assert.isBelow(evidence.similarity, 1); + assert.isTrue(evidence.incomplete); + assert.equal(evaluate(`Lovelace, Ada. 2021. ${prefix}`, [article({ title: field.raw })]).status, 'incomplete'); + let text = `${'a '.repeat(matcher.MAX_REFERENCE_TOKENS)}DOI: 10.1234/beyond-bound`; + let result = evaluate(text, [article({ DOI: '10.1234/beyond-bound' })]); + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'reference-token-limit'); + }); + + it("should not let a noncompeting bounded field invalidate a complete candidate", function () { + let longTitle = 'Reliable ' + Array.from({ length: 70 }, (_, i) => `unrelated${i}`).join(' '); + let result = evaluate('Lovelace, Ada. 2021. Reliable matching across diverse bibliographies.', [ + article(), + article({ title: longTitle, creators: [], date: '', publicationTitle: '', pages: '' }), + article({ title: 'Reliable observations in another unrelated discipline', + creators: Array.from({ length: 185 }, (_, i) => ({ + creatorType: 'author', firstName: 'Other', lastName: `Researcher${i}`, + })) }), + ]); + assert.equal(result.status, 'matched'); + assert.equal(result.item.getField('title'), article().title); + }); + + it("should measure a tiny title fragment against the complete title", function () { + let matcher = BibliographyMatcher; + let field = matcher._prepareField('alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu'); + assert.closeTo(matcher._alignmentEvidence(field, ['alpha']).similarity, 2 / 13, 0.00001); + }); + + it("should preserve mathematical comparisons while removing known inline formatting", function () { + let matcher = BibliographyMatcher; + assert.equal(matcher._prepareField('Bounds for x < y and y > z').raw, 'Bounds for x < y and y > z'); + assert.equal(matcher._prepareField('An italic title and H2O').raw, 'An italic title and H2O'); + }); + + it("should abstain when only creators and year are available", function () { + let candidate = article({ + title: 'A title omitted from the visible record', + creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Grace', lastName: 'Hopper' }, + ], + publicationTitle: '', + pages: '', + }); + let reference = 'Lovelace, A.; Hopper, G. 2021.'; + let unique = evaluate(reference, [candidate]); + let competitor = evaluate(reference, [candidate, { + ...candidate, + title: 'A different title also omitted from the visible record', + }]); + + assert.equal(unique.status, 'unmatched'); + + assert.equal(competitor.status, 'unmatched'); + assert.notProperty(competitor, 'item'); + }); + + it("should accept a general et-al creator omission", function () { + let result = evaluate( + 'Lovelace et al. 2021. Reliable matching across diverse bibliographies.', + [article({ + creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Grace', lastName: 'Hopper' }, + { creatorType: 'author', firstName: 'Katherine', lastName: 'Johnson' }, + ], + })] + ); + + assert.equal(result.status, 'matched'); + }); + + it("should expand et al only after a leading creator prefix", function () { + let candidate = article({ + creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Grace', lastName: 'Hopper' }, + { creatorType: 'author', firstName: 'Katherine', lastName: 'Johnson' }, + ], + }); + let positive = evaluate( + 'Lovelace, A.; Hopper, G.; et al. 2021. ' + + 'Reliable matching across diverse bibliographies.', + [candidate] + ); + let negative = evaluate( + 'Hopper, G.; et al. 2021. Reliable matching across diverse bibliographies.', + [candidate] + ); + + assert.equal(positive.status, 'matched'); + assert.notEqual(negative.status, 'matched'); + }); + + it("should reject explicit year and page-range contradictions", function () { + let candidate = new Zotero.Item('journalArticle'); + candidate.fromJSON(article()); + let reference = 'Lovelace, Ada. 2021. Reliable matching across diverse bibliographies. pp. 120–139.'; + assert.equal(evaluate(reference, [candidate]).status, 'matched'); + + for (let text of [reference.replace('2021', '2020'), reference.replace('120–139', '200–219')]) { + assert.isTrue(BibliographyMatcher._evaluate(candidate, BibliographyMatcher.prepareReference(text)).conflict); + let result = evaluate(text, [candidate]); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + } + }); + + it("should not treat noisy multiple years as one explicit contradiction", function () { + let result = evaluate( + 'Lovelace, Ada. Originally circulated 2020; published 2021. ' + + 'Reliable matching across diverse bibliographies.', + [article()] + ); + + assert.equal(result.status, 'matched'); + }); + + it("should exclude numbers aligned to candidate title and container fields from publication evidence", function () { + for (let [date, container] of [['2008', ''], ['1999', ' Proceedings of History 1999.']]) { + let candidate = article({ title: 'The financial crisis of 2008', date, + publicationTitle: 'Proceedings of History 1999', pages: '' }); + let reference = `Lovelace, Ada. ${candidate.title}.${container}`; + assert.equal(evaluate(`${reference} ${date}.`, [candidate]).status, 'matched', date); + let result = evaluate(reference, [candidate]); + assert.equal(result.status, 'unmatched', date); + assert.notProperty(result, 'item'); + assert.equal(evaluate(`${reference} 2010.`, [candidate]).status, 'unmatched', date); + } + let parsed = BibliographyMatcher.prepareReference('Quantum gravity: 2001.12345'); + assert.isTrue(parsed.identifiers.arXiv.has('2001.12345')); + assert.deepEqual([...BibliographyMatcher._extractYears(parsed.numericEvidenceText)], []); + let invalidISBN = BibliographyMatcher.prepareReference('2008 123457'); + assert.equal(invalidISBN.identifiers.ISBN.size, 0); + assert.deepEqual([...BibliographyMatcher._extractYears(invalidISBN.numericEvidenceText)], ['2008']); + }); + + it("should use all plausible item years and ignore numeric identifier and URL spans", function () { + let parsed = BibliographyMatcher.prepareReference( + 'Lovelace. Reliable matching. DOI: 10.1234/archive_2019. ' + + 'https://example.test/2020/1500-2100' + ); + let result = evaluate( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.', + [article({ date: 'First circulated 2020; revised 2021' })] + ); + + assert.deepEqual([...BibliographyMatcher._extractYears(parsed.numericEvidenceText)], []); + assert.deepEqual([...BibliographyMatcher._extractPageRanges(parsed.numericEvidenceText)], []); + assert.equal(result.status, 'matched'); + }); + + it("should preserve explicit and known page ranges that resemble years", function () { + let positive = evaluate( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies. pp. 2019-2020.', + [article({ pages: '2019-2020' })] + ); + let negative = evaluate( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies. pp. 2018-2020.', + [article({ pages: '2019-2020' })] + ); + + assert.equal(positive.status, 'matched'); + assert.equal(negative.status, 'unmatched'); + for (let pages of ['pp. 2021–2022', 'p. 2021']) { + let citation = `Lovelace, A. Reliable matching across diverse bibliographies. ${pages}.`; + let candidate = article({ date: '2021', pages: '2021–2022' }); + assert.equal(evaluate(citation, [candidate]).status, 'unmatched'); + assert.equal(evaluate(`${citation} 2020.`, [candidate]).status, 'unmatched'); + assert.equal(evaluate(`${citation} 2021.`, [candidate]).status, 'matched'); + } + }); + + it("should retrieve URL identifiers using the same evidence as candidate matching", async function () { + for (let [reference, url] of [ + ['arXiv: 2301.12345', 'https://arxiv.org/abs/2301.12345'], + ['2021ApJ...910...14S', 'https://ui.adsabs.harvard.edu/abs/2021ApJ...910...14S/abstract'], + ]) { + let expected = await createItem({ itemType: 'journalArticle', title: 'URL identifier retrieval', url }); + await createItem({ itemType: 'journalArticle', title: 'Identifier substring distractor', url: url.replace('/abs/', '/abs/9') }); + let match = await BibliographyMatcher.matchLibrary(reference, expected.libraryID); + assert.equal(match.status, 'matched'); + assert.equal(match.item.id, expected.id); + } + }); + + it("should normalize abbreviated page endpoints", function () { + let result = evaluate( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies. pp. 120–39.', + [article()] + ); + + assert.equal(result.status, 'matched'); + assert.deepEqual([...BibliographyMatcher._extractPageRanges('120–39.')], ['120-139']); + }); + + it("should retrieve equivalent ISBN forms without treating ISBN alone as record identity", function () { + let result = evaluate('ISBN 0-306-40615-2', [{ + itemType: 'book', + title: '', + ISBN: '9780306406157', + }]); + + assert.equal(result.status, 'unmatched'); + let evidence = BibliographyMatcher._identifierEvidence( + BibliographyMatcher.extractIdentifiers('ISBN 0-306-40615-2'), + BibliographyMatcher.extractIdentifiers('ISBN 9780306406157')); + assert.isFalse(evidence.match, 'A shared ISBN does not establish record identity'); + assert.isFalse(evidence.conflict); + }); + + it("should extract ISBNs independently from other identifiers and permitted separators", function () { + let identifiers = BibliographyMatcher.extractIdentifiers( + 'DOI: 10.1234/isbn-cooccurrence\nISBN: 9791\u2011843\u00A0123\u2043391' + ); + + assert.deepEqual([...identifiers.DOI], ['10.1234/isbn-cooccurrence']); + assert.deepEqual([...identifiers.ISBN], ['9791843123391']); + for (let i = 0; i < 3; i++) { + let parsed = BibliographyMatcher.prepareReference('ISBN 0-306-40615-2; ISBN 9791\u2011843\u00A0123\u2043391'); + assert.sameMembers([...parsed.identifiers.ISBN], ['9780306406157', '9791843123391']); + assert.equal(parsed.numericEvidenceText.replace(/isbn|[;\s]/g, ''), ''); + } + }); + + it("should verify ordinary book metadata without an ISBN score boost or contradiction override", async function () { + let book = await createItem({ itemType: 'book', title: 'Reliable methods for evaluating bibliographic identity', + creators: [{ creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }], date: '2021', ISBN: '9781118282175' }); + let reference = 'Alice Smith. 2021. Reliable methods for evaluating bibliographic identity. ISBN 1118282175'; + let result = await BibliographyMatcher.matchLibrary(reference, book.libraryID); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, book.id); + + let withoutISBN = await BibliographyMatcher.matchLibrary(reference.replace('ISBN 1118282175', ''), book.libraryID); + assert.deepEqual(withoutISBN, result); + + let conflict = evaluate(reference.replace('Alice Smith', 'Bob Smith'), [book]); + assert.equal(conflict.status, 'unmatched'); + let dateConflict = evaluate(reference.replace('2021', '2022'), [book]); + assert.equal(dateConflict.status, 'unmatched'); + }); + + it("should not let a shared ISBN promote a different component to a strict match", async function () { + let creators = ['Adam Jorgensen', 'Jorge Segarra', 'Patrick LeBlanc', 'Jose Chinchilla', 'Aaron Nelson'] + .map((name) => { + let [firstName, lastName] = name.split(' '); return { firstName, lastName, creatorType: 'author' }; + }); + let sibling = await createItem({ itemType: 'bookSection', title: 'The World of SQL Server', + creators, date: 'August 28, 2012', bookTitle: 'Microsoft SQL Server 2012 Bible', ISBN: '9781118282175' }); + let reference = 'Laying the Foundations. Adam Jorgensen; Jorge Segarra; Patrick LeBlanc; Jose Chinchilla; Aaron Nelson. ' + + 'August 28, 2012. Microsoft SQL Server 2012 Bible. ISBN 9781118282175'; + let result = await BibliographyMatcher.matchLibrary(reference, sibling.libraryID); + assert.equal(result.status, 'unmatched'); + }); + + it("should retrieve a normally hyphenated ISBN from a populated library", async function () { + let expected = await createItem({ + itemType: 'book', + title: '', + ISBN: '9780199535927', + }); + assert.equal(expected.getField('ISBN'), '978-0-19-953592-7'); + + let result = await BibliographyMatcher.matchLibrary( + 'ISBN: 9780199535927', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'unmatched'); + assert.deepEqual((await retrieveISBN('ISBN: 9780199535927')).ids, [expected.id]); + }); + + it("should retrieve equivalent stored and queried ISBN-10 and ISBN-13 forms both ways", async function () { + let stored10 = await createItem({ + itemType: 'book', + title: '', + ISBN: '123456789X', + }); + let stored13 = await createItem({ + itemType: 'book', + title: '', + ISBN: '9780306406157', + }); + let generalParser = observations.spy(BibliographyMatcher, 'extractItemIdentifiers'); + + let from10 = await retrieveISBN( + 'ISBN: 9781234567897', + Zotero.Libraries.userLibraryID + ); + let from13 = await retrieveISBN( + 'ISBN: 0-306-40615-2', + Zotero.Libraries.userLibraryID + ); + + assert.deepEqual(from10.ids, [stored10.id]); + assert.deepEqual(from13.ids, [stored13.id]); + assert.equal(generalParser.callCount, 2, 'Only matching ISBN values should reach the parser'); + }); + + it('should preserve general parsing for multi-value and mixed-content ISBN rows', async function () { + let matcher = BibliographyMatcher; + let values = [ + '978-0-306-40615-7', + '123456789x', + '9791843123391', + '978 0 306 40615 7', + '9791\u2011843\u00A0123\u2043391', + '0306406152 (pbk.)', + '9780306406157; 123456789X', + '9780306406157 DOI: 10.1234/isbn-mixed', + 'ISBN 9783161484100 https://doi.org/%31%30%2E1234%2Fisbn-mixed', + '123456789X PMID: 22678280', + 'DOI: 10.1234/isbn-mixed', + '9780306406158', + 'invalid', + 'x'.repeat(matcher.MAX_MATCH_TEXT_LENGTH) + ' 123456789X', + 'prefix\u0000 123456789X', + '2' + '0'.repeat(2000), + ]; + let records = []; + for (let ISBN of values) records.push(await createItem({ itemType: 'book', ISBN })); + for (let text of [ + 'ISBN 9780306406157', + 'ISBN 9781234567897', + 'ISBN 9791843123391', + 'ISBN 9780000000026', + 'ISBN 9783161484100 DOI: 10.1234/isbn-mixed\nPMID: 22678280', + ]) { + let identifiers = matcher.prepareReference(text).identifiers; + let expected = records.filter(item => Object.entries(matcher.extractItemIdentifiers(item)) + .some(([type, stored]) => [...stored].some(id => identifiers[type].has(id)))).map(item => item.id); + let result = await matcher._searchByIdentifiers(identifiers, Zotero.Libraries.userLibraryID); + assert.sameMembers(result.ids, expected, text); + assert.isFalse(result.incomplete); + } + }); + + it('filters unrelated annotated ISBN values before parsing', async function () { + let matcher = BibliographyMatcher; + await createItem({ itemType: 'book', ISBN: '0306406152 (pbk.) 9780306406157' }); + let target = await createItem({ itemType: 'book', ISBN: '123456789X (hardback)' }); + let identifiers = matcher.prepareReference('ISBN 9781234567897').identifiers; + let parser = observations.spy(matcher, 'extractItemIdentifiers'); + let result = await matcher._searchByIdentifiers(identifiers, target.libraryID); + assert.deepEqual(result, { ids: [target.id], incomplete: false }); + assert.equal(parser.callCount, 1); + }); + + it("should retrieve formattable, unformattable, and multi-value ISBN-13 fields", async function () { + let formattable = await createItem({ + itemType: 'book', + title: '', + ISBN: '9791000000008', + }); + let canonical = await createItem({ + itemType: 'book', + title: '', + ISBN: '9791843123391', + }); + let mixed = await createItem({ + itemType: 'book', + title: '', + ISBN: '9791\u2011843\u00A0123\u2043391', + }); + let multiple = await createItem({ + itemType: 'book', + title: '', + ISBN: '9781000000009; 9791020000002', + }); + + let formattableResult = await retrieveISBN( + 'ISBN: 9791000000008', + Zotero.Libraries.userLibraryID + ); + let canonicalResult = await retrieveISBN( + 'ISBN: 9791843123391', + Zotero.Libraries.userLibraryID + ); + let mixedResult = await retrieveISBN( + 'ISBN: 9791\u2011843\u00A0123\u2043391', + Zotero.Libraries.userLibraryID + ); + let multipleResult = await retrieveISBN( + 'ISBN: 9791020000002', + Zotero.Libraries.userLibraryID + ); + + assert.deepEqual(formattableResult.ids, [formattable.id]); + for (let result of [canonicalResult, mixedResult]) { + assert.sameMembers(result.ids, [canonical.id, mixed.id]); + } + assert.deepEqual(multipleResult.ids, [multiple.id]); + }); + + it("should preserve equivalent ISBN duplicate ambiguity and overflow", async function () { + observations.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(3); + for (let i = 0; i <= BibliographyMatcher.MAX_CANDIDATES; i++) { + await createItem({ + itemType: 'book', + title: '', + ISBN: i % 2 ? '0306406152' : '9780306406157', + }); + } + + let isbnSearch = observations.spy(BibliographyMatcher, '_searchByIdentifiers'); + let result = await BibliographyMatcher.matchLibrary( + 'ISBN: 9780306406157', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'retrieval-limit'); + assert.isTrue(isbnSearch.calledOnce); + let route = await isbnSearch.firstCall.returnValue; + assert.lengthOf(route.ids, BibliographyMatcher.MAX_CANDIDATES + 1); + assert.isTrue(route.incomplete); + }); + + it("should restrict ISBN retrieval to regular nondeleted items in the selected library", async function () { + let metadata = { title: 'Reliable methods for evaluating bibliographic identity', date: '2021', + creators: [{ creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }] }; + let expected = await createItem({ + itemType: 'book', + ...metadata, + ISBN: '9783161484100', + }); + await createItem({ + itemType: 'book', + title: '', + ISBN: '9780199535927', + }); + let deleted = await createItem({ + itemType: 'book', + ...metadata, + ISBN: '9783161484100', + }); + deleted.deleted = true; + await deleted.saveTx(); + let child = new Zotero.Item('note'); + child.libraryID = Zotero.Libraries.userLibraryID; + child.parentID = expected.id; + child.setNote('ISBN: 9783161484100'); + await child.saveTx(); + items.push(child); + let group = await createGroup(); + try { + let groupItem = await createItem({ + itemType: 'book', + ...metadata, + ISBN: '9783161484100', + }, group.libraryID); + + let userResult = await retrieveISBN( + 'ISBN: 9783161484100', + Zotero.Libraries.userLibraryID + ); + let groupResult = await retrieveISBN( + 'ISBN: 9783161484100', + group.libraryID + ); + + assert.deepEqual(userResult.ids, [expected.id]); + assert.deepEqual(groupResult.ids, [groupItem.id]); + let text = `Alice Smith. 2021. ${metadata.title}. ISBN 9783161484100`; + for (let target of [expected, groupItem]) { + let matched = await BibliographyMatcher.matchLibrary(text, target.libraryID); + assert.equal(matched.status, 'matched'); + assert.equal(matched.item.id, target.id); + } + } + finally { + await group.eraseTx(); + } + }); + + it("should union co-occurring DOI and ISBN routes before resolving exact identifiers", async function () { + let both = await createItem({ + itemType: 'book', + title: '', + DOI: '10.1234/isbn-union', + ISBN: '9780306406157', + }); + let isbnDuplicate = await createItem({ + itemType: 'book', + title: '', + ISBN: '0306406152', + }); + + let loading = observations.spy(BibliographyMatcher, '_loadLibraryCandidates'); + let result = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/isbn-union\nISBN: 0-306-40615-2', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + + assert.equal(result.item.id, both.id); + assert.notEqual(result.item.id, isbnDuplicate.id); + assert.isTrue(loading.calledOnce); + assert.sameMembers(loading.firstCall.args[0], [both.id, isbnDuplicate.id]); + }); + + it("should not use a citation hyperlink as matching evidence", function () { + let result = evaluate([{ + text: 'Publisher page', + attributes: { url: 'https://doi.org/10.1234/hidden' }, + }], [article({ title: '', creators: [], date: '', DOI: '10.1234/hidden' })]); + + assert.equal(result.status, 'unmatched'); + assert.deepEqual( + [...BibliographyMatcher.prepareReference([{ + text: 'Publisher page', + attributes: { url: 'https://doi.org/10.1234/hidden' }, + }]).identifiers.DOI], + [] + ); + }); + + it("should match visible text regardless of unrelated, unsupported, malformed or excessive hrefs", function () { + for (let url of [ + 'http://linker.aanda.org/10.1051/0004-6361/202243709/74', + 'zotero://select/library/items/ABCDEFGH', + 'not a URL', + ]) { + let parts = Array.from({ length: 21 }, (_, index) => ({ + text: index ? '' : 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.', + attributes: { url: index === 0 ? url : `https://example.com/${'x'.repeat(3000)}${index}` }, + })); + let item = article({ DOI: '10.1234/visible-record' }); + let result = evaluate(parts, [item]); + assert.equal(result.status, 'matched', url); + assert.equal(result.item.getField('DOI'), item.DOI); + } + }); + + it("should extract supported identifiers from candidate URL metadata", function () { + let result = evaluate('doi:10.1234/from-url', [article({ + title: '', + creators: [], + date: '', + url: 'https://doi.org/10.1234/from-url', + })]); + + assert.equal(result.status, 'matched'); + }); + + it("should let an exact identifier override conflicting secondary metadata", function () { + let result = evaluate( + 'Lovelace. 2020. Reliable matching across diverse bibliographies. ' + + 'pp. 200–219. DOI: 10.1234/secondary-conflict', + [article({ DOI: '10.1234/secondary-conflict' })] + ); + + assert.equal(result.status, 'matched'); + }); + + describe('normalized identifier retrieval', function () { + it('preserves case folding and literal punctuation in identifier probes', async function () { + for (let field of ['DOI', 'extra', 'url']) { + for (let suffix of ['UPPER_CASE', 'ÉXAMPLE', 'PERCENT%literal']) { + let DOI = `10.1234/${field}-${suffix}`; + let item = await createItem(article({ [field]: DOI })); + let result = await BibliographyMatcher.matchLibrary('DOI: ' + DOI.toLowerCase(), item.libraryID); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, item.id); + } + } + }); + + it('matches URL-encoded DOIs in every identifier-bearing field without title overlap', async function () { + let matcher = BibliographyMatcher; + let titleSearch = observations.spy(matcher, '_searchTitleCandidates'); + for (let field of ['DOI', 'extra', 'url']) { + for (let [index, encode] of [ + value => value.replaceAll('/', '%2F'), + value => value.replaceAll('/', '%2f'), + value => value.replace('10.', '%31%30%2E'), + value => value.replace('10.', '1%30.'), + value => value.replace('10.', '%310.'), + value => [...value].map(char => '%' + char.charCodeAt(0).toString(16)).join(''), + value => '\u0000 https://doi.org/' + encodeURIComponent(value), + ].entries()) { + let DOI = `10.1234/encoded-${field}-${index}/id`; + let item = await createItem(article({ [field]: `https://doi.org/${encode(DOI)}` })); + let result = await matcher.matchLibrary(`DOI: ${DOI}`, item.libraryID); + assert.equal(result.status, 'matched', `${field} encoding ${index}`); + assert.equal(result.item.id, item.id); + let duplicate = await matcher.checkForDuplicate(matcher.createCandidateSnapshot(article({ DOI })), item.libraryID); + assert.equal(duplicate.status, 'matched'); + assert.equal(duplicate.item.id, item.id); + } + } + assert.isFalse(titleSearch.called); + }); + + it('does not parse unrelated percent-encoded URLs during DOI retrieval', async function () { + let matcher = BibliographyMatcher; + let target = await createItem(article({ DOI: '10.1234/encoded-prefilter' })); + await createItem({ itemType: 'journalArticle', url: 'https://example.invalid/field%20observations' }); + let identifiers = matcher.prepareReference('DOI: 10.1234/encoded-prefilter').identifiers; + let parser = observations.spy(matcher, 'extractItemIdentifiers'); + let result = await matcher._searchByIdentifiers(identifiers, target.libraryID); + assert.deepEqual(result, { ids: [target.id], incomplete: false }); + assert.equal(parser.callCount, 1); + }); + + it('preserves encoded DOI matches and competitors excluded by title pruning', async function () { + let matcher = BibliographyMatcher; + let title = 'Reliable identification of scientific references with incomplete'; + let DOI = '10.1234/encoded-competitors/id'; + let storedDOI = 'https://doi.org/10.1234/encoded-competitors%2Fid'; + let item = await createItem(article({ title: title + ' metadata across diverse libraries', DOI: storedDOI })); + let reference = `Lovelace, Ada. 2021. ${title}. DOI: ${DOI}`; + let result = await matcher.matchLibrary(reference, item.libraryID); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, item.id); + await createItem(article({ title, DOI: storedDOI })); + assert.deepEqual(await matcher.matchLibrary(reference, item.libraryID), { status: 'unmatched', reason: 'ambiguous-identifiers' }); + let duplicate = await matcher.checkForDuplicate(matcher.createCandidateSnapshot(article({ DOI })), item.libraryID); + assert.deepEqual(duplicate, { status: 'unresolved', reason: 'ambiguous' }); + }); + + it('matches stored DOI forms without relying on title overlap', async function () { + let matcher = BibliographyMatcher; + let titleSearch = observations.spy(matcher, '_searchTitleCandidates'); + for (let [index, prefix] of ['', 'doi:', 'DOI: ', 'https://doi.org/', 'http://dx.doi.org/'].entries()) { + let DOI = `10.1234/normalized-${index}`; + let item = await createItem(article({ DOI: prefix + DOI })); + let result = await matcher.matchLibrary(`DOI: ${DOI}`, item.libraryID); + assert.equal(result.status, 'matched', prefix); + assert.equal(result.item.id, item.id); + let duplicate = await matcher.checkForDuplicate(matcher.createCandidateSnapshot(article({ DOI })), item.libraryID); + assert.equal(duplicate.status, 'matched', prefix); + assert.equal(duplicate.item.id, item.id); + } + assert.isFalse(titleSearch.called); + }); + + it('withholds competing DOI forms even when only one title survives pruning', async function () { + let matcher = BibliographyMatcher; + let title = 'Reliable identification of scientific references with incomplete'; + for (let [index, prefix] of ['', 'https://doi.org/'].entries()) { + let DOI = `10.1234/normalized-duplicates-${index}`; + await createItem(article({ title: title + ' metadata across diverse libraries', DOI: `https://doi.org/${DOI}` })); + let item = await createItem(article({ title, DOI: prefix + DOI })); + let result = await matcher.matchLibrary(`Lovelace, Ada. 2021. ${title}. DOI: ${DOI}`, item.libraryID); + assert.equal(result.status, 'unmatched'); + assert.deepEqual(result, { status: 'unmatched', reason: 'ambiguous-identifiers' }); + let duplicate = await matcher.checkForDuplicate(matcher.createCandidateSnapshot(article({ DOI })), item.libraryID); + assert.deepEqual(duplicate, { status: 'unresolved', reason: 'ambiguous' }); + } + }); + + it('counts normalized identifier equality, not substring collisions, against the candidate limit', async function () { + let matcher = BibliographyMatcher; + let DOI = '10.1234/normalized-prefix'; + for (let suffix of ['-one', '-two', '-three']) { + await createItem(article({ DOI: DOI + suffix })); + await createItem(article({ DOI: `https://doi.org/${encodeURIComponent(DOI + suffix)}` })); + } + let item = await createItem(article({ DOI: `https://doi.org/${encodeURIComponent(DOI)}` })); + observations.stub(matcher, 'MAX_CANDIDATES').value(1); + let identifiers = matcher.prepareReference(`DOI: ${DOI}`).identifiers; + assert.deepEqual(await matcher._searchByIdentifiers(identifiers, item.libraryID), { ids: [item.id], incomplete: false }); + let duplicate = await createItem(article({ DOI: `doi:${DOI}` })); + let overflow = await matcher._searchByIdentifiers(identifiers, item.libraryID); + assert.sameMembers(overflow.ids, [item.id, duplicate.id]); + assert.isTrue(overflow.incomplete); + }); + }); + + it("should return an exact identifier before a broad route can overflow", async function () { + let expected = await createItem(article({ DOI: '10.1234/exact-before-overflow' })); + observations.stub(BibliographyMatcher, '_searchByIdentifiers') + .resolves({ ids: [expected.id], incomplete: false }); + let titleSearch = observations.stub(BibliographyMatcher, '_searchTitleCandidates') + .resolves({ ids: [], incomplete: true }); + + let result = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/exact-before-overflow', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, expected.id); + assert.isFalse(titleSearch.called); + }); + + it("should abstain when multiple library items have the same exact identifier", async function () { + await createItem(article({ DOI: '10.1234/duplicate-exact' })); + await createItem(article({ + title: 'A separately saved duplicate record', + DOI: '10.1234/duplicate-exact', + })); + + let result = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/duplicate-exact', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'unmatched'); + assert.equal(result.reason, 'ambiguous-identifiers'); + assert.notProperty(result, 'item'); + }); + + it("should not prefer a DOI record over a separate explicitly cited preprint", async function () { + let published = await createItem(article({ DOI: '10.1234/preprint-counterpart' })); + let preprint = await createItem(article({ extra: 'arXiv:2101.12345' })); + let reference = 'DOI: 10.1234/preprint-counterpart arXiv:2101.12345'; + for (let candidate of [published, preprint]) { + assert.equal(evaluate(reference, [candidate]).status, 'matched', candidate.getField('DOI') || candidate.getField('extra')); + } + assert.deepEqual(await BibliographyMatcher.matchLibrary(reference, published.libraryID), + { status: 'unmatched', reason: 'ambiguous-identifiers' }); + }); + + it("should retrieve a library DOI stored only in the URL field", async function () { + let expected = await createItem(article({ + DOI: '', + url: 'https://doi.org/10.1234/url-only-local', + })); + + let result = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/url-only-local', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + + assert.equal(result.item.id, expected.id); + }); + + it("should count identifier candidates once across fields and bound the DOI/ISBN union", async function () { + let matcher = BibliographyMatcher; + let first = await createItem(article({ DOI: '10.1234/union', + url: 'https://doi.org/10.1234/union', extra: 'DOI: 10.1234/union' })); + let second = await createItem({ itemType: 'book', title: 'Separate ISBN candidate', ISBN: '9780306406157' }); + observations.stub(matcher, 'MAX_CANDIDATES').value(1); + let retrieve = text => matcher._searchByIdentifiers(matcher.prepareReference(text).identifiers, first.libraryID); + let single = await retrieve('DOI: 10.1234/union'); + assert.deepEqual(single, { ids: [first.id], incomplete: false }); + let union = await retrieve('DOI: 10.1234/union ISBN 0-306-40615-2'); + assert.sameMembers(union.ids, [first.id, second.id]); + assert.isTrue(union.incomplete); + }); + + it("should retrieve literal DOI wildcard characters without wildcard collisions", async function () { + let firstClass = await createItem(article({ + DOI: '10.1234/field_2021', + })); + let expected = await createItem(article({ + DOI: '', + url: 'https://doi.org/10.1234/archive_2021', + })); + await createItem(article({ + title: 'A wildcard-shaped distractor', + DOI: '', + url: 'https://doi.org/10.1234/archiveX2021', + })); + + let result = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/archive_2021', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + + assert.equal(result.item.id, expected.id); + + let firstClassResult = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/field_2021', + Zotero.Libraries.userLibraryID + ); + assert.equal(firstClassResult.status, 'matched'); + assert.equal(firstClassResult.item.id, firstClass.id); + }); + + it("should preserve duplicate ambiguity across first-class and textual DOI storage", async function () { + await createItem(article({ DOI: '10.1234/archive_2021' })); + await createItem(article({ + title: 'Separately stored identifier duplicate', + DOI: '', + extra: 'DOI: 10.1234/archive_2021', + })); + + let result = await BibliographyMatcher.matchLibrary( + 'DOI: 10.1234/archive_2021', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'unmatched'); + assert.equal(result.reason, 'ambiguous-identifiers'); + assert.notProperty(result, 'item'); + }); + + it("should retrieve and match a saved item by first-class PMID alone", async function () { + let expected = await createItem(article({ + title: '', + creators: [], + date: '', + publicationTitle: '', + volume: '', + issue: '', + pages: '', + PMID: '22678280', + })); + + let result = await BibliographyMatcher.matchLibrary( + 'PMID: 22678280', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + + assert.equal(result.item.id, expected.id); + }); + + it("should retrieve every accepted labelled PMID form from Extra and verify it exactly", async function () { + for (let [index, extra] of [ + 'PMID 22678280', + 'PMID:22678281', + 'pmid:\t22678282', + ].entries()) { + let expected = await createItem(article({ + title: `PMID form ${index}`, + PMID: '', + extra, + })); + await createItem(article({ + title: `Numeric collision ${index}`, + PMID: '', + extra: `Unrelated number ${22678280 + index}`, + })); + + let result = await BibliographyMatcher.matchLibrary( + `PMID: ${22678280 + index}`, + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched', extra); + assert.equal(result.item.id, expected.id, extra); + } + }); + + it("should select a strict candidate only with a sufficient competitor margin", function () { + let result = evaluate( + 'Lovelace, Ada. 2021. Reliable matching across diverse bibliographies.', + [ + article(), + article({ + title: 'A different work about bibliography matching', + creators: [{ creatorType: 'author', firstName: 'Grace', lastName: 'Hopper' }], + }), + ] + ); + + assert.equal(result.status, 'matched'); + }); + + it("should query only the requested populated library with bounded loading", async function () { + let expected = await createItem(article()); + let group = await createGroup(); + try { + await createItem(article({ title: 'Same retrieval words in another library' }), group.libraryID); + + let loading = observations.spy(BibliographyMatcher, '_loadLibraryCandidates'); + let result = await BibliographyMatcher.matchLibrary( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, expected.id); + for (let call of loading.getCalls()) { + assert.isAtMost(call.args[0].length, BibliographyMatcher.CANDIDATE_BATCH_SIZE); + assert.equal(call.args[1], Zotero.Libraries.userLibraryID); + } + } + finally { + await group.eraseTx(); + } + }); + + it("should keep the complete competing pool when loading in smaller batches", async function () { + for (let i = 0; i < 3; i++) await createItem(article({ DOI: '', url: '' })); + observations.stub(BibliographyMatcher, 'CANDIDATE_BATCH_SIZE').value(1); + let loading = observations.spy(BibliographyMatcher, '_loadLibraryCandidates'); + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + observations.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(2); + let result = await BibliographyMatcher.matchLibrary( + 'Lovelace, A. 2021. Reliable matching across diverse bibliographies.', + Zotero.Libraries.userLibraryID + ); + assert.equal(result.status, 'unmatched'); + assert.equal(result.reason, 'ambiguous'); + assert.notProperty(result, 'item'); + assert.equal(loading.callCount, 3); + assert.equal(scoring.callCount, 3); + for (let call of loading.getCalls()) assert.lengthOf(call.args[0], 1); + }); + + it("should retrieve title-first references independently of journal and creator order", async function () { + let expected = await createItem(article({ + title: 'How bees find their way home', + creators: [{ creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }], + publicationTitle: 'International Entomological Investigations', + })); + let result = await BibliographyMatcher.matchLibrary( + 'How bees find their way home. Alice Smith. 2021. International Entomological Investigations.', + Zotero.Libraries.userLibraryID + ); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, expected.id); + }); + + it("should preserve title candidates across normalized SQL and library boundaries", async function () { + let matcher = BibliographyMatcher; + let libraryID = Zotero.Libraries.userLibraryID; + let creator = { creatorType: 'author', firstName: 'Zoë', lastName: 'García' }; + let target = await createItem(article({ title: 'École research inquiry', creators: [creator, + { creatorType: 'author', firstName: 'Ana', lastName: 'García' }] })); + await createItem({ itemType: 'case', caseName: 'École proceedings', dateDecided: '2021-03-04' }); + await createItem(article({ title: 'Independent proceedings', + creators: [{ creatorType: 'author', name: 'École Recherche' }] })); + let fallback = await createItem(article({ title: 'SubsequenceCatalog', creators: [creator] })); + await Zotero.DB.queryAsync('UPDATE itemDataValues SET valueNormalized=NULL WHERE valueID IN ' + + ' (SELECT valueID FROM itemData WHERE itemID=? AND fieldID=?)', + [fallback.id, Zotero.ItemFields.getID('title')]); + let deleted = await createItem(article({ title: 'École deleted', creators: [creator] })); + deleted.deleted = true; + await deleted.saveTx(); + let attachment = new Zotero.Item('attachment'); + attachment.libraryID = libraryID; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_URL; + attachment.setField('title', 'École child'); + attachment.setField('url', 'https://example.invalid/reference'); + attachment.parentID = target.id; + await attachment.saveTx(); + items.push(attachment); + let group = await createGroup(); + let leftBound = Zotero.Prefs.get('search.useLeftBound'); + try { + let foreign = await createItem(article({ title: 'École foreign', creators: [creator] }), group.libraryID); + Zotero.Prefs.set('search.useLeftBound', true); + for (let text of ['ÉCOLE research inquiry García 2021', 'ÉCOLE research inquiry 2020', 'ÉCOLE research inquiry 2020 2021', 'ÉCOLE research inquiry', 'sequence 2021']) { + let reference = matcher.prepareReference(text); + let search = new Zotero.Search(); + search.libraryID = libraryID; + search.addCondition('noChildren', 'true'); + // The oracle must inspect all titles, not repeat the retrieval predicate. + let searched = await Zotero.Items.getAsync(await search.search()); + let expected = searched.filter(item => item.isRegularItem() && !item.deleted).filter((item) => { + return matcher._evaluate(item, reference, false).scoreUpperBound + >= matcher.MIN_STRICT_SCORE - matcher.MIN_MATCH_MARGIN; + }).map(item => item.id); + let actual = await matcher._searchTitleCandidates(reference, libraryID); + assert.isFalse(actual.incomplete, text); + assert.sameMembers(actual.ids, expected, text); + let ids = actual.ids; + assert.notInclude(ids, deleted.id); + assert.notInclude(ids, attachment.id); + assert.notInclude(ids, foreign.id); + if (text === 'ÉCOLE research inquiry 2020') assert.include(ids, target.id, 'Year must not filter title candidates'); + } + } + finally { + if (leftBound === undefined) Zotero.Prefs.clear('search.useLeftBound'); + else Zotero.Prefs.set('search.useLeftBound', leftBound); + await group.eraseTx(); + } + }); + + it("should leave unsupported local word splits to complete online verification", async function () { + let group = await createGroup(); + try { + let candidates = []; + for (let title of ['Quantum Computation', 'Quantum-Computation', 'Quantum/Computation']) { + candidates.push(await createItem(article({ title }), group.libraryID)); + } + let reference = 'Lovelace, Ada. 2021. QuantumComputation.'; + for (let candidate of candidates) { + assert.equal(evaluate(reference, [candidate]).status, 'matched', candidate.getField('title')); + } + let complete = evaluate(reference, candidates); + assert.equal(complete.status, 'matched'); + assert.strictEqual(complete.item, candidates[0], 'Equivalent title formatting uses the first provider record'); + + assert.deepEqual(await BibliographyMatcher.matchLibrary(reference, group.libraryID), + { status: 'unmatched', reason: 'insufficient-evidence' }); + + let literal = await createItem(article({ title: 'QuantumComputation' }), group.libraryID); + let local = await BibliographyMatcher.matchLibrary(reference, group.libraryID); + assert.equal(local.status, 'matched'); + assert.equal(local.item.id, literal.id); + assert.equal(evaluate(reference, [...candidates, literal]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary('Lovelace, Ada. 2021. Quantum Computation.', + group.libraryID)).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary('Lovelace, Ada. 2021. Quan tumComputation.', + group.libraryID)).item.id, literal.id, 'A split reference still provides a literal long-word probe'); + } + finally { + await group.eraseTx(); + } + }); + + it("should require searchable local title evidence but keep short-word online evidence", async function () { + let group = await createGroup(); + try { + let short = await createItem(article({ title: 'The way we see the sun' }), group.libraryID); + let reference = 'Lovelace, Ada. 2021. The way we see the sun and stars.'; + assert.equal(evaluate('Lovelace, Ada. 2021. The way we see the sun.', [short]).status, 'matched'); + assert.equal(evaluate(reference, [short]).status, 'unmatched', 'A title fragment is not an independent work title'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, group.libraryID)).status, 'unmatched'); + let anchored = await createItem(article({ title: 'The way we see the sun and stars' }), group.libraryID); + assert.equal(evaluate(reference, [short, anchored]).reason, 'ambiguous'); + assert.equal((await BibliographyMatcher.matchLibrary(reference, group.libraryID)).item.id, anchored.id); + short.setField('DOI', '10.1234/short-title'); + await short.saveTx(); + assert.equal((await BibliographyMatcher.matchLibrary(reference + ' doi:10.1234/short-title', + group.libraryID)).item.id, short.id, 'Identifiers remain authoritative without a title probe'); + } + finally { + await group.eraseTx(); + } + }); + + it("should include native exact-title results in identity retrieval without approximate scoring", async function () { + let literal = '100%_equality \\ study'; + await createItem(article({ title: literal })); + await createItem(article({ title: '100X_equality \\ study' })); + let numeric = await createItem(article({ title: '2021' })); + await createItem({ itemType: 'case', caseName: 'École proceedings' }); + await Zotero.DB.queryAsync('UPDATE itemDataValues SET valueNormalized=NULL WHERE valueID IN ' + + '(SELECT valueID FROM itemData WHERE itemID=? AND fieldID=?)', + [numeric.id, Zotero.ItemFields.getID('title')]); + let field = observations.spy(BibliographyMatcher, '_prepareField'); + for (let title of [literal, literal.toUpperCase(), '2021', 'Ecole proceedings']) { + let search = new Zotero.Search(); + search.libraryID = numeric.libraryID; + search.addCondition('title', 'is', title); + let expected = (await Zotero.Items.getAsync(await search.search())) + .filter(item => item.isRegularItem() && !item.deleted).map(item => item.id); + let actual = await BibliographyMatcher._searchByExactTitle(title, numeric.libraryID); + assert.isFalse(actual.incomplete, title); + assert.includeMembers(actual.ids, expected, title); + } + assert.isFalse(field.called); + }); + + it("should abstain if any authoritative retrieval route overflows", async function () { + observations.stub(BibliographyMatcher, '_searchByIdentifiers') + .resolves({ ids: [], incomplete: false }); + observations.stub(BibliographyMatcher, '_searchTitleCandidates') + .resolves({ ids: [], incomplete: true }); + + let result = await BibliographyMatcher.matchLibrary( + 'Lovelace. 2021. Reliable matching across diverse bibliographies.', + Zotero.Libraries.userLibraryID + ); + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'retrieval-limit'); + }); + + it("should retain a strong match when every omitted title candidate is provably weaker", async function () { + observations.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(1); + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + let target = await createItem(article({ + title: 'Distinctive local matching across bibliographies', + })); + for (let i = 0; i < 2; i++) { + await createItem(article({ + title: `Noisy bibliographies record ${i}`, + creators: [{ creatorType: 'author', firstName: 'Other', lastName: `Author${i}` }], + date: '1990', + })); + } + + let result = await BibliographyMatcher.matchLibrary( + 'Lovelace, A. 2021. Distinctive local matching across bibliographies.', + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, target.id); + assert.equal(scoring.callCount, 1, 'Provably weak titles need no full metadata scoring'); + assert.property(result, 'item'); + }); + + it("should exclude standalone nonregular items before ranking the library candidate pool", async function () { + observations.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(1); + for (let i = 0; i < 2; i++) { + let attachment = new Zotero.Item('attachment'); + attachment.libraryID = Zotero.Libraries.userLibraryID; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_URL; + attachment.setField('url', `https://example.invalid/attachment-${i}`); + attachment.setField('title', article().title); + await attachment.saveTx(); + items.push(attachment); + } + assert.equal((await BibliographyMatcher.checkForDuplicate( + BibliographyMatcher.createCandidateSnapshot(article()), Zotero.Libraries.userLibraryID + )).status, 'absent'); + let target = await createItem(article()); + let duplicate = await BibliographyMatcher.checkForDuplicate( + BibliographyMatcher.createCandidateSnapshot(article()), Zotero.Libraries.userLibraryID + ); + assert.equal(duplicate.status, 'matched'); + assert.equal(duplicate.item.id, target.id); + let result = await BibliographyMatcher.matchLibrary( + 'Ada Lovelace. 2021. Reliable matching across diverse bibliographies.', + Zotero.Libraries.userLibraryID + ); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, target.id); + }); + + it("should skip creator-only candidates without losing a strong title match", async function () { + observations.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(1); + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + let creators = [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Charles', lastName: 'Babbage' }, + ]; + let target = await createItem(article({ creators })); + for (let i = 0; i < 2; i++) { + await createItem(article({ title: `Unrelated record ${i}`, creators })); + } + let winner = await BibliographyMatcher.matchLibrary( + 'Ada Lovelace; Charles Babbage. 2021. Reliable matching across diverse bibliographies.', + Zotero.Libraries.userLibraryID + ); + assert.equal(winner.status, 'matched'); + assert.equal(winner.item.id, target.id); + assert.isBelow(scoring.callCount, items.length); + scoring.resetHistory(); + let ambiguous = await BibliographyMatcher.matchLibrary( + 'Ada Lovelace; Charles Babbage. 2021.', Zotero.Libraries.userLibraryID + ); + assert.equal(ambiguous.status, 'unmatched'); + assert.equal(scoring.callCount, 0); + }); + + describe('adaptive library candidate evaluation', function () { + const reference = 'Ada Lovelace; Charles Babbage. 2021. Reliable matching across diverse bibliographies.'; + let creators = [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Charles', lastName: 'Babbage' }, + ]; + // Library title matching must span several batches even above the transient pool limit. + beforeEach(function () { + observations.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(3); + observations.stub(BibliographyMatcher, 'CANDIDATE_BATCH_SIZE').value(4); + }); + + async function distractors() { + for (let i = 0; i < 10; i++) { + await createItem(article({ + title: `Reliable matching across diverse bibliographies ${i}`, + date: '1990', + creators: [{ creatorType: 'author', firstName: 'Other', lastName: `Researcher${i}` }], + })); + } + } + + it('should finish a multi-batch negative search instead of treating the first batch as final', async function () { + await distractors(); + let evaluate = observations.spy(BibliographyMatcher, '_evaluate'); + let result = await BibliographyMatcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'unmatched'); + assert.equal(result.reason, 'insufficient-evidence'); + assert.equal(evaluate.callCount, 10); + assert.notProperty(result, 'item'); + }); + + it('should recover a strict target beyond the first batch of optimistic title bounds', async function () { + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + for (let i = 0; i < 10; i++) { + await createItem(article({ date: '1990', creators: [] })); + } + let target = await createItem(article({ creators })); + let result = await BibliographyMatcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, target.id); + assert.equal(scoring.callCount, 11); + }); + + it('should withhold a late approximate title with insufficient evidence', async function () { + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + await distractors(); + await createItem(article({ title: 'Reliable local matching across diverse bibliographies', creators })); + let result = await BibliographyMatcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + + assert.equal(scoring.callCount, 11); + }); + + it('should withhold late competing approximate titles', async function () { + await distractors(); + let title = 'Reliable matching across diverse bibliographies with efficient retrieval of existing research ' + + 'records from large scientific libraries and digital scholarly collections'; + let text = `Ada Lovelace; Charles Babbage. 2021. ${title}.`; + let target = await createItem(article({ title: title.replace('Reliable matching', 'Reliable local matching'), creators })); + let competitor = await createItem(article({ title: title.replace('Reliable matching', 'Reliable automatic matching'), creators })); + for (let candidate of [target, competitor]) { + assert.equal(evaluate(text, [candidate]).status, 'matched'); + } + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + let result = await BibliographyMatcher.matchLibrary(text, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'unmatched'); + assert.equal(result.reason, 'ambiguous'); + assert.notProperty(result, 'item'); + assert.equal(scoring.callCount, 12); + }); + + it('should abstain when the evaluation budget expires with candidates still unevaluated', async function () { + await distractors(); + await createItem(article({ title: 'Unrelated omitted title', creators })); + let matcher = BibliographyMatcher; + observations.stub(matcher, 'MAX_EVALUATION_DURATION_MS').value(2000); + let evaluate = matcher._evaluate; + let evaluated = 0; + observations.stub(matcher, '_evaluate').callsFake(function (...args) { + let result = evaluate.apply(this, args); + // Expire the deadline deterministically in the matcher's own realm. + if (++evaluated === matcher.CANDIDATE_BATCH_SIZE) matcher.MAX_EVALUATION_DURATION_MS = 0; + return result; + }); + let result = await matcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'evaluation-time-limit'); + assert.equal(evaluated, matcher.CANDIDATE_BATCH_SIZE); + assert.notProperty(result, 'item'); + }); + + it('should not score or accept loaded candidates after their load exhausts the budget', async function () { + await distractors(); + let matcher = BibliographyMatcher; + observations.stub(matcher, 'MAX_EVALUATION_DURATION_MS').value(2000); + let load = matcher._loadLibraryCandidates; + observations.stub(matcher, '_loadLibraryCandidates').callsFake(async function (...args) { + let result = await load.apply(this, args); + matcher.MAX_EVALUATION_DURATION_MS = 0; + return result; + }); + let evaluate = observations.spy(matcher, '_evaluate'); + let result = await matcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'incomplete'); + assert.isFalse(evaluate.called); + assert.notProperty(result, 'item'); + }); + + it('should preserve competitive field incompleteness after finishing the candidate union', async function () { + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + await distractors(); + await createItem(article({ + title: article().title, + creators, + publicationTitle: 'x'.repeat(BibliographyMatcher.MAX_MATCH_TEXT_LENGTH + 1), + })); + let result = await BibliographyMatcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'metadata-limit'); + assert.equal(scoring.callCount, 11); + assert.notProperty(result, 'item'); + }); + + it('should yield to an event-loop cancellation while scoring candidates', async function () { + await distractors(); + let matcher = BibliographyMatcher; + let controller = new AbortController(); + let evaluate = matcher._evaluate; + let evaluated = 0; + observations.stub(matcher, '_evaluate').callsFake(function (...args) { + if (++evaluated === 1) { + setTimeout(() => controller.abort(), 0); + // Ensure this pass crosses the cooperative yield interval even on fast hosts. + let started = Date.now(); + while (Date.now() - started < 20) {} + } + return evaluate.apply(this, args); + }); + let error = await getPromiseError(matcher.matchLibrary(reference, Zotero.Libraries.userLibraryID, + { signal: controller.signal })); + assert.equal(error?.name, 'AbortError'); + assert.isBelow(evaluated, matcher.CANDIDATE_BATCH_SIZE); + }); + + it('should stop before evaluating the next batch when its load is cancelled', async function () { + await distractors(); + let matcher = BibliographyMatcher; + let controller = new AbortController(); + let load = matcher._loadLibraryCandidates; + let calls = 0; + observations.stub(matcher, '_loadLibraryCandidates').callsFake(async function (...args) { + let result = await load.apply(this, args); + if (++calls === 2) controller.abort(); + return result; + }); + let evaluate = observations.spy(matcher, '_evaluate'); + let error = await getPromiseError(matcher.matchLibrary(reference, Zotero.Libraries.userLibraryID, + { signal: controller.signal })); + assert.equal(error?.name, 'AbortError'); + assert.equal(evaluate.callCount, matcher.CANDIDATE_BATCH_SIZE); + }); + + it('should account for a candidate deleted between retrieval and loading', async function () { + let scoring = observations.spy(BibliographyMatcher, '_evaluate'); + await distractors(); + let removed = items[0]; + let matcher = BibliographyMatcher; + let load = matcher._loadLibraryCandidates; + let calls = 0; + observations.stub(matcher, '_loadLibraryCandidates').callsFake(async function (...args) { + if (++calls === 1) { + removed.deleted = true; + await removed.saveTx(); + } + return load.apply(this, args); + }); + let result = await matcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'unmatched'); + assert.equal(scoring.callCount, 9); + }); + + it('should reuse identifier-route evaluations while extending the candidate union', async function () { + await distractors(); + let matcher = BibliographyMatcher; + observations.stub(matcher, '_searchByIdentifiers').resolves({ ids: [items[0].id], incomplete: false }); + let evaluate = observations.spy(matcher, '_evaluate'); + let result = await matcher.matchLibrary(reference, Zotero.Libraries.userLibraryID); + assert.equal(result.status, 'unmatched'); + assert.equal(evaluate.callCount, 10); + }); + }); + + it("should never truncate an overflowing transient candidate set into a winner", function () { + let candidates = Array.from( + { length: BibliographyMatcher.MAX_CANDIDATES + 1 }, + () => article() + ); + let result = evaluate('Reliable matching across diverse bibliographies', candidates); + + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'candidate-overflow'); + }); + + it("should not discard an unreadable transient competitor and certify the remaining candidate", function () { + for (let invalid of [null, { itemType: 'unknown-item-type' }]) { + let result = evaluate('DOI: 10.1234/unreadable-pool', [article({ DOI: '10.1234/unreadable-pool' }), invalid]); + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'invalid-candidate'); + assert.notProperty(result, 'item'); + } + }); + + it("should reject an excessive identifier probe set before querying", async function () { + let reference = Array.from( + { length: BibliographyMatcher.MAX_IDENTIFIER_PROBES + 1 }, + (_, index) => `PMID: ${100000 + index}` + ).join('; '); + let queries = ['queryAsync', 'columnQueryAsync'].map(method => observations.spy(Zotero.DB, method)); + let result = await BibliographyMatcher.matchLibrary( + reference, + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.status, 'incomplete'); + assert.equal(result.reason, 'retrieval-limit'); + assert.isTrue(queries.every(query => !query.called)); + }); + + it("should reject oversized references before querying", async function () { + let queries = ['queryAsync', 'columnQueryAsync'].map(method => observations.spy(Zotero.DB, method)); + let result = await BibliographyMatcher.matchLibrary( + 'x'.repeat(BibliographyMatcher.MAX_MATCH_TEXT_LENGTH + 1), + Zotero.Libraries.userLibraryID + ); + + assert.equal(result.reason, 'invalid-input'); + assert.isTrue(queries.every(query => !query.called)); + }); +}); diff --git a/test/tests/readerReferencesTest.js b/test/tests/readerReferencesTest.js new file mode 100644 index 0000000000..d0d1f8394f --- /dev/null +++ b/test/tests/readerReferencesTest.js @@ -0,0 +1,4108 @@ +"use strict"; + +var { BibliographyMatcher } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/bibliographyMatcher.mjs'); +var { ReaderReferenceSession, referenceSaver, onlineReferenceLookup } = ChromeUtils.importESModule('chrome://zotero/content/xpcom/readerReferences.mjs'); + +describe("Reader reference sessions and actions", function () { + let sandbox; + let objects; + let sessions; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + objects = []; + sessions = []; + sandbox.stub(onlineReferenceLookup, '_translateLandingPage').resolves([]); + }); + + afterEach(async function () { + for (let session of sessions) session.dispose(); + sandbox.restore(); + Zotero.Prefs.clear('downloadAssociatedFiles'); + + for (let object of objects.reverse()) { + if (object.id && Zotero.Items.exists(object.id)) { + await object.eraseTx(); + } + } + }); + + function stubOnlineCandidates(items) { + return sandbox.stub(onlineReferenceLookup, 'findOnline').callsFake(async reference => ( + BibliographyMatcher.matchCandidates(reference, items) + )); + } + + async function createItem(data = {}) { + let item = new Zotero.Item(data.itemType || 'journalArticle'); + item.libraryID = Zotero.Libraries.userLibraryID; + item.fromJSON({ + itemType: 'journalArticle', + title: 'Reliable matching across diverse bibliographies', + creators: [ + { creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + ], + date: '2021', + publicationTitle: 'Journal of Bibliographic Systems', + DOI: '10.1234/general.42', + url: 'https://example.invalid/article', + ...data, + }); + await item.saveTx(); + objects.push(item); + return item; + } + + async function createAttachment() { + let attachment = new Zotero.Item('attachment'); + attachment.libraryID = Zotero.Libraries.userLibraryID; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_URL; + attachment.attachmentContentType = 'application/pdf'; + attachment.setField('title', 'Source PDF'); + attachment.setField('url', 'https://example.invalid/source.pdf'); + await attachment.saveTx(); + objects.push(attachment); + return attachment; + } + + function strictMatch(item) { + return { status: 'matched', item }; + } + + function unmatched() { + return { status: 'unmatched', reason: 'no-candidates' }; + } + + function cacheMatch(session, text, match) { + session._onlineMatchCache.set(text, JSON.stringify(BibliographyMatcher.createCandidateSnapshot(match.item))); + } + + function bindingFor(session, resultID) { + return [...session._references.values()].find(reference => reference.result?.resultID === resultID)?.result; + } + + it("should preserve lookup failures and distinguish them from a completed no-match", async function () { + let matcher = sandbox.stub(BibliographyMatcher, 'matchLibrary'); + let session = await createSession(); + for (let [match, expected] of [ + [{ status: 'unmatched', reason: 'no-candidates' }, 'no-match'], + [{ status: 'unmatched', reason: 'invalid-input' }, 'invalid'], + ...['incomplete', 'offline', 'rate-limited', 'timeout', 'cancelled', 'error', 'invalid'] + .map(status => [{ status }, status]), + [{ status: 'unexpected' }, 'error'], + ]) { + matcher.resolves({ ...match }); + let response = await session.matchReference({ requestID: 'lookup-outcome', sourceKey: 'reference', + referenceParts: [{ text: 'A bibliographic reference' }] }); + assert.equal(response.status, expected); + assert.notProperty(response, 'result'); + } + }); + + function assertDisplayTextSafety(response) { + let result = response.result; + if (!result) return; + let display = result.display; + assert.notProperty(display, 'url'); + assert.notProperty(result, 'viewURL'); + assert.notProperty(result, 'evidence'); + for (let value of [display.title, display.firstCreator]) { + assert.isAtMost(String(value ?? '').length, ReaderReferenceSession.MAX_DISPLAY_FIELD_LENGTH); + assert.notMatch(String(value ?? ''), /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u); + } + assert.isAtMost((display.abstract || '').length, ReaderReferenceSession.MAX_ABSTRACT_LENGTH); + assert.notMatch(display.abstract || '', /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/u); + } + + async function createSession() { + let attachment = await createAttachment(); + let session = new ReaderReferenceSession(attachment); + sessions.push(session); + return session; + } + + it("should leave chapter fallbacks available and cache only the verified online work", async function () { + let book = await createItem({ + itemType: 'book', title: 'The Ethics of Belief', date: '2014', DOI: '', url: '', + publisher: 'Oxford University Press', creators: [ + { creatorType: 'editor', firstName: 'Jonathan', lastName: 'Matheson' }, + { creatorType: 'editor', firstName: 'Rico', lastName: 'Vitz' }, + ], + }); + let provider = sandbox.stub(onlineReferenceLookup, '_translateSearch'); + for (let [firstName, lastName, title, text] of [ + [ + 'Dennis', + 'Whitcomb', + 'Can There Be a Knowledge-First Ethics of Belief?', + 'Whitcomb, D. (2014). Can There Be a Knowledge-First Ethics of Belief? ' + + 'In: Matheson, J.; Vitz, R. (eds.), The Ethics of Belief. Oxford: Oxford University Press. pp. 93-112.', + ], + [ + 'Jonathan', + 'Matheson', + 'Evidence and epistemic duty in recent debates', + 'Matheson, J. (2014). Evidence and epistemic duty in recent debates. ' + + 'In J. Matheson & R. Vitz (Eds.), The Ethics of Belief (pp. 93–112). Oxford University Press.', + ], + ]) { + let chapter = { + itemType: 'bookSection', title, bookTitle: book.getField('title'), date: '2014', pages: '93-112', + creators: [{ creatorType: 'author', firstName, lastName }], + }; + let referenceParts = [{ text }]; + provider.resolves([book.toJSON()]).resetHistory(); + let session = await createSession(); + let local = await session.matchReference({ requestID: 'chapter-local', sourceKey: 'chapter', referenceParts }); + assert.equal(local.status, 'no-match'); + assert.notProperty(local, 'result'); + assert.isFalse(provider.called, 'Local lookup never starts the online fallback'); + let online = await session.findOnline({ requestID: 'chapter-online', referenceKey: local.referenceKey }); + assert.equal(online.status, 'no-match'); + assert.equal(session._onlineMatchCache.size, 0, 'The containing book must not be cached'); + provider.resolves([{ ...book.toJSON(), creators: [] }, chapter]); + let found = await session.findOnline({ requestID: 'chapter-correct', referenceKey: local.referenceKey }); + assert.equal(found.status, 'strong'); + assert.equal(found.result.display.title, chapter.title); + assert.equal(session._onlineMatchCache.size, 1); + let reopened = await session.matchReference({ requestID: 'chapter-reopen', sourceKey: 'chapter', referenceParts }); + assert.equal(reopened.status, 'strong'); + assert.equal(reopened.lookupSource, 'online'); + assert.equal(reopened.result.display.title, chapter.title); + assert.equal(provider.callCount, 2, 'Reopening reuses only the verified chapter metadata'); + } + }); + + it("should classify lookup errors consistently", function () { + let transport = (type, fields = {}) => Object.assign(Object.create(Zotero.HTTP[type].prototype), fields); + for (let [error, expected] of [ + [null, 'error'], + [undefined, 'error'], + [new Error('ordinary failure'), 'error'], + [transport('CancelledException', { status: 429 }), 'cancelled'], + [{ name: 'AbortError', status: 408 }, 'cancelled'], + [transport('TimeoutException', { status: 429 }), 'timeout'], + [transport('BrowserOfflineException', { status: 429 }), 'offline'], + [transport('UnexpectedStatusException', { status: 429 }), 'rate-limited'], + [transport('UnexpectedStatusException', { status: 408 }), 'timeout'], + [transport('UnexpectedStatusException', { status: 500 }), 'error'], + [{ status: 429 }, 'rate-limited'], + [{ status: 408 }, 'timeout'], + [{ name: 'TimeoutError' }, 'timeout'], + [{ name: 'TimeoutError', status: 429 }, 'rate-limited'], + [transport('UnexpectedStatusException', { name: 'TimeoutError', status: 500 }), 'timeout'], + [{ status: '429' }, 'error'], + ]) { + assert.equal(onlineReferenceLookup.errorStatus(error), expected); + } + }); + + async function waitForCacheOperation(callback) { + for (let attempt = 0; attempt < 100; attempt++) { + if (callback()) return; + await Zotero.Promise.delay(0); + } + throw new Error('Cache operation did not reach its asynchronous boundary'); + } + + it("should resolve reference lookup and action messages from Zotero's Fluent resources", function () { + let keys = ['popup', + 'expand', + 'collapse', + 'no-match', + 'error', + 'retry', + 'find-online', + 'existing', + 'action-add-to-library', + 'action-open-attachment'] + .map(key => `reader-reference-${key}`); + for (let key of [...keys, 'general-back', 'general-loading', 'general-show-in-library', 'view-online']) { + let value = Zotero.ftl.formatValueSync(key); + assert.isString(value); + assert.isNotEmpty(value); + assert.notEqual(value, key); + } + }); + + it("should accept type-mapped titles in plain metadata and native snapshots", function () { + for (let itemType of ['case', 'email', 'statute']) { + let field = Zotero.ItemFields.getName(Zotero.ItemFields.getFieldIDFromTypeAndBase(itemType, 'title')); + let input = { itemType, [field]: `Mapped ${itemType} title` }; + let item = new Zotero.Item(itemType); + item.fromJSON(input); + let snapshot = BibliographyMatcher.createCandidateSnapshot(input); + assert.equal(snapshot[field], input[field]); + assert.deepEqual(BibliographyMatcher.createCandidateSnapshot(item), snapshot); + assert.deepEqual(BibliographyMatcher.createCandidateSnapshot({ itemType, title: input[field] }), snapshot); + assert.throws(() => BibliographyMatcher.createCandidateSnapshot({ itemType }), 'required bibliographic fields'); + } + }); + + it("should bound total serialized candidate metadata rather than individual fields or creator count", function () { + let limit = BibliographyMatcher.MAX_CANDIDATE_JSON_LENGTH; + let fields = Zotero.ItemFields.getAll(); + // Reinitializing Zotero's field catalog can repeat allowlisted names. + sandbox.stub(Zotero.ItemFields, 'getAll').returns([...fields, ...fields]); + for (let metadata of [ + { abstractNote: 'x'.repeat(limit / 2), title: 'y'.repeat(limit / 2) }, + { abstractNote: '\\'.repeat(limit / 2) }, + { creators: [{ creatorType: 'author', firstName: 'A'.repeat(limit / 2), lastName: 'B'.repeat(limit / 2) }] }, + { creators: Array.from({ length: limit / 2 }, () => ({})) }, + ]) { + assert.throws(() => BibliographyMatcher.createCandidateSnapshot({ + itemType: 'journalArticle', title: 'Payload budget', ...metadata, + }), 'candidate payload limit'); + } + let allowed = { itemType: 'journalArticle', title: 'Complete fields', + abstractNote: 'a'.repeat(limit / 4), creators: [{ creatorType: 'author', name: 'A research collaboration' }] }; + assert.equal(BibliographyMatcher.createCandidateSnapshot(allowed).abstractNote, allowed.abstractNote); + let boundary = BibliographyMatcher.createCandidateSnapshot({ ...allowed, abstractNote: 'x' }); + boundary.abstractNote = 'x'.repeat(limit - JSON.stringify(boundary).length + 1); + assert.equal(JSON.stringify(boundary).length, limit); + assert.deepEqual(BibliographyMatcher.createCandidateSnapshot(boundary), boundary); + assert.throws(() => BibliographyMatcher.createCandidateSnapshot({ ...boundary, abstractNote: boundary.abstractNote + 'x' }), + 'candidate payload limit'); + }); + + it("should snapshot canonical Items without rebuilding them or retaining non-bibliographic data", function () { + let input = { itemType: 'journalArticle', title: 'Canonical snapshot', DOI: '10.1234/snapshot', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }], + tags: [{ tag: 'Not metadata' }], collections: [], relations: {}, }; + let expected = BibliographyMatcher.createCandidateSnapshot(input); + let item = new Zotero.Item(input.itemType); + item.fromJSON(input); + let original = item.toJSON(); + let fromJSON = sinon.spy(Zotero.Item.prototype, 'fromJSON'); + try { + let snapshot = BibliographyMatcher.createCandidateSnapshot(item); + assert.deepEqual(snapshot, expected); + assert.isFalse(fromJSON.called); + assert.notProperty(snapshot, 'tags'); + assert.notProperty(snapshot, 'collections'); + assert.notProperty(snapshot, 'relations'); + snapshot.title = 'Changed snapshot'; + snapshot.creators[0].firstName = 'Changed creator'; + assert.deepEqual(item.toJSON(), original); + item.setField('abstractNote', 'x'.repeat(BibliographyMatcher.MAX_CANDIDATE_JSON_LENGTH)); + assert.throws(() => BibliographyMatcher.createCandidateSnapshot(item), 'candidate payload limit'); + assert.throws(() => BibliographyMatcher.createCandidateSnapshot(new Zotero.Item('attachment')), + 'not a bibliographic item'); + } + finally { + fromJSON.restore(); + } + }); + + it("should preserve first-class PMID candidate identity", function () { + let candidate = { + itemType: 'journalArticle', + title: 'Biodiversity Loss and Its Impact on Humanity', + PMID: '22678280', + }; + let sanitized = BibliographyMatcher.createCandidateSnapshot(candidate); + + assert.equal(sanitized.PMID, '22678280'); + assert.include(BibliographyMatcher.getCandidateKey(sanitized), '22678280'); + assert.lengthOf(BibliographyMatcher.deduplicateCandidates([ + candidate, + { ...candidate, title: 'Different metadata for the same PMID' }, + ]), 1); + }); + + it("should withhold tentative local candidates instead of displaying a browsable list", async function () { + let item = await createItem({ DOI: '' }); + let session = await createSession(); + let response = await session.matchReference({ requestID: 'weak', sourceKey: 'reference', + referenceParts: [{ text: item.getField('title') }] }); + assert.equal(response.status, 'no-match'); + assert.notProperty(response, 'result'); + assert.notProperty(session._references.get(response.referenceKey), 'result'); + }); + + it("should match a first-class PMID without exposing unused display identifiers", async function () { + let item = await createItem({ DOI: '', url: '', PMID: '22678280' }); + let session = await createSession(); + let prepareReference = sandbox.spy(BibliographyMatcher, 'prepareReference'); + + let response = await session.matchReference({ + requestID: 'local-pmid', + sourceKey: 'reference-pmid', + referenceParts: [{ text: 'PMID: 22678280' }], + }); + + assert.equal(response.status, 'strong'); + assert.equal(response.result.libraryState, 'existing'); + assert.notProperty(response.result.display, 'identifiers'); + assert.equal(response.result.display.title, item.getField('title')); + assert.isTrue(prepareReference.calledOnce, 'The session must not repeat the matcher\'s citation analysis'); + }); + + it("should propagate independent local lookup signals and suppress normal cancellation errors", async function () { + let session = await createSession(); + let pending = []; + sandbox.stub(BibliographyMatcher, 'matchLibrary').callsFake((reference, libraryID, options) => new Promise((resolve, reject) => { + pending.push({ signal: options?.signal, resolve, reject }); + options?.signal?.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')), { once: true }); + })); + let log = sandbox.spy(Zotero, 'logError'); + let first = session.matchReference({ requestID: 'cancel-local-first', sourceKey: 'primary:first', referenceParts: [{ text: 'First reference' }] }); + let second = session.matchReference({ requestID: 'cancel-local-second', sourceKey: 'secondary:second', referenceParts: [{ text: 'Second reference' }] }); + try { + await waitForCacheOperation(() => pending.length === 2); + assert.isOk(pending[0].signal); + assert.isOk(pending[1].signal); + assert.notStrictEqual(pending[0].signal, pending[1].signal); + session.cancel({ requestID: 'cancel-local-first' }); + assert.isTrue(pending[0].signal.aborted); + assert.isFalse(pending[1].signal.aborted); + pending[1].resolve(unmatched()); + assert.equal((await first).status, 'cancelled'); + assert.equal((await second).status, 'no-match'); + assert.isFalse(log.called); + } + finally { + session.dispose(); + for (let operation of pending) operation.resolve(unmatched()); + await Promise.allSettled([first, second]); + } + }); + + it("should keep popup-open matching local and return only sanitized DTOs", async function () { + let item = await createItem(); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(item)); + let translate = sandbox.spy(Zotero.Translate.Search.prototype, 'translate'); + let http = sandbox.spy(Zotero.HTTP, 'request'); + + let response = await session.matchReference({ + requestID: 'local-1', + sourceKey: 'page-3-reference-7', + referenceParts: [{ + text: 'Lovelace. 2021. Reliable matching. ', + attributes: { url: 'https://doi.org/10.1234/general.42' }, + }], + }); + + assert.equal(response.status, 'strong'); + assert.equal(response.requestID, 'local-1'); + assert.hasAllKeys(response, ['requestID', 'referenceKey', 'status', 'result']); + assert.hasAllKeys(response.result, ['resultID', 'libraryState', 'actions', 'display']); + assert.hasAllKeys(response.result.display, ['itemType', 'title', 'firstCreator', 'year']); + assert.equal(response.result.display.title, item.getField('title')); + assert.equal(response.result.display.firstCreator, item.getField('firstCreator')); + assert.notProperty(response, 'sourceKey'); + assert.match(response.referenceKey, /^[0-9a-f-]+$/i); + assert.isObject(response.result); + assert.notProperty(response.result.display, 'identifiers'); + assert.notProperty(response.result.display, 'url'); + assert.notProperty(response.result, 'itemID'); + assert.notProperty(response.result, 'libraryID'); + assert.notInclude(JSON.stringify(response), 'https://example.invalid/article'); + assert.isFalse(translate.called); + assert.isFalse(http.called); + }); + + it("should reuse localized creator summaries for saved and unsaved items without changing creator data", async function () { + let session = await createSession(); + let author = lastName => ({ creatorType: 'author', firstName: 'Given', lastName }); + let cases = [ + { creators: [author('One')], expected: 'One' }, + { creators: [author('One'), author('Two')], + expected: Zotero.getString('general.andJoiner', ['\u2068One\u2069', '\u2068Two\u2069']) }, + { creators: [author('One'), author('Two'), author('Three')], expected: `One ${Zotero.getString('general.etAl')}` }, + { creators: [{ creatorType: 'author', name: 'Example Organization' }], expected: 'Example Organization' }, + { itemType: 'book', creators: [{ creatorType: 'editor', firstName: 'Ed', lastName: 'Editor' }], expected: 'Editor' }, + { itemType: 'film', creators: [{ creatorType: 'director', firstName: 'Film', lastName: 'Director' }], expected: 'Director' }, + { creators: [{ creatorType: 'contributor', firstName: 'A', lastName: 'Contributor' }], expected: 'Contributor' }, + { creators: [], expected: '' }, + ]; + for (let { expected, ...data } of cases) { + let saved = await createItem(data); + let unsaved = new Zotero.Item(saved.itemTypeID); + unsaved.fromJSON(saved.toJSON()); + for (let item of [saved, unsaved]) { + let creators = item.getCreators(); + let display = session._getDisplayMetadata(item); + assert.equal(display.firstCreator, expected); + assert.equal(display.firstCreator, item.getField('firstCreator')); + assert.deepEqual(item.getCreators(), creators); + assert.notProperty(display, 'creators'); + } + } + }); + + it("should use the standard display year without changing stored dates", async function () { + let session = await createSession(); + for (let date of ['1492', '2101', '2020-05-02', '']) { + let saved = await createItem({ date }); + let unsaved = new Zotero.Item(saved.itemTypeID); + unsaved.fromJSON(saved.toJSON()); + for (let item of [saved, unsaved]) { + let storedDate = item.getField('date', true, true); + assert.equal(session._getDisplayMetadata(item).year, item.getField('year')); + assert.equal(item.getField('date', true, true), storedDate); + if (date === '1492' || date === '2101') assert.equal(session._getDisplayMetadata(item).year, date); + } + } + }); + + it("should preserve item metadata as literal display text rather than extracted citation text", async function () { + let title = 'Research on Node.js systems'; + let abstract = 'Sources: https://example.org/data?q=1, editor@example.org. ' + + 'literal link ' + + ' x < y. Empty notation () [] {} stays.'; + let item = await createItem({ title, abstractNote: abstract }); + let stored = item.toJSON(); + let session = await createSession(); + let referenceParts = [{ text: 'Damaged PDF citation title. DOI: 10.1234/general.42' }]; + let network = ['request', 'doGet', 'doPost'].map(method => ( + sandbox.stub(Zotero.HTTP, method).throws(new Error('Display projection must not use HTTP')) + )); + let loadURI = sandbox.stub(); + let viewAttachment = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI, viewAttachment } }); + let provider = sandbox.spy(onlineReferenceLookup, 'findOnline'); + let response = await session.matchReference({ requestID: 'literal-item-display', + sourceKey: 'raw-pdf-reference', referenceParts }); + assert.equal(response.status, 'strong'); + assert.equal(response.result.display.title, title); + assert.equal(response.result.display.abstract, abstract); + assertDisplayTextSafety(response); + assert.equal(session._references.get(response.referenceKey).text, referenceParts.map(part => part.text).join('')); + assert.deepEqual(item.toJSON(), stored, 'Display projection must not alter stored metadata'); + assert.isTrue(network.every(method => !method.called)); + assert.isFalse(provider.called); + assert.isFalse(loadURI.called); + assert.isFalse(viewAttachment.called); + }); + + it("should preserve link-shaped text in every bibliographic display field", async function () { + let cases = [ + { + name: 'title', + data: { title: 'Readable title (HTTPS://Example.org/Title?q=1), retained' }, + field: 'title', + expected: 'Readable title (HTTPS://Example.org/Title?q=1), retained', + }, + { + name: 'creator', + data: { creators: [{ + creatorType: 'author', firstName: 'Ada', + lastName: 'Lovelace FTP://Files.Example.org/archive.zip.', + }] }, + field: 'firstCreator', + expected: 'Lovelace FTP://Files.Example.org/archive.zip.', + }, + { + name: 'abstract', + data: { + abstractNote: 'Tests at https://labelerrors.com and reproduced by ' + + 'https://github.com/cleanlab/label-errors. Evidence remains.', + }, + field: 'abstract', + expected: 'Tests at https://labelerrors.com and reproduced by ' + + 'https://github.com/cleanlab/label-errors. Evidence remains.', + }, + { + name: 'ordinary bibliography text', + data: { title: 'Ordinary et.al, J. Biol., and DOI 10.1234/example remain' }, + field: 'title', + expected: 'Ordinary et.al, J. Biol., and DOI 10.1234/example remain', + }, + { + name: 'protocol-relative Unicode punctuation', + data: { title: 'Mirror(//Example.org/path)。 retained' }, + field: 'title', + expected: 'Mirror(//Example.org/path)。 retained', + }, + { + name: 'supported title markup', + data: { title: 'Growth of Arabidopsis under CO2' }, + field: 'title', + expected: 'Growth of Arabidopsis under CO2', + }, + { + name: 'mathematical text within title markup', + data: { title: '0 < p ≤ 1 and Node.js' }, + field: 'title', + expected: '0 < p ≤ 1 and Node.js', + }, + { + name: 'literal markup outside Zotero title formatting', + data: { title: ' and text' }, + field: 'title', + expected: ' and text', + }, + { + name: 'abstract markup remains plain content', + data: { abstractNote: 'Evidence at https://example.org/' }, + field: 'abstract', + expected: 'Evidence at https://example.org/', + }, + { + name: 'control characters and whitespace', + data: {}, field: 'title', rawField: 'title', + rawValue: '\u0000Research\u0001 on\tNode.js\u007F systems\n', + expected: 'Research on Node.js systems', + }, + { + name: 'numeric zero', + data: {}, + field: 'year', + rawField: 'year', + rawValue: 0, + expected: '0', + }, + { + name: 'nullish value', + data: {}, + field: 'year', + rawField: 'year', + rawValue: null, + expected: '', + }, + ]; + + let session = await createSession(); + for (let testCase of cases) { + let item = new Zotero.Item('journalArticle'); + item.fromJSON({ itemType: 'journalArticle', ...testCase.data }); + if (testCase.rawField) { + let originalGetField = item.getField.bind(item); + sandbox.stub(item, 'getField').callsFake((field, ...args) => ( + field === testCase.rawField ? testCase.rawValue : originalGetField(field, ...args) + )); + } + let display = session._getDisplayMetadata(item); + assert.deepEqual(display[testCase.field], testCase.expected, testCase.name); + assertDisplayTextSafety({ result: { display } }); + } + }); + + it("should preserve complete container titles in every fallback position", function () { + for (let fields of [ + { itemType: 'journalArticle', publicationTitle: 'Journal of complete bibliographic metadata' }, + { itemType: 'bookSection', bookTitle: 'Collected studies of complete bibliographic metadata' }, + { itemType: 'conferencePaper', proceedingsTitle: 'Proceedings of complete bibliographic metadata' }, + { itemType: 'webpage', websiteTitle: 'Website of complete bibliographic metadata' }, + { itemType: 'blogPost', blogTitle: 'Blog of complete bibliographic metadata' }, + { itemType: 'preprint', repository: 'Repository of complete bibliographic metadata' }, + ]) { + let item = new Zotero.Item(fields.itemType); + item.fromJSON({ title: 'Container fallback', ...fields }); + let expected = Object.values(fields)[1]; + assert.equal(BibliographyMatcher._prepareMatchMetadata(item).container.raw, expected); + } + }); + + it("should bound large raw display fields before sanitizing them", async function () { + let item = await createItem({ DOI: '10.1234/bounded-display-input' }); + let originalGetField = item.getField.bind(item); + let oversized = `Readable https://example.org/private ${'x'.repeat(1_000_000)}`; + sandbox.stub(item, 'getField').callsFake((field, ...args) => ( + field === 'abstractNote' ? oversized : originalGetField(field, ...args) + )); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(item)); + let session = await createSession(); + let started = Date.now(); + let response = await session.matchReference({ + requestID: 'bounded-display-input', + sourceKey: 'bounded-display-input', + referenceParts: [{ text: 'DOI: 10.1234/bounded-display-input' }], + }); + let durationMS = Date.now() - started; + + assert.equal(response.status, 'strong'); + assert.isAtMost( + response.result.display.abstract.length, + ReaderReferenceSession.MAX_ABSTRACT_LENGTH + ); + assert.isTrue(response.result.display.abstract.endsWith('…')); + assertDisplayTextSafety(response); + assert.include(response.result.display.abstract, 'https://example.org/private'); + assert.isBelow(durationMS, 1000); + }); + + it("should preserve literal online, saved, and reopened display text while keeping operational URLs private", async function () { + let metadata = { + itemType: 'journalArticle', + title: 'URL boundary study HTTPS://Example.org/title retained', + creators: [{ + creatorType: 'author', firstName: 'Ada', + lastName: 'Lovelace ftp://files.example.org/creator', + }], + date: '2021', + publicationTitle: 'Journal www.example.net/container', + volume: '42 example.edu/volume', + issue: '7 mailto:editor@example.org', + pages: '10-20 https://example.dev/pages', + DOI: '10.1234/url-boundary-regression', + url: 'https://privileged.example/original', + abstractNote: 'Test set errors can be viewed at https://labelerrors.com and reproduced by ' + + 'https://github.com/cleanlab/label-errors. Mirror //example.org/results; ' + + 'Unicode adjacent(HTTPS://Example.org/資料)。', + }; + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON(metadata); + let attachment = await createAttachment(); + let session = new ReaderReferenceSession(attachment); + let reopenedSession; + let provider = stubOnlineCandidates([transient]); + let loadURI = sandbox.stub(); + let network = ['request', 'doGet', 'doPost'].map(method => ( + sandbox.stub(Zotero.HTTP, method).throws(new Error('Display lifecycle fixture must not use HTTP')) + )); + sandbox.stub(Zotero.Attachments, 'addFileFromURLs').resolves(false); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + try { + let local = await session.matchReference({ + requestID: 'url-boundary-local', + sourceKey: 'url-boundary-reference', + referenceParts: [{ text: `DOI: ${metadata.DOI}` }], + }); + assert.equal(local.status, 'no-match'); + + let online = await session.findOnline({ + requestID: 'url-boundary-online', + referenceKey: local.referenceKey, + }); + assert.equal(online.status, 'strong'); + assertDisplayTextSafety(online); + assert.equal(online.result.display.title, metadata.title); + assert.equal(online.result.display.abstract, metadata.abstractNote); + assert.isFalse(loadURI.called, 'Projecting literal URLs must not navigate'); + let hidden = bindingFor(session, online.result.resultID); + assert.equal(hidden.candidateJSON.abstractNote, metadata.abstractNote); + assert.equal(hidden.candidateJSON.url, metadata.url); + + let viewed = await session.performAction({ + requestID: 'url-boundary-view', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'view-online', + }); + assert.equal(viewed.status, 'ok'); + assert.isTrue(loadURI.calledOnceWithExactly(metadata.url)); + + let added = await session.performAction({ + requestID: 'url-boundary-add', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + let savedID = bindingFor(session, online.result.resultID)?.itemID; + let saved = savedID && await Zotero.Items.getAsync(savedID); + if (saved) objects.push(saved); + assert.equal(added.status, 'saved'); + assertDisplayTextSafety({ result: added.result }); + assert.equal(added.result.display.abstract, metadata.abstractNote); + for (let field of [ + 'title', 'publicationTitle', 'volume', 'issue', 'pages', 'abstractNote', 'url', + ]) { + assert.equal(saved.getField(field), metadata[field], `Saved ${field} was sanitized`); + } + assert.equal(saved.getCreators()[0].lastName, metadata.creators[0].lastName); + + reopenedSession = new ReaderReferenceSession(attachment); + let reopened = await reopenedSession.matchReference({ + requestID: 'url-boundary-reopened-local', + sourceKey: 'url-boundary-reopened-reference', + referenceParts: [{ text: `DOI: ${metadata.DOI}` }], + }); + assert.equal(reopened.status, 'strong'); + assertDisplayTextSafety(reopened); + assert.equal(reopened.result.display.title, metadata.title); + assert.equal(reopened.result.display.abstract, metadata.abstractNote); + assert.equal(provider.callCount, 1); + assert.isTrue(network.every(method => !method.called)); + } + finally { + reopenedSession?.dispose(); + session.dispose(); + } + }); + + it("should execute unsaved View Online without exposing its URL and reject cross-reference tokens", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', + title: 'Online-only result', + DOI: '10.1234/online-only', + url: 'https://example.invalid/online-only', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + + let first = await session.matchReference({ + requestID: 'local-view-1', + sourceKey: 'reference-view-1', + referenceParts: [{ text: 'doi:10.1234/online-only' }], + }); + let online = await session.findOnline({ + requestID: 'online-view-1', referenceKey: first.referenceKey, + }); + assert.include(online.result.actions, 'view-online'); + assert.notInclude(JSON.stringify(online), 'https://example.invalid/online-only'); + let viewed = await session.performAction({ + requestID: 'action-view-1', + referenceKey: first.referenceKey, + resultID: online.result.resultID, + action: 'view-online', + }); + assert.equal(viewed.status, 'ok'); + assert.notInclude(JSON.stringify(viewed), 'https://example.invalid/online-only'); + assert.isTrue(loadURI.calledOnceWithExactly('https://example.invalid/online-only')); + + let second = await session.matchReference({ + requestID: 'local-view-2', + sourceKey: 'reference-view-2', + referenceParts: [{ text: 'A different reference' }], + }); + let stale = await session.performAction({ + requestID: 'action-view-stale', + referenceKey: second.referenceKey, + resultID: online.result.resultID, + action: 'view-online', + }); + assert.equal(stale.status, 'stale'); + assert.isTrue(loadURI.calledOnce); + }); + + it("should reject unsafe View Online URLs and report navigation failures", async function () { + let session = await createSession(); + let referenceKey = session._replaceReference('navigation-safety', 'Reference'); + let binding = { resultID: 'result', referenceKey, actions: ['view-online'] }; + session._references.get(referenceKey).result = binding; + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + sandbox.stub(Zotero, 'logError'); + let request = { requestID: 'view', referenceKey, resultID: binding.resultID, action: 'view-online' }; + // eslint-disable-next-line no-script-url -- Verify that script URLs cannot reach navigation. + for (let url of ['file:///invalid-navigation', 'javascript:alert(1)', 'https://user:password@example.invalid/']) { + binding.viewURL = url; + assert.equal((await session.performAction(request)).status, 'stale', url); + } + assert.isFalse(loadURI.called); + binding.viewURL = 'https://example.invalid/navigation'; + loadURI.throws(new Error('Navigation failed')); + assert.equal((await session.performAction(request)).status, 'failed'); + assert.isTrue(loadURI.calledOnceWithExactly(binding.viewURL)); + loadURI.resetBehavior(); + assert.equal((await session.performAction(request)).status, 'ok'); + }); + + it("should reject actions during a save and allow navigation once it completes", async function () { + Zotero.Prefs.set('downloadAssociatedFiles', false); + let session = await createSession(); + let key = session._replaceReference('navigation-save', 'doi:10.1234/navigation-save'); + let result = { resultID: 'navigation-candidate', referenceKey: key, + actions: ['add-to-library', 'view-online'], + viewURL: 'https://example.invalid/navigation-save', candidateJSON: { + itemType: 'journalArticle', title: 'Navigation during save', DOI: '10.1234/navigation-save', + } }; + session._references.get(result.referenceKey).result = result; + let release; + let originalSave = referenceSaver.saveCandidate.bind(referenceSaver); + let save = sandbox.stub(referenceSaver, 'saveCandidate').callsFake(async (...args) => { + await new Promise((resolve) => { + release = resolve; + }); + let saved = await originalSave(...args); + objects.push(saved.item); + return saved; + }); + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + sandbox.stub(Zotero, 'logError'); + let request = { requestID: 'pending-add', referenceKey: key, resultID: result.resultID, action: 'add-to-library' }; + let pending = session.performAction(request); + try { + await waitForCacheOperation(() => release); + let reference = session._references.get(key); + let assertSavePending = () => { + assert.equal(reference.saving, request.requestID); + assert.isTrue(save.calledOnce); + }; + let view = { ...request, requestID: 'view-during-add', action: 'view-online' }; + for (let blocked of [request, view, { ...request, requestID: 'duplicate-add' }]) { + assert.equal((await session.performAction(blocked)).status, 'failed'); + assertSavePending(); + } + assert.isFalse(loadURI.called); + release(); + let saved = await pending; + assert.equal(saved.status, 'saved'); + assert.equal(saved.result.libraryState, 'existing'); + assert.include(saved.result.actions, 'show-in-library'); + assert.equal((await session.performAction(view)).status, 'ok'); + assert.isTrue(loadURI.calledOnce); + assert.isTrue(save.calledOnce); + } + finally { + release?.(); + await pending; + session.dispose(); + } + }); + + it("should keep actions blocked until saved metadata is ready", async function () { + let session = await createSession(); + let saved = await createItem(); + let key = session._replaceReference('hydrating-navigation', 'Candidate'); + let result = { resultID: 'hydrating-candidate', referenceKey: key, + actions: ['add-to-library', 'view-online'] }; + session._references.get(result.referenceKey).result = result; + let save = sandbox.stub(referenceSaver, 'saveCandidate').resolves({ item: saved, existing: false }); + let release; + let originalDTO = session._createLibraryResult.bind(session); + let hydrate = sandbox.stub(session, '_createLibraryResult').callsFake(async (...args) => { + if (hydrate.callCount === 1) { + await new Promise((resolve) => { + release = resolve; + }); + } + return originalDTO(...args); + }); + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + let request = { requestID: 'hydrating-add', referenceKey: key, resultID: result.resultID, action: 'add-to-library' }; + let pending = session.performAction(request); + try { + await waitForCacheOperation(() => release); + assert.equal(result.itemID, saved.id); + let reference = session._references.get(key); + let navigation = await session.performAction({ ...request, requestID: 'hydrating-view', action: 'view-online' }); + assert.equal(navigation.status, 'failed'); + assert.isFalse(loadURI.called); + assert.equal(reference.saving, request.requestID); + assert.isTrue(save.calledOnce); + release(); + let response = await pending; + assert.equal(response.status, 'saved'); + assert.equal(response.result.libraryState, 'existing'); + assert.include(response.result.actions, 'show-in-library'); + } + finally { + release?.(); + await pending; + session.dispose(); + } + }); + + it("should reject View Online after disposal without cancelling a delegated save", async function () { + let session = await createSession(); + let saved = await createItem(); + let key = session._replaceReference('disposed-navigation', 'Candidate'); + session._references.get(key).result = { resultID: 'candidate', referenceKey: key, + actions: ['add-to-library', 'view-online'], + viewURL: 'https://example.invalid/article' }; + let release; + sandbox.stub(referenceSaver, 'saveCandidate').callsFake(() => new Promise((resolve) => { + release = resolve; + })); + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + let request = { requestID: 'disposed-add', referenceKey: key, resultID: 'candidate', action: 'add-to-library' }; + let pending = session.performAction(request); + try { + await waitForCacheOperation(() => release); + let reference = session._references.get(key); + session.dispose(); + assert.equal((await session.performAction({ ...request, requestID: 'disposed-view', action: 'view-online' })).status, 'stale'); + assert.isFalse(loadURI.called); + assert.equal(reference.saving, request.requestID); + release({ item: saved, existing: false }); + assert.equal((await pending).status, 'saved'); + } + finally { + session.dispose(); + release?.({ item: saved, existing: false }); + await pending; + } + }); + + it("should roll back a local result superseded during asynchronous DTO hydration", async function () { + let item = await createItem(); + let session = await createSession(); + let releaseHydration; + let hydrationStarted; + let started = new Promise(resolve => hydrationStarted = resolve); + sandbox.stub(item, 'getBestAttachment').callsFake(() => { + hydrationStarted(); + return new Promise(resolve => releaseHydration = resolve); + }); + sandbox.stub(BibliographyMatcher, 'matchLibrary') + .onFirstCall().resolves(strictMatch(item)) + .onSecondCall() +.resolves(unmatched()); + + let firstPromise = session.matchReference({ + requestID: 'hydrate-1', + sourceKey: 'hydrated-reference', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + await started; + let second = await session.matchReference({ + requestID: 'hydrate-2', + sourceKey: 'hydrated-reference', + referenceParts: [{ text: 'Replacement reference' }], + }); + releaseHydration(null); + let first = await firstPromise; + + assert.equal(first.status, 'cancelled'); + assert.equal(second.status, 'no-match'); + assert.equal([...session._references.values()].filter(reference => reference.result).length, 0); + assert.equal(session._references.size, 1); + }); + + it("should publish no result when native metadata hydration fails", async function () { + let item = await createItem(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(item)); + sandbox.stub(item, 'getBestAttachment').rejects(new Error('Hydration failed')); + let session = await createSession(); + let response = await session.matchReference({ requestID: 'hydrate', sourceKey: 'reference', + referenceParts: [{ text: item.getField('title') }] }); + assert.equal(response.status, 'error'); + assert.notProperty(session._references.get(response.referenceKey), 'result'); + assert.equal(session._operations.size, 0); + }); + it("should bind result tokens to one reference and reject evicted tokens", async function () { + let item = await createItem({ url: '' }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(item)); + + let first = await session.matchReference({ + requestID: 'local-1', + sourceKey: 'reference-1', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + let second = await session.matchReference({ + requestID: 'local-2', + sourceKey: 'reference-2', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + + let leaked = await session.performAction({ + requestID: 'action-1', + referenceKey: second.referenceKey, + resultID: first.result.resultID, + action: 'show-in-library', + }); + assert.deepEqual(leaked, { + requestID: 'action-1', + referenceKey: second.referenceKey, + status: 'stale', + }); + + delete session._references.get(first.referenceKey).result; + let evicted = await session.performAction({ + requestID: 'action-2', + referenceKey: first.referenceKey, + resultID: first.result.resultID, + action: 'show-in-library', + }); + assert.equal(evicted.status, 'stale'); + }); + + it("should execute existing-item navigation without rebuilding popup metadata", async function () { + let item = await createItem({ DOI: '' }); + let attachment = await createAttachment(); + attachment.parentID = item.id; + attachment.attachmentContentType = 'text/plain'; + await attachment.saveTx(); + assert.isFalse(!!attachment.attachmentReaderType); + let session = await createSession(); + let originalGet = Zotero.Libraries.get; + sandbox.stub(Zotero.Libraries, 'get').callsFake(libraryID => ( + libraryID === session._libraryID + ? { editable: true, filesEditable: true } + : originalGet.call(Zotero.Libraries, libraryID) + )); + let getBestAttachment = sandbox.stub(item, 'getBestAttachment').resolves(attachment); + let canFindFile = sandbox.spy(Zotero.Attachments, 'canFindFileForItem'); + let addAvailableFile = sandbox.stub(Zotero.Attachments, 'addAvailableFile'); + let selectItem = sandbox.stub().resolves(); + let viewAttachment = sandbox.stub().resolves(); + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ + ZoteroPane: { selectItem, viewAttachment, loadURI }, + }); + let hydrate = sandbox.spy(session, '_createLibraryResult'); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(item)); + let onlineProvider = sandbox.spy(onlineReferenceLookup, 'findOnline'); + let translate = sandbox.spy(Zotero.Translate.Search.prototype, 'translate'); + let http = sandbox.spy(Zotero.HTTP, 'request'); + + let local = await session.matchReference({ + requestID: 'local-existing-actions', + sourceKey: 'reference-existing-actions', + referenceParts: [{ text: 'Lovelace. 2021. Reliable matching.' }], + }); + let result = local.result; + assert.deepEqual(result.actions, [ + 'open-attachment', + 'show-in-library', + 'view-online', + ]); + getBestAttachment.resetHistory(); + + for (let [action, requestID] of [ + ['show-in-library', 'action-show-existing'], + ['open-attachment', 'action-open-existing'], + ['view-online', 'action-view-existing'], + ]) { + let response = await session.performAction({ + requestID, + referenceKey: local.referenceKey, + resultID: result.resultID, + action, + }); + assert.deepEqual(response, { + requestID, + referenceKey: local.referenceKey, + status: 'ok', + }); + } + + assert.isTrue(selectItem.calledOnceWithExactly(item.id)); + assert.isTrue(viewAttachment.calledOnceWithExactly(attachment.id)); + assert.isTrue(loadURI.calledOnceWithExactly(item.getField('url'))); + assert.isTrue(hydrate.calledOnce, 'Only the lookup builds popup metadata'); + assert.isFalse(addAvailableFile.called); + assert.isTrue(getBestAttachment.calledOnce, 'Only Open Attachment queries the attachment'); + assert.isTrue(getBestAttachment.alwaysCalledOn(item)); + assert.isFalse(canFindFile.called); + assert.isFalse(onlineProvider.called); + assert.isFalse(translate.called); + assert.isFalse(http.called); + }); + + it("should prioritize attachment opening without changing the remaining action order", async function () { + let item = await createItem(); + let attachment = await createAttachment(); + let session = await createSession(); + let best = sandbox.stub(item, 'getBestAttachment').resolves(null); + let withoutAttachment = await session._createLibraryResult('without-attachment', item); + assert.deepEqual(withoutAttachment.actions, ['show-in-library', 'view-online']); + best.resolves(attachment); + let withAttachment = await session._createLibraryResult('with-attachment', item); + assert.deepEqual(withAttachment.actions, ['open-attachment', 'show-in-library', 'view-online']); + }); + + it("should reject stale parent and attachment state after asynchronous attachment selection", async function () { + for (let mutation of ['parent-deleted', 'attachment-deleted', 'attachment-reparented']) { + let item = await createItem({ DOI: '', url: '' }); + let attachment = await createAttachment(); + attachment.parentID = item.id; + await attachment.saveTx(); + let session = await createSession(); + let key = session._replaceReference('stale-item', item.getField('title')); + let result = await session._createLibraryResult('fixture', item); + session._references.get(key).result = { referenceKey: key, resultID: result.resultID, + itemID: item.id, actions: result.actions }; + // Linked-URL fixture is not selected by the real best-attachment SQL; + // grant the already-advertised action and control only the async lookup. + bindingFor(session, result.resultID).actions.push('open-attachment'); + let release, start; + let started = new Promise(resolve => start = resolve); + let best = sandbox.stub(item, 'getBestAttachment').resolves(attachment); + best.onFirstCall().callsFake(() => { + start(); + return new Promise(resolve => release = resolve); + }); + let viewAttachment = sandbox.stub().resolves(); + let windowStub = sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { viewAttachment } }); + let pending = session.performAction({ requestID: 'open-stale', referenceKey: key, + resultID: result.resultID, action: 'open-attachment' }); + await started; + if (mutation === 'parent-deleted') { + item.deleted = true; await item.saveTx(); + } + if (mutation === 'attachment-deleted') { + attachment.deleted = true; await attachment.saveTx(); + } + if (mutation === 'attachment-reparented') { + attachment.parentID = false; await attachment.saveTx(); + } + release(attachment); + assert.equal((await pending).status, 'stale', mutation); + assert.isFalse(viewAttachment.called, mutation); + best.restore(); windowStub.restore(); session.dispose(); + } + }); + + it("should return stale when a pending existing-item action loses its bound result", async function () { + let item = await createItem({ DOI: '', url: '' }); + let session = await createSession(); + sandbox.stub(item, 'getBestAttachment').resolves(null); + sandbox.stub(BibliographyMatcher, 'matchLibrary') + .onFirstCall().resolves(strictMatch(item)) + .onSecondCall() +.resolves(unmatched()); + let releaseSelection; + let selectionStarted; + let started = new Promise(resolve => selectionStarted = resolve); + let selectItem = sandbox.stub().callsFake(() => { + selectionStarted(); + return new Promise(resolve => releaseSelection = resolve); + }); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { selectItem } }); + let onlineProvider = sandbox.spy(onlineReferenceLookup, 'findOnline'); + let translate = sandbox.spy(Zotero.Translate.Search.prototype, 'translate'); + let http = sandbox.spy(Zotero.HTTP, 'request'); + + let local = await session.matchReference({ + requestID: 'local-pending-action', + sourceKey: 'reference-pending-action', + referenceParts: [{ text: 'Lovelace. 2021. Reliable matching.' }], + }); + let actionPromise = session.performAction({ + requestID: 'action-pending-show', + referenceKey: local.referenceKey, + resultID: local.result.resultID, + action: 'show-in-library', + }); + await started; + let replacement = await session.matchReference({ + requestID: 'local-replacement', + sourceKey: 'reference-pending-action', + referenceParts: [{ text: 'A replacement reference' }], + }); + releaseSelection(); + + assert.equal(replacement.status, 'no-match'); + assert.deepEqual(await actionPromise, { + requestID: 'action-pending-show', + referenceKey: local.referenceKey, + status: 'stale', + }); + assert.isTrue(selectItem.calledOnceWithExactly(item.id)); + assert.isFalse(onlineProvider.called); + assert.isFalse(translate.called); + assert.isFalse(http.called); + }); + + it("should reject an action the result does not advertise", async function () { + let item = await createItem(); + let session = await createSession(); + let key = session._replaceReference('unavailable-action', item.getField('title')); + let result = await session._createLibraryResult('fixture', item); + session._references.get(key).result = { referenceKey: key, resultID: result.resultID, + itemID: item.id, actions: result.actions }; + let loadItem = sandbox.spy(Zotero.Items, 'getAsync'); + assert.notInclude(result.actions, 'add-to-library'); + let response = await session.performAction({ requestID: 'unavailable-action', referenceKey: key, + resultID: result.resultID, action: 'add-to-library' }); + assert.equal(response.status, 'failed'); + assert.isFalse(loadItem.called); + }); + + it("should cancel stale local work and dispose pending lookups", async function () { + let item = await createItem(); + let session = await createSession(); + let pending = []; + sandbox.stub(BibliographyMatcher, 'matchLibrary').callsFake(() => ( + new Promise(resolve => pending.push(resolve)) + )); + + let firstPromise = session.matchReference({ + requestID: 'local-1', + sourceKey: 'same-reference', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + await waitForCacheOperation(() => pending.length === 1); + let secondPromise = session.matchReference({ + requestID: 'local-2', + sourceKey: 'same-reference', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + await waitForCacheOperation(() => pending.length === 2); + pending[1](strictMatch(item)); + let second = await secondPromise; + pending[0](strictMatch(item)); + let first = await firstPromise; + + assert.equal(second.status, 'strong'); + assert.equal(first.status, 'cancelled'); + + let thirdPromise = session.matchReference({ + requestID: 'local-3', + sourceKey: 'another-reference', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + await waitForCacheOperation(() => pending.length === 3); + session.dispose(); + pending[2](strictMatch(item)); + assert.equal((await thirdPromise).status, 'cancelled'); + }); + + it("should reject Add invalidated before persistence delegation", async function () { + let save = sandbox.stub(referenceSaver, 'saveCandidate').resolves(null); + for (let mutation of ['replacement', 'eviction', 'disposal']) { + let attachment = await createAttachment(); + let session = new ReaderReferenceSession(attachment); + let key = session._replaceReference('source', 'Candidate'); + session._references.get(key).result = { resultID: 'candidate', referenceKey: key, + actions: ['add-to-library'] }; + let release; + let original = Zotero.Items.getAsync; + let lookup = sandbox.stub(Zotero.Items, 'getAsync').callsFake(function (id, ...args) { + return id === attachment.id ? new Promise(resolve => release = resolve) : original.call(this, id, ...args); + }); + let pending = session.performAction({ requestID: 'add', referenceKey: key, + resultID: 'candidate', action: 'add-to-library' }); + assert.isFunction(release); + if (mutation === 'replacement') session._replaceReference('source', 'Replacement'); + if (mutation === 'eviction') session._removeReference(key); + if (mutation === 'disposal') session.dispose(); + release(attachment); + assert.equal((await pending).status, 'stale', mutation); + assert.isFalse(save.called, mutation); + lookup.restore(); session.dispose(); + } + }); + + it("should redact arbitrary online provider failures at the Reader boundary", async function () { + let citationSecret = 'Secret Author 2099 Private Citation'; + let urlSecret = 'https://metadata.invalid/private?citation=secret'; + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + sandbox.stub(onlineReferenceLookup, 'findOnline') + .rejects(new Error(`${citationSecret} ${urlSecret}`)); + let logError = sandbox.stub(Zotero, 'logError'); + let local = await session.matchReference({ + requestID: 'sensitive-local', + sourceKey: 'sensitive-reference', + referenceParts: [{ text: citationSecret }], + }); + + let response = await session.findOnline({ + requestID: 'sensitive-online', + referenceKey: local.referenceKey, + }); + let logged = logError.getCalls().flatMap(call => call.args).map(value => ( + `${String(value)}\n${value?.stack || ''}` + )) + .join('\n'); + + assert.equal(response.status, 'error'); + assert.isTrue(logError.calledOnce); + assert.include(logged, 'Reference provider request failed'); + assert.notInclude(logged, citationSecret); + assert.notInclude(logged, urlSecret); + }); + + it("should use the exact action response and finish a save after teardown", async function () { + let acquiredAttachment = await createAttachment(); + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', + title: 'Reliable matching across diverse bibliographies', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }], + date: '2021', + DOI: '10.1234/general.42', + attachments: [{ title: 'Must not survive', url: 'https://example.invalid/file.pdf' }], + unknownActiveField: 'Must not survive', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + let resolveSave; + let saveStarted = new Promise((resolve) => { + resolveSave = resolve; + }); + let save = sandbox.stub(referenceSaver, 'saveCandidate').returns(saveStarted); + + let local = await session.matchReference({ + requestID: 'local-1', + sourceKey: 'reference-1', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + let online = await session.findOnline({ + requestID: 'online-1', + referenceKey: local.referenceKey, + }); + let actionPromise = session.performAction({ + requestID: 'action-1', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + await Zotero.Promise.delay(0); + assert.isTrue(save.calledOnce); + let savedInput = save.firstCall.args[0]; + assert.notProperty(savedInput, 'attachments'); + assert.notProperty(savedInput, 'unknownActiveField'); + + session.dispose(); + let existingItem = await createItem(); + sandbox.stub(existingItem, 'getBestAttachment').resolves(acquiredAttachment); + resolveSave({ item: existingItem, existing: false }); + let action = await actionPromise; + assert.equal(action.status, 'saved'); + assert.property(action, 'result'); + assert.include(action.result.actions, 'open-attachment'); + assert.notProperty(action.result.display, 'url'); + }); + + it("should keep a saved metadata result actionable when no file was acquired", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', + title: 'Metadata-only result', + DOI: '10.1234/metadata-only', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + sandbox.stub(referenceSaver, 'saveCandidate') + .callsFake(async () => { + let savedItem = await createItem({ DOI: '10.1234/metadata-only' }); + sandbox.stub(savedItem, 'getBestAttachment').resolves(null); + return { item: savedItem, existing: false }; + }); + + let local = await session.matchReference({ + requestID: 'metadata-only-local', + sourceKey: 'metadata-only-reference', + referenceParts: [{ text: 'doi:10.1234/metadata-only' }], + }); + let online = await session.findOnline({ + requestID: 'metadata-only-online', referenceKey: local.referenceKey, + }); + let action = await session.performAction({ + requestID: 'metadata-only-add', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + + assert.equal(action.status, 'saved'); + assert.sameMembers(action.result.actions, ['show-in-library', 'view-online']); + }); + + it("should not advertise Add for an unsaved result in a non-editable library", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', title: 'Read-only result', DOI: '10.1234/read-only', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + let local = await session.matchReference({ + requestID: 'local-read-only-presentation', + sourceKey: 'reference-read-only-presentation', + referenceParts: [{ text: 'doi:10.1234/read-only' }], + }); + let originalGet = Zotero.Libraries.get; + sandbox.stub(Zotero.Libraries, 'get').callsFake(libraryID => ( + libraryID === session._libraryID + ? { editable: false } + : originalGet.call(Zotero.Libraries, libraryID) + )); + + let online = await session.findOnline({ + requestID: 'online-read-only-presentation', referenceKey: local.referenceKey, + }); + + assert.equal(online.status, 'strong'); + assert.notInclude(online.result.actions, 'add-to-library'); + }); + + it("should reject Add when the source attachment no longer belongs to the session library", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', + title: 'Reliable matching across diverse bibliographies', + DOI: '10.1234/general.42', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + let save = sandbox.spy(referenceSaver, 'saveCandidate'); + let local = await session.matchReference({ + requestID: 'local-changed-library', + sourceKey: 'reference-changed-library', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + let online = await session.findOnline({ + requestID: 'online-changed-library', referenceKey: local.referenceKey, + }); + sandbox.stub(Zotero.Items, 'getAsync').withArgs(session._attachmentID).resolves({ + libraryID: session._libraryID + 1000, + deleted: false, + isAttachment: () => true, + }); + + let response = await session.performAction({ + requestID: 'add-changed-library', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + + assert.equal(response.status, 'stale'); + assert.isFalse(save.called); + }); + + it("should reject Add when the rederived target library is not editable", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', title: 'Read-only result', DOI: '10.1234/read-only', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + let local = await session.matchReference({ + requestID: 'local-read-only', sourceKey: 'reference-read-only', + referenceParts: [{ text: 'doi:10.1234/read-only' }], + }); + let online = await session.findOnline({ + requestID: 'online-read-only', referenceKey: local.referenceKey, + }); + let originalGet = Zotero.Libraries.get; + sandbox.stub(Zotero.Libraries, 'get').callsFake(libraryID => ( + libraryID === session._libraryID + ? { editable: false } + : originalGet.call(Zotero.Libraries, libraryID) + )); + + let response = await session.performAction({ + requestID: 'add-read-only', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + + assert.equal(response.status, 'failed'); + }); + + it("should recheck an item added after online lookup in the serialized duplicate check", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', title: 'Remote duplicate', DOI: '10.1234/general.42', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([transient]); + let local = await session.matchReference({ + requestID: 'local-duplicate', sourceKey: 'reference-duplicate', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + let online = await session.findOnline({ + requestID: 'online-duplicate', referenceKey: local.referenceKey, + }); + let duplicate = await createItem(); + let duplicateCheck = sandbox.spy(BibliographyMatcher, 'checkForDuplicate'); + let save = sandbox.spy(referenceSaver, 'saveCandidate'); + + let response = await session.performAction({ + requestID: 'add-duplicate', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + + assert.equal(response.status, 'existing'); + assert.equal(session._references.get(local.referenceKey).result.itemID, duplicate.id); + assert.isTrue(duplicateCheck.calledOnce); + assert.isTrue(save.calledOnce); + }); + + it("should resolve duplicate identity before metadata persistence", async function () { + let duplicate = await createItem(); + let duplicateCheck = sandbox.spy(BibliographyMatcher, 'checkForDuplicate'); + let itemSaver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + let fromJSON = sandbox.spy(Zotero.Item.prototype, 'fromJSON'); + + let response = await referenceSaver.saveCandidate({ + itemType: 'journalArticle', title: 'Remote duplicate', DOI: '10.1234/general.42', + }, { libraryID: Zotero.Libraries.userLibraryID }); + + assert.equal(response.item, duplicate); + assert.isTrue(response.existing); + assert.isTrue(duplicateCheck.calledOnce); + assert.isFalse(itemSaver.called); + assert.isTrue(fromJSON.calledOnce, 'Canonicalize once at the save boundary, not again for identity'); + }); + + it("should keep the active reader when automatic PDF acquisition completes after Add", async function () { + let win = await loadZoteroPane(); + let reader; + try { + let file = getTestDataDirectory(); + file.append('test.pdf'); + let source = await Zotero.Attachments.importFromFile({ file }); + objects.push(source); + reader = await Zotero.Reader.open(source.id); + await reader._initPromise; + Zotero.Prefs.set('downloadAssociatedFiles', true); + sandbox.stub(Zotero.Attachments, 'downloadFirstAvailableFile').callsFake(async (_resolvers, path) => { + await OS.File.copy(file.path, path); + return { url: 'https://example.invalid/automatic.pdf', mimeType: 'application/pdf', props: {} }; + }); + let select = sandbox.spy(win.ZoteroPane, 'selectItem'); + let saved = await referenceSaver.saveCandidate({ + itemType: 'journalArticle', title: 'Automatic attachment reader retention', DOI: '10.1234/reader-retention', + }, { libraryID: source.libraryID }); + objects.push(saved.item); + await saved.fileAcquisitionPromise; + assert.lengthOf(saved.item.getAttachments(), 1); + assert.isFalse(select.called); + assert.equal(win.Zotero_Tabs.selectedID, reader.tabID); + } + finally { + reader?.close(); + win.close(); + } + }); + + it("should save only allowlisted root metadata and serialize concurrent Adds", async function () { + Zotero.Prefs.set('downloadAssociatedFiles', true); + sandbox.stub(Zotero.Attachments, 'addFileFromURLs').resolves(false); + let savedItem = await createItem({ DOI: '10.1234/new-item' }); + let committed = false; + let duplicate = sandbox.stub(BibliographyMatcher, 'checkForDuplicate').callsFake(async () => ( + committed ? { status: 'matched', item: savedItem } : { status: 'absent' } + )); + let resolveSave; + let savePromise = new Promise(resolve => resolveSave = resolve); + let saveItems = sandbox.stub().callsFake(async () => { + await savePromise; + committed = true; + return [savedItem]; + }); + let itemSaver = sandbox.stub(Zotero.Translate, 'ItemSaver').returns({ saveItems }); + let result = { + candidateJSON: { + itemType: 'journalArticle', + title: 'New remote item', + DOI: '10.1234/new-item', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }], + attachments: [{ url: 'https://example.invalid/file.pdf' }], + collections: ['forged'], + tags: [{ tag: 'forged' }], + notes: [{ note: 'forged' }], + relations: { forged: 'value' }, + unknownActiveField: 'forged', + }, + }; + + let firstPromise = referenceSaver.saveCandidate(result.candidateJSON, { + libraryID: Zotero.Libraries.userLibraryID, + }); + let secondPromise; + try { + await waitForCacheOperation(() => saveItems.called); + secondPromise = referenceSaver.saveCandidate(result.candidateJSON, { + libraryID: Zotero.Libraries.userLibraryID, + }); + await Zotero.Promise.delay(0); + assert.isTrue(duplicate.calledOnce, 'The second identity check must wait for metadata commit'); + assert.isTrue(itemSaver.calledOnce); + let options = itemSaver.firstCall.args[0]; + assert.equal(options.libraryID, Zotero.Libraries.userLibraryID); + assert.equal(options.attachmentMode, Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE); + assert.notProperty(options, 'collections'); + let savedJSON = saveItems.firstCall.args[0][0]; + assert.notStrictEqual(savedJSON, result.candidateJSON); + assert.notStrictEqual(savedJSON.creators, result.candidateJSON.creators); + savedJSON.creators[0].firstName = 'Changed by ItemSaver'; + assert.equal(result.candidateJSON.creators[0].firstName, 'Ada'); + for (let field of ['attachments', 'collections', 'tags', 'notes', 'relations', 'unknownActiveField']) { + assert.notProperty(savedJSON, field); + } + resolveSave(); + + let [first, second] = await Promise.all([firstPromise, secondPromise]); + assert.isFalse(first.existing); + assert.isTrue(second.existing); + assert.equal(first.item, savedItem); + assert.equal(second.item, savedItem); + assert.isTrue(duplicate.calledTwice); + assert.isTrue(saveItems.calledOnce); + } + finally { + resolveSave(); + await Promise.allSettled([firstPromise, secondPromise]); + } + }); + + it("should select normal attachment acquisition only when preference and permissions allow it", async function () { + let acquire = sandbox.stub(Zotero.Attachments, 'addFileFromURLs').resolves(false); + let savedItem = await createItem({ DOI: '10.1234/attachment-mode' }); + sandbox.stub(BibliographyMatcher, 'checkForDuplicate').resolves({ status: 'absent' }); + let saveItems = sandbox.stub().resolves([savedItem]); + let itemSaver = sandbox.stub(Zotero.Translate, 'ItemSaver').returns({ saveItems }); + let originalGet = Zotero.Libraries.get; + let filesEditable = true; + sandbox.stub(Zotero.Libraries, 'get').callsFake(libraryID => ( + libraryID === Zotero.Libraries.userLibraryID + ? { editable: true, filesEditable } + : originalGet.call(Zotero.Libraries, libraryID) + )); + + for (let [index, prefEnabled, canEditFiles, shouldAcquire] of [ + [1, false, true, false], + [2, true, false, false], + [3, true, true, true], + ]) { + Zotero.Prefs.set('downloadAssociatedFiles', prefEnabled); + filesEditable = canEditFiles; + let response = await referenceSaver.saveCandidate({ + itemType: 'journalArticle', + title: `Attachment mode ${index}`, + DOI: `10.1234/attachment-mode-${index}`, + }, { libraryID: Zotero.Libraries.userLibraryID }); + + assert.equal(response.item, savedItem); + assert.equal(itemSaver.lastCall.args[0].attachmentMode, Zotero.Translate.ItemSaver.ATTACHMENT_MODE_IGNORE); + assert.equal(acquire.callCount, shouldAcquire ? 1 : 0); + } + assert.equal(saveItems.callCount, 3); + }); + + it("should release the save lock after an ItemSaver transaction failure", async function () { + sandbox.stub(BibliographyMatcher, 'checkForDuplicate').resolves({ status: 'absent' }); + let saveItems = sandbox.stub().rejects(new Error('transaction failed')); + sandbox.stub(Zotero.Translate, 'ItemSaver').returns({ saveItems }); + let result = { + candidateJSON: { + itemType: 'journalArticle', title: 'Failed save', DOI: '10.1234/failed-save', + }, + }; + + let firstError = await getPromiseError(referenceSaver.saveCandidate(result.candidateJSON, { + libraryID: Zotero.Libraries.userLibraryID, + })); + assert.include(String(firstError), 'transaction failed'); + assert.equal(referenceSaver._metadataSaveTails.size, 0); + let secondError = await getPromiseError(referenceSaver.saveCandidate(result.candidateJSON, { + libraryID: Zotero.Libraries.userLibraryID, + })); + assert.include(String(secondError), 'transaction failed'); + assert.equal(saveItems.callCount, 2); + }); + + it("should pool weak or ambiguous identifier evidence with Crossref", async function () { + let identifierItem = { + itemType: 'journalArticle', + title: 'Identifier metadata', + date: '2021', + DOI: '10.1234/general.42', + }; + let crossrefItem = { + itemType: 'journalArticle', + title: 'Crossref metadata', + date: '2021', + DOI: '10.1234/crossref.42', + }; + let identifier = sandbox.stub(onlineReferenceLookup, '_translateIdentifier') + .resolves([identifierItem]); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch') + .resolves([crossrefItem]); + let verify = sandbox.stub(BibliographyMatcher, 'matchCandidates'); + let reference = { referenceParts: [{ text: 'doi:10.1234/general.42' }] }; + + for (let reason of ['insufficient-evidence', 'ambiguous']) { + identifier.resetHistory(); + crossref.resetHistory(); + verify.resetHistory(); + verify.returns({ status: 'unmatched', reason }); + let result = await onlineReferenceLookup.findOnline(reference); + assert.deepEqual(result, { status: 'unmatched', reason }); + assert.isTrue(identifier.calledOnce); + assert.isTrue(verify.calledTwice); + assert.deepEqual(verify.firstCall.args, [reference, [identifierItem]]); + assert.deepEqual(verify.lastCall.args, [reference, [identifierItem, crossrefItem]]); + assert.isTrue(crossref.calledOnce); + } + }); + + it("should pool competing identifier routes independently of order with the real matcher", async function () { + let items = ['alpha', 'beta'].map(name => ({ + itemType: 'journalArticle', title: `${name} work`, DOI: `10.1234/${name}`, + })); + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier') + .callsFake(async identifier => items.filter(item => item.DOI === identifier.DOI)); + sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + for (let order of [items, [...items].reverse()]) { + provider.resetHistory(); + let reference = order.map(item => item.DOI).join(' '); + let result = await onlineReferenceLookup.findOnline(reference); + assert.equal(provider.callCount, 2); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + } + }); + + it("should resolve completed same-work identifier routes with the real matcher", async function () { + let item = { itemType: 'journalArticle', title: 'Same work', DOI: '10.1234/alpha', extra: 'PMID: 22678280' }; + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves([item]); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + let reference = '10.1234/alpha PMID: 22678280'; + let result = await onlineReferenceLookup.findOnline(reference); + assert.equal(provider.callCount, 2); + assert.property(result, 'item'); + assert.equal(result.status, 'matched'); + assert.isFalse(crossref.called); + }); + + it("should not certify partial multi-identifier results after failure or a cap", async function () { + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier'); + provider.onFirstCall().resolves([{ itemType: 'journalArticle', title: 'Alpha', DOI: '10.1234/alpha' }]); + provider.onSecondCall().rejects(new Zotero.HTTP.TimeoutException(10)); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + let result = await onlineReferenceLookup.findOnline('10.1234/alpha 10.1234/beta'); + assert.equal(result.status, 'incomplete'); + assert.notProperty(result, 'items'); + assert.equal(provider.callCount, 2); + provider.resetHistory(); + result = await onlineReferenceLookup.findOnline( + '10.1234/alpha 10.1234/beta 10.1234/gamma 10.1234/delta'); + assert.equal(result.status, 'incomplete'); + assert.isFalse(provider.called); + assert.isFalse(crossref.called); + }); + + it("should cancel a later identifier route without returning its partial pool", async function () { + let controller = new AbortController(); + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier'); + provider.onFirstCall().resolves([{ itemType: 'journalArticle', title: 'Alpha', DOI: '10.1234/alpha' }]); + provider.onSecondCall().callsFake(async () => { + controller.abort(); + throw new Zotero.HTTP.CancelledException(); + }); + let error = await getPromiseError(onlineReferenceLookup.findOnline( + '10.1234/alpha 10.1234/beta', { signal: controller.signal })); + assert.instanceOf(error, Zotero.HTTP.CancelledException); + assert.equal(provider.callCount, 2); + }); + + for (let competing of [false, true]) { + it(`should evaluate the complete merged online pool including a late ${competing ? 'competitor' : 'winner'}`, async function () { + sandbox.stub(onlineReferenceLookup, '_translateIdentifier') + .resolves([{ itemType: 'journalArticle', title: 'Identifier suggestion' }]); + let pool = Array.from({ length: 20 }, (_, i) => ({ + itemType: 'journalArticle', title: `Distinct work ${i}`, + })); + pool[19].DOI = '10.1234/alpha'; + pool[19].extra = 'PMID: 22678280'; + if (competing) { + pool[0].DOI = '10.1234/alpha'; + pool[0].extra = 'PMID: 22678281'; + } + sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves(pool); + let result = await onlineReferenceLookup.findOnline('10.1234/alpha'); + assert.equal(result.status, competing ? 'unmatched' : 'matched'); + if (competing) assert.notProperty(result, 'item'); + else assert.equal(result.item.getField('title'), 'Distinct work 19'); + }); + } + + it("should retain competing identifier results beyond twenty merged candidates", async function () { + let pool = Array.from({ length: 20 }, (_, i) => ({ + itemType: 'journalArticle', title: `Identifier work ${i}`, + })); + pool[0].DOI = '10.1234/alpha'; + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier'); + provider.onFirstCall().resolves(pool); + provider.onSecondCall().resolves([{ itemType: 'journalArticle', title: 'Competing work', extra: 'PMID: 22678280' }]); + sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + let result = await onlineReferenceLookup.findOnline('10.1234/alpha PMID: 22678280'); + assert.equal(result.status, 'unmatched'); + assert.notProperty(result, 'item'); + assert.equal(result.reason, 'ambiguous-identifiers'); + }); + + for (let route of ['identifier', 'crossref']) { + it(`should report incomplete ${route} translation selection instead of certifying the first five choices`, async function () { + let pool = Array.from({ length: 6 }, (_, i) => ({ itemType: 'journalArticle', + title: `Selection work ${i}`, DOI: i === 5 ? '10.1234/selection0' : `10.1234/selection${i}`, + extra: `PMID: ${1111111 + i}` })); + let reference = { referenceParts: [{ text: 'doi:10.1234/selection0' }] }; + assert.equal(BibliographyMatcher.matchCandidates(reference, pool).status, 'unmatched'); + assert.equal(BibliographyMatcher.matchCandidates(reference, pool.slice(0, 5)).status, 'matched'); + let select; + let selected; + let translate = { + ERROR_NO_RESULTS: 'NO_RESULTS', + setHandler(name, handler) { + if (name === 'select') select = handler; + }, + async translate() { + select(this, Object.fromEntries(pool.map((item, i) => [String(i), item.title])), value => selected = value); + if (!selected) throw this.ERROR_NO_RESULTS; + return Object.keys(selected).map(key => pool[Number(key)]); + }, + }; + let run = () => onlineReferenceLookup._runTranslation(translate); + let identifier = sandbox.stub(onlineReferenceLookup, '_translateIdentifier'); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch'); + if (route === 'identifier') identifier.callsFake(run); + else { + identifier.resolves([{ itemType: 'journalArticle', title: 'Retained identifier candidate', DOI: '10.1234/retained' }]); + crossref.callsFake(run); + } + let result = await onlineReferenceLookup.findOnline(reference); + assert.equal(result.status, 'incomplete'); + assert.notProperty(result, 'items'); + assert.isFalse(selected, 'Over-limit choice set was partially downloaded'); + if (route === 'identifier') assert.isFalse(crossref.called); + }); + } + + it("should retain every translator selection when it fits the existing request budget", async function () { + let select; + for (let count of [0, 1, 5]) { + let items = Array.from({ length: count }, (_, i) => ({ title: `Work ${i}` })); + let result = await onlineReferenceLookup._runTranslation({ + setHandler(name, handler) { + if (name === 'select') select = handler; + }, + async translate() { + let selected; + select(this, Object.fromEntries(items.map((item, i) => [String(i), item.title])), value => selected = value); + return Object.keys(selected).map(key => items[Number(key)]); + }, + }); + assert.deepEqual(result, items); + } + }); + + it("should preserve cancellation when translator selection exceeds the request budget", async function () { + let controller = new AbortController(); + let select; + sandbox.stub(onlineReferenceLookup, '_translateIdentifier').callsFake(() => onlineReferenceLookup._runTranslation({ + setHandler(name, handler) { + if (name === 'select') select = handler; + }, + async translate() { + select(this, Object.fromEntries(Array.from({ length: 6 }, (_, i) => [String(i), `Work ${i}`])), () => {}); + controller.abort(); + return []; + }, + })); + let error = await getPromiseError(onlineReferenceLookup.findOnline( + 'doi:10.1234/cancel-selection', { signal: controller.signal })); + assert.instanceOf(error, Zotero.HTTP.CancelledException); + }); + + it("should retain a translation overflow witness for bounded verification", async function () { + let items = Array.from({ length: 25 }, (_, i) => ({ title: `Work ${i}` })); + let result = await onlineReferenceLookup._runTranslation({ + setHandler() {}, translate: async () => items, + }); + assert.lengthOf(result, 21); + }); + + it("should reject raw provider overflow even when deduplication could hide the witness", async function () { + let item = { itemType: 'journalArticle', title: 'Alpha', DOI: '10.1234/alpha' }; + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves(Array(21).fill(item)); + assert.equal((await onlineReferenceLookup.findOnline(item.DOI)).status, 'incomplete'); + provider.resolves([]); + sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves(Array(21).fill(item)); + assert.equal((await onlineReferenceLookup.findOnline(item.DOI)).status, 'incomplete'); + }); + + it("should finalize a complete exact-identifier ambiguity without starting Crossref", async function () { + sandbox.stub(onlineReferenceLookup, '_translateIdentifier').callsFake(async identifier => [{ + itemType: 'journalArticle', title: identifier.DOI, DOI: identifier.DOI, + }]); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch') + .rejects(new Zotero.HTTP.TimeoutException(30)); + let reference = '10.1234/alpha 10.1234/beta'; + let result = await onlineReferenceLookup.findOnline(reference); + assert.equal(result.status, 'unmatched'); + assert.equal(result.reason, 'ambiguous-identifiers'); + assert.notProperty(result, 'item'); + assert.isFalse(crossref.called); + }); + + it("should count equivalent ISBN forms as one provider identity with the real parser", async function () { + let reference = '10.1234/alpha PMID: 22678280 ISBN 9781118282175'; + let parsed = BibliographyMatcher.prepareReference(reference); + assert.deepEqual([...parsed.identifiers.ISBN], ['9781118282175'], 'ISBN aliases share one canonical identity'); + assert.deepEqual(onlineReferenceLookup._identifierSearches(parsed), [ + { DOI: '10.1234/alpha' }, { ISBN: '9781118282175' }, { PMID: '22678280' }, + ]); + let item = { itemType: 'book', title: 'One work', ISBN: '9781118282175', + extra: 'DOI: 10.1234/alpha\nPMID: 22678280' }; + let evaluate = sandbox.spy(BibliographyMatcher, 'matchCandidates'); + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves([item]); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + let result = await onlineReferenceLookup.findOnline(reference); + assert.equal(provider.callCount, 3); + assert.property(result, 'item'); + assert.equal(result.status, 'matched'); + assert.isFalse(crossref.called); + provider.resetHistory(); + result = await onlineReferenceLookup.findOnline('ISBN 1118282175'); + assert.isTrue(provider.calledOnceWithExactly({ ISBN: '9781118282175' })); + assert.equal(result.status, 'unmatched'); + assert.deepEqual(evaluate.lastCall.args[1], [item]); + assert.isTrue(crossref.calledOnce, 'ISBN-only output is not an authoritative record match'); + }); + + it("should keep distinct ISBN identities separate and preserve their route cap", async function () { + let text = 'ISBN 9781118282175 ISBN 9780306406157'; + assert.deepEqual(onlineReferenceLookup._identifierSearches(BibliographyMatcher.prepareReference(text)), [ + { ISBN: '9781118282175' }, { ISBN: '9780306406157' }, + ]); + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves([]); + assert.equal((await onlineReferenceLookup.findOnline(`10.1234/alpha PMID: 22678280 ${text}`)).status, + 'incomplete'); + assert.isFalse(provider.called); + }); + + it("should retain ordinary single-ISBN fallback after an identifier-provider failure", async function () { + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier') + .rejects(new Zotero.HTTP.TimeoutException(10)); + let item = { itemType: 'book', title: 'One work', ISBN: '9781118282175' }; + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([item]); + let evaluate = sandbox.spy(BibliographyMatcher, 'matchCandidates'); + let result = await onlineReferenceLookup.findOnline('ISBN 1118282175'); + assert.isTrue(provider.calledOnce); + assert.isTrue(crossref.calledOnce); + assert.equal(result.status, 'timeout', 'Unverified fallback metadata cannot erase the failed provider'); + assert.deepEqual(evaluate.lastCall.args[1], [item]); + }); + + it("should retain a failed Crossref outcome when ISBN metadata is not independently matchable", async function () { + sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves([ + { itemType: 'book', title: 'One work', ISBN: '9781118282175' }, + ]); + sandbox.stub(onlineReferenceLookup, '_translateSearch').rejects(new Zotero.HTTP.BrowserOfflineException()); + assert.equal((await onlineReferenceLookup.findOnline('ISBN 1118282175')).status, 'offline'); + }); + + it("should keep final identifier ambiguity when fallback succeeds after a provider failure", async function () { + sandbox.stub(onlineReferenceLookup, '_translateIdentifier').rejects(new Zotero.HTTP.TimeoutException(10)); + sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([ + { itemType: 'journalArticle', title: 'First work', DOI: '10.1234/fallback-ambiguity', extra: 'PMID: 22678280' }, + { itemType: 'journalArticle', title: 'Competing work', DOI: '10.1234/fallback-ambiguity', extra: 'PMID: 22678281' }, + ]); + assert.deepEqual(await onlineReferenceLookup.findOnline('doi:10.1234/fallback-ambiguity'), + { status: 'unmatched', reason: 'ambiguous-identifiers' }); + }); + + it("should preserve ADS spelling through identifier lookup and bundled translator detection", async function () { + let text = '2021wfc..rept....8D'; + let [identifier] = onlineReferenceLookup._identifierSearches(BibliographyMatcher.prepareReference(text)); + assert.deepEqual(identifier, { adsBibcode: text }); + let translate = new Zotero.Translate.Search(); + translate.setIdentifier(identifier); + let translators = await translate.getTranslators(); + assert.include(translators.map(translator => translator.translatorID), '09bd8037-a9bb-4f9a-b3b9-d18b2564b49e'); + }); + + it("should retain secondary arXiv conflict evidence without adding a provider route", async function () { + let reference = 'doi:10.1234/primary arXiv:2101.12345'; + let parsed = BibliographyMatcher.prepareReference(reference); + assert.deepEqual([...parsed.identifiers.arXiv], ['2101.12345']); + assert.deepEqual(onlineReferenceLookup._identifierSearches(parsed), [{ DOI: '10.1234/primary' }]); + let identifier = sandbox.stub(onlineReferenceLookup, '_translateIdentifier'); + identifier.withArgs({ DOI: '10.1234/primary' }).resolves([ + { itemType: 'journalArticle', title: 'Published work', DOI: '10.1234/primary', extra: 'arXiv:2101.12345' }, + ]); + identifier.rejects(new Zotero.HTTP.TimeoutException(10)); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + assert.equal((await onlineReferenceLookup.findOnline(reference)).status, 'matched'); + assert.isTrue(identifier.calledOnceWithExactly({ DOI: '10.1234/primary' })); + assert.isFalse(crossref.called); + identifier.withArgs({ DOI: '10.1234/primary' }).resolves([ + { itemType: 'journalArticle', title: 'Different work', DOI: '10.1234/primary', extra: 'arXiv:2101.99999' }, + ]); + assert.equal((await onlineReferenceLookup.findOnline(reference)).status, 'unmatched'); + assert.isTrue(crossref.calledOnce); + }); + + it("should preserve same-ISBN distinct provider records but deduplicate identical metadata and record identifiers", function () { + let first = { itemType: 'bookSection', title: 'Laying the Foundations', ISBN: '9781118282175' }; + let second = { ...first, title: 'The World of SQL Server' }; + let results = BibliographyMatcher.deduplicateCandidates([first, second, { ...first }]); + assert.lengthOf(results, 2); + assert.strictEqual(results[0], first); + assert.strictEqual(results[1], second); + assert.lengthOf(BibliographyMatcher.deduplicateCandidates([ + { ...first, DOI: '10.1234/record' }, { ...second, DOI: '10.1234/record' }, + ]), 1); + }); + + it("should deduplicate strong identifiers before metadata signatures", function () { + let providers = [ + { + itemType: 'journalArticle', + title: 'First title', + date: '2020', + DOI: '10.1234/SAME', + }, + { + itemType: 'journalArticle', + title: 'Different provider title', + date: '2021', + DOI: '10.1234/same', + }, + { + itemType: 'book', + title: 'ISBN ten', + ISBN: '0-306-40615-2', + }, + { + itemType: 'book', + title: 'ISBN thirteen', + ISBN: '9780306406157', + }, + ]; + let candidates = BibliographyMatcher.deduplicateCandidates(providers); + + assert.lengthOf(candidates, 3); + assert.strictEqual(candidates[0], providers[0]); + assert.strictEqual(candidates[1], providers[2]); + assert.strictEqual(candidates[2], providers[3]); + }); + + it("should continue to Crossref after an independent identifier-provider failure", async function () { + let crossrefItem = { + itemType: 'journalArticle', + title: 'Crossref metadata', + date: '2021', + DOI: '10.1234/general.42', + }; + sandbox.stub(onlineReferenceLookup, '_translateIdentifier') + .rejects(new Zotero.HTTP.TimeoutException(10)); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch') + .resolves([crossrefItem]); + + let result = await onlineReferenceLookup.findOnline({ + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + + assert.equal(result.status, 'matched'); + assert.equal(result.item.getField('DOI'), crossrefItem.DOI); + assert.isTrue(crossref.calledOnce); + }); + + it("should time out one identifier provider without consuming the overall lookup", async function () { + let originalProviderTimeout = onlineReferenceLookup.PROVIDER_TIMEOUT_MS; + onlineReferenceLookup.PROVIDER_TIMEOUT_MS = 10; + try { + sandbox.stub(onlineReferenceLookup, '_translateIdentifier') + .returns(new Promise(() => {})); + let crossrefItem = { + itemType: 'journalArticle', title: 'Crossref fallback', DOI: '10.1234/fallback', + }; + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch') + .resolves([crossrefItem]); + let evaluate = sandbox.spy(BibliographyMatcher, 'matchCandidates'); + + let result = await onlineReferenceLookup.findOnline({ + referenceParts: [{ text: 'doi:10.1234/provider-timeout' }], + }); + + assert.equal(result.status, 'timeout', 'An unrelated fallback does not recover the timed-out identifier lookup'); + assert.isTrue(evaluate.calledOnce); + assert.deepEqual(evaluate.firstCall.args[1], [crossrefItem]); + assert.isTrue(crossref.calledOnce); + } + finally { + onlineReferenceLookup.PROVIDER_TIMEOUT_MS = originalProviderTimeout; + } + }); + + it("should enforce one overall lookup deadline", async function () { + let originalTimeout = onlineReferenceLookup.ONLINE_LOOKUP_TIMEOUT_MS; + let clock; + onlineReferenceLookup.ONLINE_LOOKUP_TIMEOUT_MS = 10; + try { + let now = 0, elapsed; + let lookupGlobal = Components.utils.getGlobalForObject(onlineReferenceLookup); + clock = sandbox.stub(lookupGlobal.Date, 'now').callsFake(() => now); + let runProvider = sandbox.stub(onlineReferenceLookup, '_runProvider').callsFake(run => run()); + let identifier = sandbox.stub(onlineReferenceLookup, '_translateIdentifier').callsFake(async () => { + now += elapsed; + throw new Zotero.HTTP.TimeoutException(elapsed); + }); + let crossref = sandbox.stub(onlineReferenceLookup, '_translateSearch').callsFake(async () => { + throw new Zotero.HTTP.TimeoutException(3); + }); + for (elapsed of [7, 10]) { + now = 0; + runProvider.resetHistory(); + identifier.resetHistory(); + crossref.resetHistory(); + let result = await onlineReferenceLookup.findOnline({ + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + assert.equal(result.status, 'timeout'); + assert.isTrue(identifier.calledOnce); + assert.deepEqual(runProvider.getCalls().map(call => call.args[2]), elapsed === 7 ? [10, 3] : [10]); + assert.equal(crossref.callCount, elapsed === 7 ? 1 : 0, 'An exhausted deadline must not start fallback'); + } + } + finally { + clock?.restore(); + onlineReferenceLookup.ONLINE_LOOKUP_TIMEOUT_MS = originalTimeout; + } + }); + + it("should cache successful online results per session reference", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', + title: 'Reliable matching across diverse bibliographies', + date: '2021', + DOI: '10.1234/general.42', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = stubOnlineCandidates([transient]); + let local = await session.matchReference({ + requestID: 'local-1', + sourceKey: 'reference-1', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + + let first = await session.findOnline({ + requestID: 'online-1', referenceKey: local.referenceKey, + }); + let second = await session.findOnline({ + requestID: 'online-2', referenceKey: local.referenceKey, + }); + + assert.equal(first.status, 'strong'); + assert.notEqual(second.result.resultID, first.result.resultID); + assert.deepEqual(second.result.display, first.result.display); + assert.isFalse(!!bindingFor(session, first.result.resultID)); + assert.equal([...session._references.values()].filter(reference => reference.result).length, 1); + assert.isTrue(provider.calledOnce); + }); + + it("should prepare one metadata snapshot per online result and keep the cache independent of actions", async function () { + let session = await createSession(); + let item = new Zotero.Item('journalArticle'); + item.fromJSON({ itemType: 'journalArticle', title: 'Shared snapshot', DOI: '10.1234/shared-snapshot' }); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + stubOnlineCandidates([item]); + let snapshot = sandbox.spy(BibliographyMatcher, 'createCandidateSnapshot'); + let parts = [{ text: 'doi:10.1234/shared-snapshot' }]; + let local = await session.matchReference({ requestID: 'snapshot-open', sourceKey: 'snapshot', referenceParts: parts }); + let found = await session.findOnline({ requestID: 'snapshot-find', referenceKey: local.referenceKey }); + assert.equal(found.status, 'strong'); + assert.isTrue(snapshot.calledOnce); + let binding = bindingFor(session, found.result.resultID); + let cached = JSON.parse(session._onlineMatchCache.get(parts[0].text)); + assert.deepEqual(cached, binding.candidateJSON); + binding.candidateJSON.title = 'Changed action metadata'; + item.setField('title', 'Changed provider item'); + let evaluate = sandbox.stub(BibliographyMatcher, 'matchCandidates').throws(new Error('Unexpected re-evaluation')); + session._removeReference(local.referenceKey); + let reopened = await session.matchReference({ requestID: 'snapshot-reopen', sourceKey: 'snapshot', referenceParts: parts }); + assert.equal(reopened.result.display.title, 'Shared snapshot'); + assert.isTrue(snapshot.calledOnce, 'Restore reuses the cached snapshot'); + assert.isFalse(evaluate.called); + }); + + it("should cache plain provider metadata immediately and restore it without another provider call", async function () { + let session = await createSession(); + let json = { itemType: 'journalArticle', title: 'Plain provider metadata', + DOI: '10.1234/plain-cache', abstractNote: 'Retained provider abstract', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }, + { creatorType: 'author', firstName: 'Grace', lastName: 'Hopper' }] }; + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = stubOnlineCandidates([json]); + let referenceParts = [{ text: 'doi:10.1234/plain-cache' }]; + let open = requestID => session.matchReference({ requestID, sourceKey: 'plain', referenceParts }); + let local = await open('plain-open'); + let found = await session.findOnline({ requestID: 'plain-find', referenceKey: local.referenceKey }); + assert.equal(found.status, 'strong'); + assert.equal(found.result.display.firstCreator, + Zotero.getString('general.andJoiner', ['\u2068Lovelace\u2069', '\u2068Hopper\u2069'])); + assert.isTrue(session._onlineMatchCache.has(referenceParts.map(part => part.text).join(''))); + let prepareReference = sandbox.spy(BibliographyMatcher, 'prepareReference'); + let reopened = await open('plain-reopen'); + assert.equal(reopened.status, 'strong'); + assert.equal(reopened.lookupSource, 'online'); + assert.deepEqual(reopened.result.display, found.result.display); + assert.notEqual(reopened.result.resultID, found.result.resultID); + assert.isTrue(provider.calledOnce); + assert.isFalse(prepareReference.called, 'Restoring verified metadata does not need citation matching analysis'); + }); + + it("should cache and save the first equivalent online publication record", async function () { + let data = { title: 'First-choice publication records', date: '2021', pages: '120-139', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }] }; + let conference = { ...data, itemType: 'conferencePaper', proceedingsTitle: 'Collected Research on Bibliographic Methods', + DOI: '10.1234/first-conference' }; + let journal = { ...data, itemType: 'journalArticle', publicationTitle: 'Journal of Bibliographic Systems', + DOI: '10.1234/second-journal' }; + let provider = sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([conference, journal]); + let session = await createSession(); + let referenceParts = [{ text: `Lovelace, Ada. 2021. ${data.title}. pp. 120-139.` }]; + let local = await session.matchReference({ requestID: 'first-open', sourceKey: 'reference', referenceParts }); + let found = await session.findOnline({ requestID: 'first-find', referenceKey: local.referenceKey }); + assert.equal(found.status, 'strong'); + assert.equal(bindingFor(session, found.result.resultID).candidateJSON.DOI, conference.DOI); + let reopened = await session.matchReference({ requestID: 'first-reopen', sourceKey: 'reference', referenceParts }); + assert.equal(reopened.status, 'strong'); + assert.equal(bindingFor(session, reopened.result.resultID).candidateJSON.DOI, conference.DOI); + assert.isTrue(provider.calledOnce); + assert.equal(session._onlineMatchCache.size, 1); + Zotero.Prefs.set('downloadAssociatedFiles', false); + let added = await session.performAction({ requestID: 'first-add', referenceKey: reopened.referenceKey, + resultID: reopened.result.resultID, action: 'add-to-library' }); + assert.equal(added.status, 'saved'); + let item = await Zotero.Items.getAsync(bindingFor(session, reopened.result.resultID).itemID); + objects.push(item); + assert.equal(item.getField('DOI'), conference.DOI); + assert.equal(Zotero.ItemTypes.getName(item.itemTypeID), 'conferencePaper'); + }); + + it("should not publish or cache ambiguous online results", async function () { + let session = await createSession(); + let referenceParts = [{ text: 'doi:10.1234/mixed-cache' }]; + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline').resolves({ status: 'unmatched', reason: 'ambiguous' }); + let local = await session.matchReference({ requestID: 'open', sourceKey: 'reference', referenceParts }); + let found = await session.findOnline({ requestID: 'find', referenceKey: local.referenceKey }); + assert.equal(found.status, 'no-match'); + assert.notProperty(found, 'result'); + assert.equal(session._onlineMatchCache.size, 0); + assert.notProperty(session._references.get(local.referenceKey), 'result'); + let reopened = await session.matchReference({ requestID: 'reopen', sourceKey: 'reference', referenceParts }); + assert.equal(reopened.status, 'no-match'); + assert.isTrue(provider.calledOnce); + }); + + for (let identifier of [true, false]) { + it(`should reconcile fresh and cached online identity (${identifier ? 'DOI' : 'metadata'})`, async function () { + let metadata = { + itemType: 'journalArticle', title: 'Online identity: complete metadata', date: '2021', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }], + DOI: identifier ? '10.1234/online-identity' : '', abstractNote: 'Provider abstract', + }; + let saved = await createItem({ ...metadata, publicationTitle: '', url: '', + title: identifier ? 'Online identity' : metadata.title, abstractNote: 'Library abstract' }); + let transient = new Zotero.Item(metadata.itemType); + transient.fromJSON(metadata); + let session = await createSession(); + let local = sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = stubOnlineCandidates([transient]); + let duplicate = sandbox.spy(BibliographyMatcher, 'checkForDuplicate'); + let referenceParts = [{ text: `Lovelace, A. (2021). ${metadata.title}.` }]; + let open = requestID => session.matchReference({ requestID, sourceKey: 'online-identity', referenceParts }); + let first = await open('identity-open'); + let online = await session.findOnline({ requestID: 'identity-find', referenceKey: first.referenceKey }); + assert.equal(online.result.libraryState, 'existing'); + assert.equal(online.result.display.title, saved.getField('title')); + assert.equal(online.result.display.abstract, 'Library abstract'); + assert.include(online.result.actions, 'show-in-library'); + assert.notInclude(online.result.actions, 'add-to-library'); + assert.isTrue(duplicate.calledOnce); + assert.equal(JSON.parse(session._onlineMatchCache.get(referenceParts[0].text)).title, metadata.title); + let reopened = await open('identity-reopen'); + assert.deepEqual(reopened.result.display, online.result.display); + assert.deepEqual(reopened.result.actions, online.result.actions); + assert.isTrue(duplicate.calledTwice); + saved.deleted = true; + await saved.saveTx(); + let deleted = await open('identity-deleted'); + assert.equal(deleted.result.libraryState, 'unsaved'); + assert.equal(deleted.result.display.title, metadata.title); + assert.equal(deleted.result.display.abstract, 'Provider abstract'); + assert.isTrue(provider.calledOnce); + assert.isTrue(local.calledOnce); + }); + } + + it("should restore online metadata across popup replacement with fresh tokens and current library identity", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ itemType: 'journalArticle', title: 'Recovered online work', DOI: '10.1234/cache' }); + let wrong = await createItem({ title: 'Approximate local alternative', DOI: '10.1234/other' }); + let session = await createSession(); + let localMatch = sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(wrong)); + let provider = stubOnlineCandidates([transient]); + let parts = [{ text: 'doi:10.1234/cache' }]; + let open = requestID => session.matchReference({ requestID, sourceKey: 'same', referenceParts: parts }); + let first = await open('cache-open'); + let online = await session.findOnline({ requestID: 'cache-find', referenceKey: first.referenceKey }); + assert.equal(online.status, 'strong'); + let reopened = await open('cache-reopen'); + assert.equal(reopened.lookupSource, 'online'); + assert.equal(reopened.result.display.title, 'Recovered online work'); + assert.equal(reopened.result.libraryState, 'unsaved'); + assert.notEqual(reopened.referenceKey, first.referenceKey); + assert.notEqual(reopened.result.resultID, online.result.resultID); + assert.isFalse(!!bindingFor(session, online.result.resultID)); + let saved = await createItem({ title: 'Current edited saved title', DOI: '10.1234/cache' }); + sandbox.stub(saved, 'getBestAttachment').resolves(null); + let current = await open('cache-saved'); + assert.equal(current.lookupSource, 'online'); + assert.equal(current.result.libraryState, 'existing'); + assert.equal(current.result.display.title, 'Current edited saved title'); + assert.include(current.result.actions, 'show-in-library'); + assert.notInclude(current.result.actions, 'add-to-library'); + assert.equal(localMatch.callCount, 1, 'A cache hit unnecessarily repeated local matching'); + assert.isTrue(provider.calledOnce); + saved.deleted = true; + await saved.saveTx(); + let deleted = await open('cache-deleted'); + assert.equal(deleted.result.libraryState, 'unsaved'); + session.dispose(); + assert.equal(session._onlineMatchCache.size, 0); + }); + + it("should retain session metadata and bindings until eviction while respecting visible text and permissions", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ itemType: 'journalArticle', title: 'Cached work', DOI: '10.1234/cache-scope' }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = stubOnlineCandidates([transient]); + let parts = [{ text: 'doi:10.1234/cache-scope' }]; + let open = (requestID, referenceParts = parts) => session.matchReference({ requestID, sourceKey: 'same', referenceParts }); + let first = await open('scope-first'); + let found = await session.findOnline({ requestID: 'scope-find', referenceKey: first.referenceKey }); + let entry = session._onlineMatchCache.values().next().value; + let loadURI = sandbox.stub(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { loadURI } }); + let valid = await session.performAction({ requestID: 'scope-valid-action', referenceKey: first.referenceKey, + resultID: found.result.resultID, action: 'view-online' }); + assert.equal(valid.status, 'ok'); + assert.isTrue(loadURI.calledOnce); + session._removeReference(first.referenceKey); + let stale = await session.performAction({ requestID: 'scope-old-action', referenceKey: first.referenceKey, + resultID: found.result.resultID, action: 'view-online' }); + assert.equal(stale.status, 'stale'); + let offline = sandbox.stub(Zotero.HTTP, 'browserIsOffline').returns(true); + let primary = await open('scope-offline'); + assert.equal(primary.lookupSource, 'online'); + let secondary = await session.matchReference({ requestID: 'scope-secondary', sourceKey: 'secondary:same', referenceParts: parts }); + assert.equal(secondary.lookupSource, 'online'); + assert.notEqual(primary.result.resultID, secondary.result.resultID); + assert.isTrue(!!bindingFor(session, primary.result.resultID), 'Split-view restoration invalidated the other owner'); + assert.notEqual(primary.referenceKey, first.referenceKey); + assert.notEqual(primary.result.resultID, found.result.resultID); + assert.strictEqual(session._onlineMatchCache.values().next().value, entry); + offline.restore(); + assert.notProperty(await open('scope-text', [{ text: 'doi:10.1234/cache-scope ' }]), 'lookupSource'); + assert.equal((await open('scope-url', [{ text: parts[0].text, attributes: { url: 'https://example.invalid/' } }])).lookupSource, 'online'); + assert.equal((await open('scope-split', [{ text: 'doi:' }, { text: '10.1234/cache-scope' }])).lookupSource, 'online'); + let other = new ReaderReferenceSession(await Zotero.Items.getAsync(session._attachmentID)); + assert.notProperty(await other.matchReference({ requestID: 'other', sourceKey: 'same', referenceParts: parts }), 'lookupSource'); + other.dispose(); + assert.equal((await open('scope-still-retained')).lookupSource, 'online'); + assert.equal(session._onlineMatchCache.size, 1); + assert.isTrue(provider.calledOnce); + }); + + it("should evict metadata at the fixed cap and never cache empty outcomes", async function () { + let session = await createSession(); + let item = new Zotero.Item('journalArticle'); + item.fromJSON({ itemType: 'journalArticle', title: 'Bounded metadata' }); + sandbox.stub(ReaderReferenceSession, 'MAX_REFERENCES').value(2); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline').resolves(strictMatch(item)); + for (let text of ['first', 'second', 'third']) { + let local = await session.matchReference({ requestID: text, sourceKey: text, referenceParts: [{ text }] }); + await session.findOnline({ requestID: 'find-' + text, referenceKey: local.referenceKey }); + } + assert.equal(session._onlineMatchCache.size, 2); + assert.isFalse(session._onlineMatchCache.has('first')); + assert.equal(session._references.size, 2); + provider.resolves(unmatched()).resetHistory(); + let local = await session.matchReference({ requestID: 'empty', sourceKey: 'empty', referenceParts: [{ text: 'Missing work' }] }); + for (let requestID of ['empty-one', 'empty-two']) { + assert.equal((await session.findOnline({ requestID, referenceKey: local.referenceKey })).status, 'no-match'); + } + assert.isTrue(provider.calledTwice); + assert.equal(session._onlineMatchCache.size, 2); + }); + + it("should retry failed reconciliation locally and honor permission changes during cache restoration", async function () { + let session = await createSession(); + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ itemType: 'journalArticle', title: 'Cached permission work', DOI: '10.1234/cache-permission' }); + let wrong = await createItem({ DOI: '10.1234/other' }); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(wrong)); + let parts = [{ text: 'doi:10.1234/cache-permission' }]; + let open = requestID => session.matchReference({ requestID, sourceKey: 'permission', referenceParts: parts }); + cacheMatch(session, parts.map(part => part.text).join(''), strictMatch(transient)); + let duplicate = sandbox.stub(BibliographyMatcher, 'checkForDuplicate').rejects(new Error('Reconciliation failed')); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline').rejects(new Error('Unexpected network retry')); + let failed = await open('permission-failure'); + assert.equal(failed.lookupSource, 'online'); + assert.equal(failed.status, 'error'); + assert.notProperty(failed, 'result'); + assert.equal(session._onlineMatchCache.size, 1); + assert.equal([...session._references.values()].filter(reference => reference.result).length, 0); + duplicate.resolves({ status: 'absent' }); + let offline = sandbox.stub(Zotero.HTTP, 'browserIsOffline').returns(true); + let retry = await session.findOnline({ requestID: 'permission-retry', referenceKey: failed.referenceKey }); + assert.equal(retry.status, 'strong'); + assert.equal(retry.result.display.title, 'Cached permission work'); + assert.isFalse(provider.called); + let miss = await session.matchReference({ requestID: 'permission-offline-miss', sourceKey: 'uncached', + referenceParts: [{ text: 'A different uncached reference' }] }); + assert.equal((await session.findOnline({ requestID: 'permission-offline-find', + referenceKey: miss.referenceKey })).status, 'offline'); + assert.isFalse(provider.called, 'Offline cache miss reached a provider'); + session._removeReference(miss.referenceKey); + offline.restore(); + let library = Zotero.Libraries.get(session._libraryID); + let getLibrary = sandbox.stub(Zotero.Libraries, 'get').callThrough(); + getLibrary.withArgs(session._libraryID).returns({ editable: false }); + let readonly = await open('permission-readonly'); + assert.equal(readonly.lookupSource, 'online'); + assert.notInclude(readonly.result.actions, 'add-to-library'); + getLibrary.restore(); + assert.isTrue(library.editable); + let release; + duplicate.callsFake(() => new Promise(resolve => release = resolve)); + let disposed = open('permission-dispose-during-restore'); + await waitForCacheOperation(() => release); + session.dispose(); + release({ status: 'absent' }); + assert.equal((await disposed).status, 'cancelled'); + assert.equal(session._onlineMatchCache.size, 0); + assert.equal([...session._references.values()].filter(reference => reference.result).length, 0); + }); + + it("should not cache a cancelled explicit lookup or leak cancelled restoration bindings", async function () { + let session = await createSession(); + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ itemType: 'journalArticle', title: 'Cancelled cache work', DOI: '10.1234/cache-cancel' }); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let parts = [{ text: 'doi:10.1234/cache-cancel' }]; + let local = await session.matchReference({ requestID: 'cancel-open', sourceKey: 'cancel', referenceParts: parts }); + let release; + sandbox.stub(onlineReferenceLookup, 'findOnline').callsFake(() => new Promise(resolve => release = resolve)); + let online = session.findOnline({ requestID: 'cancel-find', referenceKey: local.referenceKey }); + await waitForCacheOperation(() => release); + session.cancel({ requestID: 'cancel-find' }); + release(strictMatch(transient)); + assert.equal((await online).status, 'cancelled'); + assert.equal(session._onlineMatchCache.size, 0); + cacheMatch(session, parts.map(part => part.text).join(''), strictMatch(transient)); + release = null; + sandbox.stub(BibliographyMatcher, 'checkForDuplicate').callsFake(() => new Promise(resolve => release = resolve)); + let reopened = session.matchReference({ requestID: 'cancel-restore', sourceKey: 'cancel', referenceParts: parts }); + await waitForCacheOperation(() => release); + session.cancel({ requestID: 'cancel-restore' }); + release({ status: 'absent' }); + assert.equal((await reopened).status, 'cancelled'); + assert.equal([...session._references.values()].filter(reference => reference.result).length, 0); + }); + + it("should preserve metadata when another same-owner restoration supersedes a pending request", async function () { + let session = await createSession(); + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ itemType: 'journalArticle', title: 'Retained across refresh', DOI: '10.1234/refresh-cache' }); + let referenceParts = [{ text: 'doi:10.1234/refresh-cache' }]; + cacheMatch(session, referenceParts.map(part => part.text).join(''), strictMatch(transient)); + let local = sandbox.stub(BibliographyMatcher, 'matchLibrary').rejects(new Error('Cache should be first')); + let release; + let duplicate = sandbox.stub(BibliographyMatcher, 'checkForDuplicate'); + duplicate.onFirstCall().callsFake(() => new Promise(resolve => release = resolve)); + duplicate.resolves({ status: 'absent' }); + let first = session.matchReference({ requestID: 'refresh-old', sourceKey: 'same', referenceParts }); + await waitForCacheOperation(() => release); + let second = await session.matchReference({ requestID: 'refresh-new', sourceKey: 'same', referenceParts }); + release({ status: 'absent' }); + assert.equal((await first).status, 'cancelled'); + assert.equal(second.status, 'strong'); + assert.equal(second.lookupSource, 'online'); + assert.equal(session._onlineMatchCache.size, 1); + assert.equal([...session._references.values()].filter(reference => reference.result).length, 1); + assert.isTrue(!!bindingFor(session, second.result.resultID)); + assert.isFalse(local.called); + }); + + it("should retain verified metadata and allow recovery after a cached DTO failure", async function () { + let session = await createSession(); + let item = new Zotero.Item('journalArticle'); + item.fromJSON({ itemType: 'journalArticle', title: 'Verified work' }); + let referenceParts = [{ text: 'Verified reference' }]; + cacheMatch(session, referenceParts[0].text, strictMatch(item)); + let raw = session._onlineMatchCache.values().next().value; + sandbox.stub(BibliographyMatcher, 'checkForDuplicate').resolves({ status: 'absent' }); + let display = sandbox.stub(session, '_getDisplayMetadata').throws(new Error('Display failed')); + let request = { sourceKey: 'reference', referenceParts }; + let failed = await session.matchReference({ ...request, requestID: 'failed' }); + assert.equal(failed.status, 'error'); + assert.equal(failed.lookupSource, 'online'); + assert.notProperty(failed, 'result'); + assert.notProperty(session._references.get(failed.referenceKey), 'result'); + assert.equal(session._onlineMatchCache.values().next().value, raw); + display.restore(); + let restored = await session.matchReference({ ...request, requestID: 'retry' }); + assert.equal(restored.status, 'strong'); + assert.equal(restored.result.display.title, 'Verified work'); + }); + + it("should preserve lookup failure statuses and existing actions without a result", async function () { + let item = await createItem(); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(strictMatch(item)); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline'); + let local = await session.matchReference({ requestID: 'status-local', sourceKey: 'status', + referenceParts: [{ text: item.getField('title') }] }); + let binding = bindingFor(session, local.result.resultID); + for (let status of ['invalid', 'incomplete', 'offline', 'rate-limited', 'timeout', 'cancelled', 'error']) { + provider.resolves({ status }); + let response = await session.findOnline({ requestID: `status-${status}`, referenceKey: local.referenceKey }); + assert.equal(response.status, status); + assert.notProperty(response, 'result'); + assert.strictEqual(bindingFor(session, binding.resultID), binding); + assert.equal(session._onlineMatchCache.size, 0); + } + }); + + it("should not cache transient online failures", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', title: 'Recovered result', DOI: '10.1234/recovered', + }); + let session = await createSession(); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves(unmatched()); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline'); + provider.onFirstCall().resolves({ status: 'timeout' }); + provider.onSecondCall().resolves(strictMatch(transient)); + let local = await session.matchReference({ + requestID: 'local-transient-cache', sourceKey: 'reference-transient-cache', + referenceParts: [{ text: 'doi:10.1234/recovered' }], + }); + + let first = await session.findOnline({ + requestID: 'online-transient-cache-1', referenceKey: local.referenceKey, + }); + let second = await session.findOnline({ + requestID: 'online-transient-cache-2', referenceKey: local.referenceKey, + }); + + assert.equal(first.status, 'timeout'); + assert.equal(second.status, 'strong'); + assert.equal(provider.callCount, 2); + }); + + it("should allow explicit Find after a match, no-match, or incomplete local lookup", async function () { + let item = await createItem(); + let remote = new Zotero.Item('journalArticle'); + remote.fromJSON({ itemType: 'journalArticle', title: 'Online work', DOI: '10.1234/remote' }); + let localMatcher = sandbox.stub(BibliographyMatcher, 'matchLibrary'); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline').resolves(strictMatch(remote)); + for (let outcome of [strictMatch(item), unmatched(), { status: 'incomplete' }]) { + localMatcher.resolves(outcome); + let session = await createSession(); + let callsBefore = provider.callCount; + let local = await session.matchReference({ requestID: 'local', sourceKey: 'reference', + referenceParts: [{ text: 'A reference' }] }); + assert.equal(local.status, { matched: 'strong', unmatched: 'no-match', incomplete: 'incomplete' }[outcome.status]); + assert.equal(provider.callCount, callsBefore); + let online = await session.findOnline({ requestID: 'online', referenceKey: local.referenceKey }); + assert.equal(online.status, 'strong'); + assert.equal(online.result.libraryState, 'unsaved'); + assert.equal(provider.callCount, callsBefore + 1); + session.dispose(); + } + }); + + it("should reconcile an item added during online lookup without repeating citation matching", async function () { + let transient = new Zotero.Item('journalArticle'); + transient.fromJSON({ + itemType: 'journalArticle', title: 'Remote result', DOI: '10.1234/remote-result', + }); + let match = sandbox.stub(BibliographyMatcher, 'matchLibrary'); + match.onFirstCall().resolves(unmatched()); + let evaluate = sandbox.spy(BibliographyMatcher, 'matchCandidates'); + let existing; + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline') + .callsFake(async (reference) => { + existing = await createItem({ title: 'Remote result', DOI: '10.1234/remote-result' }); + match.resolves(strictMatch(existing)); + return BibliographyMatcher.matchCandidates(reference, [transient]); + }); + let session = await createSession(); + let saver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + let local = await session.matchReference({ + requestID: 'local-race', sourceKey: 'reference-race', + referenceParts: [{ text: 'doi:10.1234/remote-result' }], + }); + + let response = await session.findOnline({ + requestID: 'online-race', referenceKey: local.referenceKey, + }); + + assert.equal(response.status, 'strong'); + assert.equal(response.result.libraryState, 'existing'); + assert.include(response.result.actions, 'show-in-library'); + assert.notInclude(response.result.actions, 'add-to-library'); + assert.isTrue(provider.calledOnce); + assert.isTrue(evaluate.calledOnce); + assert.isTrue(match.calledOnce); + assert.equal(bindingFor(session, response.result.resultID).itemID, existing.id); + assert.isFalse(saver.called); + }); + it("should share identical Crossref translations without sharing caller cancellation", async function () { + let controller = new AbortController(); + let release; + let metadata = { itemType: 'journalArticle', title: 'Crossref shared translation', date: '2021', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Lovelace' }] }; + let translate = sandbox.stub(onlineReferenceLookup, '_runTranslation').callsFake(() => (new Promise(resolve => release = resolve))); + let text = 'Ada Lovelace. 2021. Crossref shared translation.'; + let first = getPromiseError(onlineReferenceLookup.findOnline(text, { signal: controller.signal })); + let second = onlineReferenceLookup.findOnline(text); + await waitForCacheOperation(() => release); + controller.abort(); + assert.instanceOf(await first, Zotero.HTTP.CancelledException); + release([metadata]); + assert.equal((await second).status, 'matched'); + assert.isTrue(translate.calledOnce); + assert.equal(onlineReferenceLookup._providerFlights.size, 0); + }); + + it("should preserve unreadable provider competitors rather than certify a partial pool", async function () { + sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves([ + { itemType: 'journalArticle', title: 'Valid work', DOI: '10.1234/valid' }, null, + ]); + sandbox.stub(onlineReferenceLookup, '_translateSearch').resolves([]); + let result = await onlineReferenceLookup.findOnline('doi:10.1234/valid'); + assert.equal(result.status, 'incomplete'); + assert.notProperty(result, 'item'); + }); + + it("should reject a duplicate pending lookup ID without invalidating the original reference", async function () { + let item = await createItem(); + let session = await createSession(); + let release; + sandbox.stub(BibliographyMatcher, 'matchLibrary').callsFake(() => new Promise(resolve => release = resolve)); + let request = { requestID: 'pending', sourceKey: 'reference', referenceParts: [{ text: 'A reference' }] }; + let first = session.matchReference(request); + await waitForCacheOperation(() => release); + assert.equal((await session.matchReference(request)).status, 'invalid'); + release(strictMatch(item)); + assert.equal((await first).status, 'strong'); + }); +}); + +describe('Reader reference lookup', function () { + let sandbox; + let lookup = onlineReferenceLookup; + let metadata = { itemType: 'journalArticle', title: 'Reference lookup boundary', + DOI: '10.1234/lookup-boundary', date: '2020' }; + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(lookup, '_translateLandingPage').resolves([]); + }); + afterEach(function () { + sandbox.restore(); + }); + + it('verifies the complete competing pool before returning a result', async function () { + let candidates = [metadata, { ...metadata, DOI: '10.1234/competing', title: 'Competing work' }]; + sandbox.stub(lookup, '_translateIdentifier').resolves(candidates); + sandbox.stub(lookup, '_translateSearch').resolves([]); + let result = await lookup.findOnline('doi:' + metadata.DOI); + assert.equal(result.status, 'matched'); + assert.equal(result.item.getField('DOI'), metadata.DOI); + assert.notProperty(result, 'candidates'); + assert.notProperty(result, 'referenceKey'); + assert.notProperty(result, 'actions'); + assert.notProperty(result, 'display'); + let ambiguous = await lookup.findOnline('doi:' + metadata.DOI + ' doi:10.1234/competing'); + assert.equal(ambiguous.status, 'unmatched'); + }); + + it('reuses a confirmed identifier outcome without verifying the same pool twice', async function () { + let identifier = sandbox.stub(lookup, '_translateIdentifier').resolves([metadata]); + let crossref = sandbox.stub(lookup, '_translateSearch').rejects(new Error('Unexpected fallback')); + let verify = sandbox.spy(BibliographyMatcher, 'matchCandidates'); + let reference = 'doi:' + metadata.DOI; + let result = await lookup.findOnline(reference); + assert.equal(result.status, 'matched'); + assert.equal(result.item.getField('DOI'), metadata.DOI); + assert.isTrue(verify.calledOnceWithExactly(reference, [metadata])); + assert.isTrue(identifier.calledOnce); + assert.isFalse(crossref.called); + }); + + it('adds only a verified publisher abstract and otherwise preserves the online match', async function () { + let source = { ...metadata, url: `https://publisher.example/articles/${metadata.DOI}/full` }; + sandbox.stub(lookup, '_translateIdentifier').resolves([source]); + let publisher = { ...source, title: 'Do not replace the verified title', abstractNote: 'Publisher abstract' }; + let landing = lookup._translateLandingPage; + let baseline = BibliographyMatcher.createCandidateSnapshot(source); + landing.resolves([publisher]); + let enriched = await lookup.findOnline('doi:' + metadata.DOI); + assert.equal(enriched.status, 'matched'); + assert.deepEqual(BibliographyMatcher.createCandidateSnapshot(enriched.item), { ...baseline, abstractNote: publisher.abstractNote }); + for (let result of [ + [{ ...publisher, DOI: '10.1234/other' }], + [{ ...publisher, extra: 'DOI: 10.1234/other' }], + [publisher, publisher], + [{ ...publisher, abstractNote: 'x'.repeat(BibliographyMatcher.MAX_CANDIDATE_JSON_LENGTH) }], + new Error('Publisher unavailable'), + ]) { + if (result instanceof Error) landing.rejects(result); + else landing.resolves(result); + let match = await lookup.findOnline('doi:' + metadata.DOI); + assert.equal(match.status, 'matched'); + assert.deepEqual(BibliographyMatcher.createCandidateSnapshot(match.item), baseline); + } + sandbox.stub(lookup, '_translateSearch').resolves([source]); + lookup._translateIdentifier.resolves([]); + landing.resolves([publisher]); + let crossref = await lookup.findOnline('doi:' + metadata.DOI); + assert.equal(crossref.item.getField('abstractNote'), publisher.abstractNote, 'Crossref matches are enriched too'); + lookup._translateIdentifier.resolves([{ ...source, abstractNote: 'Provider abstract' }]); + landing.resetHistory(); + assert.equal((await lookup.findOnline(metadata.DOI)).item.getField('abstractNote'), 'Provider abstract'); + assert.isFalse(landing.called, 'existing abstracts do not start a publisher lookup'); + }); + + it('bounds publisher enrichment and ignores results arriving after timeout or cancellation', async function () { + sandbox.stub(lookup, '_translateIdentifier').resolves([metadata]); + sandbox.stub(lookup, 'ABSTRACT_TIMEOUT_MS').value(20); + let verify = sandbox.spy(BibliographyMatcher, 'matchCandidates'); + for (let cancel of [false, true]) { + let started = Zotero.Promise.defer(), reply = Zotero.Promise.defer(); + lookup._translateLandingPage.callsFake(() => { + started.resolve(); + return reply.promise; + }); + let controller = new AbortController(); + let pending = lookup.findOnline(metadata.DOI, { signal: controller.signal }); + let failure = cancel && getPromiseError(pending); + await started.promise; + if (cancel) { + controller.abort(); + assert.instanceOf(await failure, Zotero.HTTP.CancelledException); + } + else assert.equal((await pending).status, 'matched'); + let item = verify.lastCall.returnValue.item; + reply.resolve([{ ...metadata, abstractNote: 'Too late' }]); + await Zotero.Promise.delay(0); + assert.equal(item.getField('abstractNote'), '', 'late enrichment cannot mutate delivered metadata'); + } + }); + + it('keeps failed or incomplete retrieval distinct from a searched empty pool', async function () { + sandbox.stub(lookup, '_translateIdentifier').resolves([]); + let provider = sandbox.stub(lookup, '_translateSearch'); + for (let status of ['timeout', 'error', 'incomplete']) { + let error = status === 'timeout' ? new Zotero.HTTP.TimeoutException(10) : new Error('Provider failure'); + if (status === 'incomplete') error.name = 'IncompleteCandidatePoolError'; + provider.rejects(error); + assert.deepEqual(await lookup.findOnline(metadata.DOI), { status }); + } + provider.resolves([]); + let empty = await lookup.findOnline(metadata.DOI); + assert.equal(empty.status, 'unmatched'); + assert.notProperty(empty, 'item'); + }); + + ['identifier', 'crossref'].forEach((route) => { + it(`does not return ${route} results after caller cancellation`, async function () { + let controller = new AbortController(); + let identifier = sandbox.stub(lookup, '_translateIdentifier').resolves([]); + let crossref = sandbox.stub(lookup, '_translateSearch').resolves([]); + (route === 'identifier' ? identifier : crossref).callsFake(async () => { + controller.abort(); + return [metadata]; + }); + let error = await getPromiseError(lookup.findOnline(metadata.DOI, { signal: controller.signal })); + assert.instanceOf(error, Zotero.HTTP.CancelledException); + }); + }); + + it('rejects an already cancelled lookup even when the citation is invalid', async function () { + let controller = new AbortController(); + assert.deepEqual(await lookup.findOnline(''), { status: 'invalid' }); + controller.abort(); + let error = await getPromiseError(lookup.findOnline('', { signal: controller.signal })); + assert.instanceOf(error, Zotero.HTTP.CancelledException); + }); +}); + +describe('Reader reference translators', function () { + let sandbox; + let identifier = { DOI: '10.1234/provider-abstract' }; + let candidate = () => ({ + itemType: 'journalArticle', ...identifier, title: 'Verified baseline title', + date: '2020', url: 'https://publisher.example/article', abstractNote: 'Provider abstract', + creators: [{ firstName: 'Jane', lastName: 'Example', creatorType: 'author' }], + }); + let response = (url, data) => ({ + status: 200, statusText: 'OK', responseURL: url, response: data, + responseText: typeof data === 'string' ? data : JSON.stringify(data), + getAllResponseHeaders: () => 'content-type: application/json\r\n', + getResponseHeader: () => null, + }); + + beforeEach(function () { + sandbox = sinon.createSandbox(); + let translator = buildDummyTranslator('search', ` + function detectSearch() { return true; } + async function doSearch() { + let data = await ZU.requestJSON('https://metadata.example/search'); + let item = new Zotero.Item(data.itemType); + Object.assign(item, data); + item.complete(); + } + `); + sandbox.stub(Zotero.Translate.Search.prototype, 'getTranslators').resolves([translator]); + }); + + afterEach(function () { + sandbox.restore(); + assert.equal(onlineReferenceLookup._providerFlights.size, 0); + }); + + it('previews identifier metadata without saving or following its publisher URL', async function () { + let request = sandbox.stub(Zotero.HTTP, 'request') + .callsFake(async (method, url) => response(url, candidate())); + let count = await Zotero.DB.valueQueryAsync('SELECT COUNT(*) FROM items'); + let result = await onlineReferenceLookup.findOnline('doi:' + identifier.DOI); + assert.equal(result.status, 'matched'); + assert.property(result, 'item'); + assert.equal(result.item.getField('abstractNote'), 'Provider abstract'); + assert.equal(await Zotero.DB.valueQueryAsync('SELECT COUNT(*) FROM items'), count); + assert.isTrue(request.calledOnce); + assert.equal(request.firstCall.args[1], 'https://metadata.example/search'); + }); + + it('loads the DOI landing page and translates its abstract without saving items', async function () { + let doc = new DOMParser().parseFromString(` + + `, 'text/html'); + let translator = buildDummyTranslator('web', ` + function detectWeb() { return 'journalArticle'; } + function doWeb(doc) { + let item = new Zotero.Item('journalArticle'); + item.title = 'Publisher title'; + item.DOI = doc.querySelector('meta[name="citation_doi"]').content; + item.abstractNote = doc.querySelector('meta[name="description"]').content; + item.complete(); + } + `, { translatorID: 'publisher-abstract-test' }); + sandbox.stub(Zotero.Translate.Web.prototype, 'getTranslators').resolves([translator]); + let request = sandbox.stub(Zotero.HTTP, 'request').callsFake(async (method, url) => ( + url === 'https://metadata.example/search' + ? response(url, { ...candidate(), abstractNote: '' }) + : { ...response('https://publisher.example/article', ''), response: doc } + )); + let count = await Zotero.DB.valueQueryAsync('SELECT COUNT(*) FROM items'); + let match = await onlineReferenceLookup.findOnline('doi:' + identifier.DOI); + assert.equal(match.status, 'matched'); + assert.equal(match.item.getField('title'), candidate().title); + assert.equal(match.item.getField('abstractNote'), 'Publisher abstract'); + assert.isTrue(request.calledTwice); + assert.equal(request.secondCall.args[1], 'https://doi.org/' + encodeURIComponent(identifier.DOI)); + assert.equal(request.secondCall.args[2].responseType, 'document'); + assert.equal(await Zotero.DB.valueQueryAsync('SELECT COUNT(*) FROM items'), count); + }); + + it('reuses an outstanding identifier request after timeout or cancellation', async function () { + for (let cancel of [false, true]) { + let started = Zotero.Promise.defer(); + let reply = Zotero.Promise.defer(); + let request = sandbox.stub(Zotero.HTTP, 'request').callsFake(async (method, url) => { + started.resolve(); + return response(url, await reply.promise); + }); + let controller = new AbortController(); + let first = getPromiseError(onlineReferenceLookup._runProvider( + () => onlineReferenceLookup._translateIdentifier(identifier), controller.signal, cancel ? 5000 : 100 + )); + try { + await started.promise; + if (cancel) controller.abort(); + assert.instanceOf(await first, cancel ? Zotero.HTTP.CancelledException : Zotero.HTTP.TimeoutException); + let reopened = onlineReferenceLookup._translateIdentifier({ ...identifier }); + reply.resolve(candidate()); + assert.equal((await reopened)[0].abstractNote, 'Provider abstract'); + assert.isTrue(request.calledOnce); + } + finally { + reply.resolve(candidate()); + await Promise.allSettled([...onlineReferenceLookup._providerFlights.values()]); + request.restore(); + } + } + }); + + it('does not certify an empty pool when a standard translator hides a transport failure', async function () { + let request = sandbox.stub(Zotero.HTTP, 'request').rejects(new Error('Fixture network failure')); + let error = await getPromiseError(onlineReferenceLookup._translateIdentifier(identifier)); + assert.equal(String(error), new Zotero.Translate.Search().ERROR_NO_RESULTS); + assert.equal(onlineReferenceLookup._providerFlights.size, 0); + let result = await onlineReferenceLookup.findOnline('doi:' + identifier.DOI + ' doi:10.1234/another'); + assert.equal(result.status, 'incomplete'); + request.callsFake(async (method, url) => response(url, candidate())); + assert.lengthOf(await onlineReferenceLookup._translateIdentifier(identifier), 1); + }); + + it('runs the packaged Crossref translator through the standard framework', async function () { + let source = await Zotero.File.getContentsFromURLAsync('resource://app/translators/Crossref%20REST.js'); + let metadata = JSON.parse(/^\s*{[\S\s]*?}\s*?[\r\n]/.exec(source)[0]); + metadata.cacheCode = true; + let translator = Zotero.Translators.load(metadata, null, source); + let data = JSON.stringify({ status: 'ok', message: { items: Array.from({ length: 20 }, (_, i) => ({ + type: 'journal-article', title: [`Fixture title ${i}`], DOI: i ? `10.1234/provider-${i}` : identifier.DOI, + author: [{ given: 'Jane', family: 'Example' }], issued: { 'date-parts': [[2020]] }, + abstract: 'Crossref abstract', + })) } }); + // The unchanged Crossref translator uses the legacy callback API. + let request = sandbox.stub(Zotero.HTTP, 'doGet').callsFake((url, callback) => { + let xhr = response(url, data); + Zotero.Promise.resolve().then(() => callback(xhr)); + return xhr; + }); + let result = await onlineReferenceLookup._translateSearch({ search: { itemType: 'document', query: 'Fixture title' }, translator }); + assert.lengthOf(result, 20, 'The standard Crossref result pool must not be truncated to five choices'); + assert.equal(result[0].DOI, identifier.DOI); + assert.include(result[0].abstractNote, 'Crossref abstract'); + assert.isTrue(request.calledOnce); + assert.include(request.firstCall.args[0], 'query.bibliographic=Fixture%20title'); + assert.notInclude(request.firstCall.args[0], 'rows='); + }); +}); + +describe("Reader reference identity and saving", function () { + let sandbox, items, sessions; + let libraryID; + let candidate = (changes = {}) => ({ + itemType: 'journalArticle', title: 'Structured identity regression', date: '2021', + creators: [{ creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }], + ...changes, + }); + beforeEach(function () { + libraryID = Zotero.Libraries.userLibraryID; + sandbox = sinon.createSandbox(); + items = []; + sessions = []; + Zotero.Prefs.set('downloadAssociatedFiles', false); + }); + afterEach(async function () { + for (let session of sessions) session.dispose(); + sandbox.restore(); + Zotero.Prefs.clear('downloadAssociatedFiles'); + for (let item of items.reverse()) if (Zotero.Items.exists(item.id)) await item.eraseTx(); + }); + async function store(json) { + let item = new Zotero.Item(json.itemType); + item.libraryID = libraryID; + item.fromJSON(json); + await item.saveTx(); + items.push(item); + return item; + } + async function save(json) { + let result = await referenceSaver.saveCandidate(json, { libraryID }); + if (!result.existing) items.push(result.item); + return result; + } + function findDuplicate(json) { + return BibliographyMatcher.checkForDuplicate(BibliographyMatcher.createCandidateSnapshot(json), libraryID); + } + async function sessionFor(json, existing = false) { + let attachment = new Zotero.Item('attachment'); + attachment.libraryID = libraryID; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_URL; + attachment.setField('url', 'https://example.invalid/source.pdf'); + await attachment.saveTx(); + items.push(attachment); + let session = new ReaderReferenceSession(attachment); + sessions.push(session); + let item = existing ? json : new Zotero.Item(json.itemType); + if (!existing) item.fromJSON(json); + let referenceKey = session._replaceReference('reference', item.getField('title', false, true)); + let service = existing ? BibliographyMatcher : onlineReferenceLookup; + let method = existing ? 'matchLibrary' : 'findOnline'; + let lookup = sandbox.stub(service, method).resolves({ status: 'matched', item }); + let response; + try { + response = await session._lookup({ requestID: 'fixture' }, referenceKey, !existing); + } + finally { + lookup.restore(); + } + let result = response.result; + return { session, referenceKey, resultID: result.resultID, result }; + } + it("saves distinct full creators separately and coalesces only identical candidates", async function () { + let alice = candidate(); + let bob = candidate({ creators: [{ creatorType: 'author', firstName: 'Bob', lastName: 'Smith' }] }); + let [a, b, repeated] = await Promise.all([save(alice), save(bob), save(alice)]); + assert.isFalse(a.existing); + assert.isFalse(b.existing); + assert.isTrue(repeated.existing); + assert.notEqual(a.item.id, b.item.id); + assert.equal(a.item.id, repeated.item.id); + assert.equal(b.item.getCreators()[0].firstName, 'Bob'); + assert.lengthOf(BibliographyMatcher.deduplicateCandidates([alice, bob]), 2); + }); + it("preserves complete schema metadata independently of display limits", async function () { + let json = candidate({ itemType: 'preprint', abstractNote: 'x'.repeat(2100), repository: 'Research archive', archiveID: '12345' }); + let result = await save(json); + assert.equal(result.item.getField('abstractNote'), json.abstractNote); + assert.equal(result.item.getField('repository'), json.repository); + assert.equal(result.item.getField('archiveID'), json.archiveID); + let { result: dto } = await sessionFor(result.item, true); + assert.equal(dto.display.abstract, json.abstractNote); + }); + it("keeps different item types with the same mapped title distinct", async function () { + await store({ itemType: 'case', caseName: 'École proceedings', dateDecided: '2021' }); + let json = candidate({ title: 'École proceedings' }); + assert.equal((await findDuplicate(json)).status, 'absent'); + let saved = await save(json); + assert.isFalse(saved.existing); + assert.equal(saved.item.itemType, 'journalArticle'); + }); + + it("saves and restores mapped-title candidates through the popup lifecycle", async function () { + for (let itemType of ['case', 'email', 'statute']) { + let field = Zotero.ItemFields.getName(Zotero.ItemFields.getFieldIDFromTypeAndBase(itemType, 'title')); + let json = { itemType, [field]: `Online ${itemType}`, extra: `DOI: 10.1234/mapped-${itemType}` }; + let { session, referenceKey } = await sessionFor(json); + let item = new Zotero.Item(itemType); + item.fromJSON(json); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline') + .resolves({ status: 'matched', item }); + let found = await session.findOnline({ requestID: 'mapped-find', referenceKey }); + assert.equal(found.status, 'strong'); + assert.equal(found.result.display.title, json[field]); + let reopened = await session.matchReference({ requestID: 'mapped-reopen', sourceKey: 'mapped', + referenceParts: [{ text: session._references.get(referenceKey).text }] }); + assert.equal(reopened.lookupSource, 'online'); + let added = await session.performAction({ requestID: 'mapped-add', referenceKey: reopened.referenceKey, + resultID: reopened.result.resultID, action: 'add-to-library' }); + assert.equal(added.status, 'saved'); + let saved = await Zotero.Items.getAsync(session._references.get(reopened.referenceKey).result.itemID); + items.push(saved); + assert.equal(saved.getField('title', false, true), json[field]); + assert.equal((await findDuplicate(json)).item.id, saved.id); + provider.restore(); + } + }); + + it("uses mapped titles and dates for structured identity without identifiers", async function () { + let creatorType = Zotero.CreatorTypes.getName(Zotero.CreatorTypes.getPrimaryIDForType(Zotero.ItemTypes.getID('case'))); + let json = { itemType: 'case', caseName: 'Mapped identity', dateDecided: '2021', + creators: [{ creatorType, firstName: 'Ada', lastName: 'Example' }] }; + let item = await store(json); + let duplicate = await findDuplicate(json); + assert.equal(duplicate.status, 'matched'); + assert.equal(duplicate.item.id, item.id); + assert.equal((await findDuplicate({ ...json, dateDecided: '2022' })).status, 'absent'); + }); + + it("distinguishes mapped volume and page fields with incomplete creator names", async function () { + let creatorType = Zotero.CreatorTypes.getName(Zotero.CreatorTypes.getPrimaryIDForType(Zotero.ItemTypes.getID('case'))); + let json = { itemType: 'case', caseName: 'Mapped locator identity', dateDecided: '2021', + reporterVolume: '1', firstPage: '10', creators: [{ creatorType, firstName: 'A', lastName: 'Example' }] }; + let item = await store(json); + assert.equal((await findDuplicate(json)).item.id, item.id); + for (let fields of [{ reporterVolume: '2' }, { firstPage: '11' }]) { + assert.equal((await findDuplicate({ ...json, ...fields })).status, 'absent'); + } + }); + + it("preserves all 250 creators through online registration and a real metadata save", async function () { + let json = candidate({ + title: 'Large collaborative author list', + creators: Array.from({ length: 250 }, (_, index) => ({ + creatorType: 'author', firstName: `Given ${index}`, lastName: `Family ${index}`, + })), + abstractNote: 'Complete abstract. '.repeat(1500).trim(), + }); + let { session, referenceKey, resultID, result } = await sessionFor(json); + assert.deepEqual(session._references.get(referenceKey).result.candidateJSON.creators, json.creators); + assert.lengthOf(result.display.abstract, ReaderReferenceSession.MAX_ABSTRACT_LENGTH); + assert.isTrue(result.display.abstract.endsWith('…')); + let response = await session.performAction({ requestID: 'large-save', referenceKey, resultID, action: 'add-to-library' }); + assert.equal(response.status, 'saved'); + let saved = await Zotero.Items.getAsync(session._references.get(referenceKey).result.itemID); + items.push(saved); + assert.deepEqual(saved.getCreators().map(({ creatorTypeID, firstName, lastName }) => ({ + creatorType: Zotero.CreatorTypes.getName(creatorTypeID), firstName, lastName, + })), json.creators); + assert.equal(saved.getField('abstractNote'), json.abstractNote); + }); + it("rejects oversized matched metadata before publication or persistence", async function () { + let oversized = candidate({ title: 'Oversized metadata', abstractNote: 'x'.repeat(256 * 1024 + 1) }); + let saver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + let error = await getPromiseError(save(oversized)); + assert.include(String(error), 'candidate payload limit'); + assert.isFalse(saver.called); + let { session, referenceKey } = await sessionFor(candidate()); + delete session._references.get(referenceKey).result; + session._onlineMatchCache.clear(); + let item = new Zotero.Item(oversized.itemType); + item.fromJSON(oversized); + sandbox.stub(onlineReferenceLookup, 'findOnline').resolves({ status: 'matched', item }); + let response = await session.findOnline({ requestID: 'oversized', referenceKey }); + assert.equal(response.status, 'error'); + assert.notProperty(response, 'result'); + assert.notProperty(session._references.get(referenceKey), 'result'); + assert.equal(session._onlineMatchCache.size, 0); + assert.isFalse(saver.called); + }); + + it("requires same structured record identity before suppressing Add for a shared ISBN", async function () { + let creators = [{ creatorType: 'author', firstName: 'Jayeeta', lastName: 'Chattopadhyay' }, + { creatorType: 'author', firstName: 'Nimmy', lastName: 'Srivastava' }]; + let book = await store({ itemType: 'book', title: 'Application of Nanomaterials in Chemical Sensors and Biosensors', + creators, date: '2021-07-23', ISBN: '9781003009085', extra: 'DOI: 10.1201/9781003009085' }); + let chapter = { itemType: 'bookSection', title: 'Nanomaterials as Heavy Metal Sensors in Water', + creators, date: '2021', bookTitle: book.getField('title'), ISBN: '9781003009085' }; + let sibling = await store({ ...chapter, title: 'A different chapter in the same edition' }); + assert.equal((await findDuplicate(chapter)).status, 'absent'); + let { session, referenceKey, resultID } = await sessionFor(chapter); + let response = await session.performAction({ requestID: 'add-isbn-chapter', referenceKey, resultID, action: 'add-to-library' }); + assert.equal(response.status, 'saved'); + let saved = await Zotero.Items.getAsync(session._references.get(referenceKey).result.itemID); + items.push(saved); + assert.notEqual(saved.id, book.id); + assert.notEqual(saved.id, sibling.id); + assert.equal(saved.getField('title'), chapter.title); + assert.equal(saved.itemType, 'bookSection'); + let duplicate = await findDuplicate(chapter); + assert.equal(duplicate.status, 'matched'); + assert.equal(duplicate.item.id, saved.id); + let lookup = await session.matchReference({ requestID: 'isbn-only-popup', sourceKey: 'isbn-only', + referenceParts: [{ text: `ISBN ${chapter.ISBN}` }] }); + assert.notEqual(lookup.status, 'strong'); + }); + it("does not suggest a differently titled shared-ISBN sibling or suppress Add", async function () { + let authors = ['Adam Jorgensen', 'Jorge Segarra', 'Patrick LeBlanc', 'Jose Chinchilla', 'Aaron Nelson'] + .map((name) => { + let [firstName, lastName] = name.split(' '); return { firstName, lastName, creatorType: 'author' }; + }); + let json = { itemType: 'bookSection', title: 'Laying the Foundations', creators: authors, + date: 'August 28, 2012', bookTitle: 'Microsoft SQL Server 2012 Bible', ISBN: '9781118282175' }; + await store({ ...json, title: 'The World of SQL Server' }); + let { session } = await sessionFor(json); + let text = [json.title, + authors.map(c => `${c.firstName} ${c.lastName}`).join('; '), + json.date, + json.bookTitle, + `ISBN ${json.ISBN}`].join('. '); + let network = ['request', 'doGet', 'doPost'].map(method => sandbox.stub(Zotero.HTTP, method) + .throws(new Error('Local identity check must not request the network'))); + let lookup = await session.matchReference({ requestID: 'sibling', sourceKey: 'sibling', referenceParts: [{ text }] }); + assert.equal(lookup.status, 'no-match'); + assert.notProperty(lookup, 'result'); + assert.equal((await findDuplicate(json)).status, 'absent'); + assert.isTrue(network.every(stub => !stub.called)); + }); + it("keeps ISBN-matched incomplete structured identity unresolved instead of silently adding a duplicate", async function () { + let json = candidate({ itemType: 'book', ISBN: '9781118282175' }); + let item = await store(json); + assert.equal((await findDuplicate({ ...json, ISBN: '1118282175' })).item.id, item.id); + assert.equal((await findDuplicate({ ...json, creators: [] })).status, 'unresolved'); + }); + it("reuses normalized ISBN titles and blocks uncertain identity without saving duplicate metadata", async function () { + let json = candidate({ itemType: 'book', ISBN: '9781118282175' }); + let item = await store(json); + assert.equal((await findDuplicate(json)).item.id, item.id); + let saver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + for (let [changes, expected] of [ + [{ title: json.title + '.' }, 'unresolved'], + [{ title: json.title.toUpperCase() }, 'matched'], + [{ title: json.title.replaceAll(' ', ' ') }, 'matched'], + [{ date: '2021-01-01' }, 'unresolved'], + ]) { + let variant = { ...json, ...changes }; + let duplicate = await findDuplicate(variant); + assert.equal(duplicate.status, expected); + if (expected === 'matched') assert.equal(duplicate.item.id, item.id); + let { session, referenceKey, resultID, result } = await sessionFor(variant); + if (expected === 'matched') { + assert.equal(result.libraryState, 'existing'); + assert.notInclude(result.actions, 'add-to-library'); + assert.equal(session._references.get(referenceKey).result.itemID, item.id); + } + else { + let response = await session.performAction({ requestID: 'add-variant', referenceKey, resultID, action: 'add-to-library' }); + assert.equal(response.status, 'duplicate-conflict'); + } + } + assert.isFalse(saver.called); + }); + it("bounds the union of ISBN and title candidates before structured inspection", async function () { + let matcher = BibliographyMatcher; + let first = await store(candidate()); + let second = await store(candidate({ title: 'Other retrieved record' })); + sandbox.stub(matcher, 'MAX_CANDIDATES').value(1); + sandbox.stub(matcher, '_searchByIdentifiers').resolves({ ids: [first.id], incomplete: false }); + sandbox.stub(matcher, '_searchByExactTitle').resolves({ ids: [second.id], incomplete: false }); + assert.deepEqual(await findDuplicate(candidate()), { status: 'unresolved', reason: 'incomplete' }); + }); + it("finds identifierless title variants without weakening structured identity", async function () { + let json = candidate(); + let item = await store({ ...json, title: json.title.replaceAll(' ', ' ') }); + assert.equal((await findDuplicate(json)).item.id, item.id); + assert.equal((await findDuplicate({ ...json, title: json.title + '.' })).status, 'unresolved'); + assert.equal((await findDuplicate({ ...json, title: 'Structured regression' })).status, 'absent'); + }); + it("ignores local abstract size for identity but still bounds incoming candidates", async function () { + let json = candidate(); + let large = { ...json, abstractNote: 'x'.repeat(BibliographyMatcher.MAX_CANDIDATE_JSON_LENGTH + 1) }; + let item = await store(large); + assert.equal((await findDuplicate(json)).item.id, item.id); + assert.throws(() => BibliographyMatcher.createCandidateSnapshot(large), /payload limit/); + }); + it("finds a URL identifier without artificial citation or title-length limits", async function () { + let original = await store(candidate({ DOI: '10.1234/structured-long' })); + let json = candidate({ + title: 'Long title '.repeat(100), url: 'https://doi.org/10.1234/structured-long', + creators: Array.from({ length: 100 }, (_, i) => ({ creatorType: 'author', firstName: 'Longgivenname', lastName: `Family${i}` })), + }); + let result = await findDuplicate(json); + assert.equal(result.status, 'matched'); + assert.equal(result.item.id, original.id); + }); + it("blocks Add for an ambiguous identifier and preserves the unsaved result", async function () { + let json = candidate({ DOI: '10.1234/duplicate-conflict' }); + await store(json); + await store(json); + let { session, referenceKey, resultID } = await sessionFor(json); + let saver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + let response = await session.performAction({ requestID: 'add', referenceKey, resultID, action: 'add-to-library' }); + assert.equal(response.status, 'duplicate-conflict'); + assert.isFalse(saver.called); + assert.notProperty(session._references.get(referenceKey).result, 'itemID'); + assert.property(session._references.get(referenceKey).result, 'candidateJSON'); + }); + it("keeps missing or initial-only creator identity unresolved", async function () { + await store(candidate()); + for (let creators of [[], + [{ creatorType: 'author', firstName: 'A', lastName: 'Smith' }], + [{ creatorType: 'author', firstName: 'Alice Jane', lastName: 'Smith' }]]) { + let response = await findDuplicate(candidate({ creators })); + assert.equal(response.status, 'unresolved'); + } + }); + it("rejects invalid save input before identity checks or persistence", async function () { + let duplicate = sandbox.spy(BibliographyMatcher, 'checkForDuplicate'); + let saver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + let error = await getPromiseError(save({})); + assert.match(error.message, /missing required bibliographic fields/); + assert.isFalse(duplicate.called); + assert.isFalse(saver.called); + }); + it("treats duplicate lookup bounds as unresolved", async function () { + sandbox.stub(BibliographyMatcher, 'MAX_CANDIDATES').value(3); + for (let i = 0; i <= BibliographyMatcher.MAX_CANDIDATES; i++) { + await store(candidate({ DOI: `10.1234/overflow-${i}` })); + } + let result = await findDuplicate(candidate()); + assert.deepEqual(result, { status: 'unresolved', reason: 'incomplete' }); + let exact = await findDuplicate(candidate({ DOI: '10.1234/overflow-0' })); + assert.equal(exact.status, 'matched'); + assert.equal(exact.item.getField('DOI'), '10.1234/overflow-0'); + }); + it("uses only open-access and opted-in custom resolvers for automatic acquisition while preserving the manual path", async function () { + Zotero.Prefs.set('downloadAssociatedFiles', true); + let custom; + let originalGet = Zotero.Prefs.get.bind(Zotero.Prefs); + sandbox.stub(Zotero.Prefs, 'get').callsFake((name, ...args) => ( + name === 'findPDFs.resolvers' ? JSON.stringify([custom]) : originalGet(name, ...args) + )); + let http = sandbox.stub(Zotero.HTTP, 'request').resolves({ response: new DOMParser().parseFromString( + 'PDF', 'text/html' + ) }); + let oa = sandbox.stub(Zotero.Utilities.Internal, 'getOpenAccessPDFURLs').resolves([ + { url: 'https://files.example/oa.pdf' }, + ]); + let methods; + sandbox.stub(Zotero.Attachments, 'addFileFromURLs').callsFake(async (item, resolvers) => { + assert.isTrue(Zotero.Items.exists(item.id)); + methods = []; + for (let resolver of resolvers) { + let resolved = typeof resolver === 'function' ? await resolver() : [resolver]; + methods.push(...resolved.map(value => value.accessMethod)); + } + return false; + }); + let saved; + for (let automatic of [false, undefined, true]) { + custom = { + name: 'Synthetic custom resolver', method: 'GET', + url: 'https://resolver.example/{doi}', mode: 'html', selector: 'a', attribute: 'href', + ...(automatic === undefined ? {} : { automatic }), + }; + http.resetHistory(); + saved = await save(candidate({ title: `Resolver opt-in ${automatic}`, + DOI: `10.1234/automatic-${automatic}`, url: 'https://publisher.example/article' })); + await saved.fileAcquisitionPromise; + assert.sameMembers(methods, automatic === true ? ['oa', custom.name] : ['oa']); + assert.equal(http.callCount, automatic === true ? 1 : 0); + } + assert.equal(oa.callCount, 3); + custom.automatic = false; + await Zotero.Attachments.addAvailableFile(saved.item); + assert.includeMembers(methods, ['doi', 'url', 'oa', custom.name]); + }); + + it("runs automatic file resolvers after metadata commit without holding unrelated saves", async function () { + Zotero.Prefs.set('downloadAssociatedFiles', true); + let finish; + let pending = new Promise(resolve => finish = resolve); + let acquisition = sandbox.stub(Zotero.Attachments, 'addFileFromURLs').callsFake(async () => { + await pending; + return false; + }); + let first = save(candidate({ DOI: '10.1234/file', url: 'https://example.invalid/item' })); + try { + await waitForCallback(() => acquisition.called, 10, 5); + let [item, resolvers] = acquisition.firstCall.args; + assert.isTrue(Zotero.Items.exists(item.id)); + assert.notInclude(resolvers.map(resolver => resolver.accessMethod), 'doi'); + assert.notInclude(resolvers.map(resolver => resolver.accessMethod), 'url'); + Zotero.Prefs.set('downloadAssociatedFiles', false); + let second = await save(candidate({ title: 'Unrelated metadata save' })); + assert.isFalse(second.existing); + assert.isFalse((await first).existing); + } + finally { + finish(); + await (await first).fileAcquisitionPromise; + } + }); + it("completes Add before background PDF acquisition and refreshes a reopened result", async function () { + Zotero.Prefs.set('downloadAssociatedFiles', true); + let finish; + let pending = new Promise(resolve => finish = resolve); + let acquisition = sandbox.stub(Zotero.Attachments, 'addFileFromURLs').callsFake(async (item) => { + await pending; + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ file, parentItemID: item.id }); + return attachment; + }); + let json = candidate({ DOI: '10.1234/background-reopen' }); + let { session, referenceKey, resultID } = await sessionFor(json); + try { + let response = await Promise.race([ + session.performAction({ requestID: 'background-save', referenceKey, resultID, action: 'add-to-library' }), + Zotero.Promise.delay(2000).then(() => { + throw new Error('Add awaited the optional PDF'); + }), + ]); + assert.equal(response.status, 'saved'); + assert.notInclude(response.result.actions, 'open-attachment'); + let item = Zotero.Items.get(session._references.get(referenceKey).result.itemID); + items.push(item); + let repeated = await save(json); + assert.isTrue(repeated.existing); + assert.equal(repeated.item.id, item.id); + assert.isTrue(acquisition.calledOnce); + let oldRefresh = session.waitForAttachmentUpdate(referenceKey, resultID); + session.dispose(); + let reopened = await sessionFor(item, true); + assert.notEqual(reopened.resultID, resultID); + assert.notInclude(reopened.result.actions, 'open-attachment'); + let freshRefresh = reopened.session.waitForAttachmentUpdate(reopened.referenceKey, reopened.resultID); + finish(); + let update = await freshRefresh; + assert.isNull(await oldRefresh, 'Disposed token must not receive attachment authority'); + assert.equal(update.referenceKey, reopened.referenceKey); + assert.equal(update.result.resultID, reopened.resultID); + assert.include(update.result.actions, 'open-attachment'); + assert.include(reopened.session._references.get(reopened.referenceKey).result.actions, 'open-attachment'); + await repeated.fileAcquisitionPromise; + assert.equal(referenceSaver._metadataSaveTails.size, 0); + assert.equal(referenceSaver._attachmentFlights.size, 0); + } + finally { + finish(); + await Promise.all([...referenceSaver._attachmentFlights.values()]); + } + }); + it("rechecks a completed save if its item is trashed during PDF acquisition", async function () { + let finish; + let pending = new Promise(resolve => finish = resolve); + sandbox.stub(referenceSaver, '_acquireAssociatedFile').returns(pending); + let json = candidate({ DOI: '10.1234/trashed-during-download' }); + try { + let first = await save(json); + first.item.deleted = true; + await first.item.saveTx(); + let second = await save(json); + assert.isFalse(second.existing); + assert.isFalse(second.item.deleted); + assert.notEqual(second.item.id, first.item.id); + } + finally { + finish(); + await Promise.all([...referenceSaver._attachmentFlights.values()]); + } + }); + it("handles background acquisition failure without failing committed Add or retaining flights", async function () { + Zotero.Prefs.set('downloadAssociatedFiles', true); + let fail; + let pending = new Promise((resolve, reject) => fail = reject); + sandbox.stub(Zotero.Attachments, 'addFileFromURLs').returns(pending); + let log = sandbox.stub(Zotero, 'logError'); + let result = await save(candidate({ DOI: '10.1234/background-shutdown' })); + assert.isTrue(Zotero.Items.exists(result.item.id)); + fail(new Error('Simulated shutdown during PDF acquisition')); + await result.fileAcquisitionPromise; + assert.isTrue(Zotero.Items.exists(result.item.id)); + assert.isFalse(result.existing); + await Zotero.Promise.delay(0); + assert.isTrue(log.calledOnce); + assert.equal(referenceSaver._metadataSaveTails.size, 0); + assert.equal(referenceSaver._attachmentFlights.size, 0); + }); + it("opens an existing attachment and blocks attachment opening after disposal", async function () { + let view = sandbox.stub().resolves(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { viewAttachment: view } }); + let item = await store(candidate({ DOI: '10.1234/actions' })); + let file = getTestDataDirectory(); + file.append('test.pdf'); + let attachment = await Zotero.Attachments.importFromFile({ file, parentItemID: item.id }); + items.push(attachment); + let { session, referenceKey, resultID, result } = await sessionFor(item, true); + assert.include(result.actions, 'open-attachment'); + let opened = await session.performAction({ requestID: 'open', referenceKey, resultID, action: 'open-attachment' }); + assert.equal(opened.status, 'ok'); + assert.isTrue(view.calledOnceWithExactly(attachment.id)); + let release; + sandbox.stub(item, 'getBestAttachment').returns(new Promise(resolve => release = resolve)); + let pending = session.performAction({ requestID: 'stale', referenceKey, resultID, action: 'open-attachment' }); + await Zotero.Promise.delay(0); + session.dispose(); + release(attachment); + assert.equal((await pending).status, 'stale'); + assert.isTrue(view.calledOnce); + }); + it("reports eviction and refreshes locally from the original raw reference", async function () { + let item = await store(candidate({ DOI: '10.1234/expiry' })); + let { session, referenceKey } = await sessionFor(item, true); + session._removeReference(referenceKey); + let response = await session.findOnline({ requestID: 'expired', referenceKey }); + assert.equal(response.status, 'expired'); + let refreshed = await session.matchReference({ requestID: 'refresh', sourceKey: 'reference', + referenceParts: [{ text: 'doi:10.1234/expiry' }] }); + assert.equal(refreshed.status, 'strong'); + assert.notEqual(refreshed.referenceKey, referenceKey); + }); + it("retains local action bindings after empty and failed real translator searches", async function () { + let source = await Zotero.File.getContentsFromURLAsync('resource://app/translators/Crossref%20REST.js'); + let metadata = JSON.parse(/^\s*{[\S\s]*?}\s*?[\r\n]/.exec(source)[0]); + metadata.cacheCode = true; + let translator = Zotero.Translators.load(metadata, null, source); + sandbox.stub(Zotero.Translators, 'get').callThrough().withArgs(metadata.translatorID) +.returns(translator); + let item = await store(candidate()); + let { session, referenceKey, resultID } = await sessionFor(item, true); + let select = sandbox.stub().resolves(); + sandbox.stub(Zotero, 'getMainWindow').returns({ ZoteroPane: { selectItem: select } }); + let unexpected = sandbox.stub(Zotero.HTTP, 'request').rejects(new Error('Unexpected network request')); + let emptyBody = JSON.stringify({ status: 'ok', message: { items: [] } }); + let statuses = [200, 503]; + let request = sandbox.stub(Zotero.HTTP, 'doGet').callsFake((url, callback) => { + assert.match(url, /^https:\/\/api\.crossref\.org\/works\//); + let response = { status: statuses.shift(), responseText: emptyBody, responseURL: url }; + Zotero.Promise.resolve().then(() => callback(response)); + return response; + }); + let empty = await session.findOnline({ requestID: 'empty', referenceKey }); + // Standard Search translators use the same failure for no items and + // transport errors. Neither certifies an empty candidate pool. + assert.equal(empty.status, 'error'); + // These outcomes are not cached and leave the local action binding intact. + let failure = await session.findOnline({ requestID: 'failed', referenceKey }); + assert.equal(failure.status, 'error'); + assert.isTrue(request.calledTwice); + assert.isEmpty(statuses); + assert.isFalse(unexpected.called); + assert.equal(session._onlineMatchCache.size, 0); + assert.equal(session._references.get(referenceKey).result.resultID, resultID); + let action = await session.performAction({ requestID: 'select', referenceKey, resultID, action: 'show-in-library' }); + assert.equal(action.status, 'ok'); + assert.isTrue(select.calledOnceWithExactly(item.id)); + }); + + it('rechecks current structured identity across changed local metadata and two sessions', async function () { + let json = { itemType: 'journalArticle', title: 'Current metadata recovery review', + DOI: '10.1234/lifecycle-review', date: '2021', + creators: [{ creatorType: 'author', firstName: 'Alice', lastName: 'Smith' }] }; + let existing = await store(json); + let attachment = new Zotero.Item('attachment'); + attachment.libraryID = libraryID; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_URL; + attachment.setField('url', 'https://example.invalid/source.pdf'); + await attachment.saveTx(); + items.push(attachment); + let first = new ReaderReferenceSession(attachment); + let second = new ReaderReferenceSession(attachment); + sessions.push(first, second); + let local = await first.matchReference({ requestID: 'local', sourceKey: 'primary:reference', + referenceParts: [{ text: `doi:${json.DOI}` }] }); + assert.equal(local.status, 'strong'); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline').callsFake(async (reference) => { + let item = new Zotero.Item(json.itemType); + item.fromJSON(json); + return BibliographyMatcher.matchCandidates(reference, [item]); + }); + existing.setField('DOI', '10.1234/changed-record'); + existing.setField('title', 'A different current record'); + await existing.saveTx(); + let online = await first.findOnline({ requestID: 'online', referenceKey: local.referenceKey }); + assert.equal(online.status, 'strong'); + assert.equal(online.result.libraryState, 'unsaved'); + let otherLocal = await second.matchReference({ requestID: 'local', sourceKey: 'primary:reference', + referenceParts: [{ text: `doi:${json.DOI}` }] }); + let otherOnline = await second.findOnline({ requestID: 'online', referenceKey: otherLocal.referenceKey }); + let saver = sandbox.spy(Zotero.Translate, 'ItemSaver'); + let add = (session, reference, result) => session.performAction({ requestID: 'add', + referenceKey: reference.referenceKey, resultID: result.result.resultID, action: 'add-to-library' }); + let saved = await add(first, local, online); + assert.equal(saved.status, 'saved'); + let savedID = first._references.get(local.referenceKey).result.itemID; + items.push(await Zotero.Items.getAsync(savedID)); + assert.notEqual(savedID, existing.id); + let repeated = await add(second, otherLocal, otherOnline); + assert.equal(repeated.status, 'existing'); + assert.equal(second._references.get(otherLocal.referenceKey).result.itemID, savedID); + assert.isTrue(saver.calledOnce); + assert.equal(provider.callCount, 2); + let crossSession = await second.performAction({ requestID: 'cross', referenceKey: local.referenceKey, + resultID: online.result.resultID, action: 'show-in-library' }); + assert.equal(crossSession.status, 'stale'); + let release; + // Explicit cache-miss fixture: session metadata no longer expires + // with the independently expiring action/reference tokens. + first._onlineMatchCache.clear(); + provider.callsFake(() => new Promise(resolve => release = resolve)); + let pending = first.findOnline({ requestID: 'pending-refresh', referenceKey: local.referenceKey }); + await waitForCallback(() => release, 1, 5); + assert.isFunction(release); + let refreshed = await first.matchReference({ requestID: 'refresh', sourceKey: 'primary:reference', + referenceParts: [{ text: 'doi:10.1234/replacement-reference' }] }); + let transient = new Zotero.Item(json.itemType); + transient.fromJSON(json); + release(BibliographyMatcher.matchCandidates(`doi:${json.DOI}`, [transient])); + assert.equal((await pending).status, 'cancelled'); + assert.equal(refreshed.status, 'no-match'); + assert.isFalse([...first._references.values()].some(reference => reference.result)); + assert.isTrue(second._isBoundResult(second._references.get(otherLocal.referenceKey).result)); + }); +}); + + +describe("Reader reference saving integration", function () { + // PLOS reference parts: journals.plos.org/plosone/article?id=10.1371/journal.pone.0236550 (CC BY 4.0). + // Metadata: https://www.nature.com/articles/nature11148 + let parts = [ + { text: "7. Cardinale BJ, Duffy JE, Gonzalez A, Hooper DU, Perrings C, Venail P, et al. Biodiversity loss and its impact on humanity. Nature. 2012; 486: 59–67. " }, + { text: "https://doi.org/10.1038/nature11148", attributes: { url: "https://doi.org/10.1038/nature11148" } }, + { text: " PMID: " }, + { text: "22678280", attributes: { url: "http://www.ncbi.nlm.nih.gov/pubmed/22678280" } }, + ]; + let target = { + itemType: "journalArticle", + title: "Biodiversity Loss and Its Impact on Humanity", + creators: [ + { creatorType: "author", firstName: "Bradley J.", lastName: "Cardinale" }, + { creatorType: "author", firstName: "J. Emmett", lastName: "Duffy" }, + { creatorType: "author", firstName: "Andrew", lastName: "Gonzalez" }, + { creatorType: "author", firstName: "David U.", lastName: "Hooper" }, + { creatorType: "author", firstName: "Charles", lastName: "Perrings" }, + { creatorType: "author", firstName: "Patrick", lastName: "Venail" }, + ], + date: "2012", + publicationTitle: "Nature", + volume: "486", + pages: "59-67", + DOI: "10.1038/nature11148", + extra: "PMID: 22678280", + }; + + async function createEmptyLibraryCase(title) { + let group = await createGroup({ editable: true, filesEditable: true }); + let attachment = new Zotero.Item('attachment'); + attachment.libraryID = group.libraryID; + attachment.attachmentLinkMode = Zotero.Attachments.LINK_MODE_LINKED_URL; + attachment.attachmentContentType = 'application/pdf'; + attachment.setField('title', title); + attachment.setField('url', "https://journals.plos.org/plosone/article?id=10.1371/journal.pone.0236550"); + await attachment.saveTx(); + return { attachment, group }; + } + + it("saves verified online metadata, rematches locally, and deduplicates concurrent Adds", async function () { + this.timeout(120000); + let targetPMID = [...BibliographyMatcher.extractIdentifiers(target.extra).PMID][0]; + assert.equal(targetPMID, '22678280'); + let canonicalTarget = new Zotero.Item(target.itemType); + canonicalTarget.fromJSON(target); + let expectedMetadata = BibliographyMatcher.createCandidateSnapshot( + canonicalTarget.toJSON() + ); + let sandbox = sinon.createSandbox(); + let groups = []; + let sessions = []; + let downloadPref = 'downloadAssociatedFiles'; + let hadDownloadPref = Zotero.Prefs.prefHasUserValue(downloadPref); + let originalDownloadPref = Zotero.Prefs.get(downloadPref); + let network = ['request', 'doGet', 'doPost'].map(method => sandbox.spy(Zotero.HTTP, method)); + let translate = sandbox.spy(Zotero.Translate.Search.prototype, 'translate'); + let availableFileLookup = sandbox.stub(Zotero.Attachments, 'addFileFromURLs').resolves(false); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline').callsFake( + (reference, { signal } = {}) => { + assert.equal(reference, parts.map(part => part.text).join('')); + assert.isFalse(signal?.aborted); + let data = JSON.parse(JSON.stringify(target)); + let item = new Zotero.Item(data.itemType); + item.fromJSON(data); + return BibliographyMatcher.matchCandidates(reference, [item]); + } + ); + + let regularItemsForTarget = async libraryID => ( + (await Zotero.Items.getAll(libraryID)) + .filter(item => item.isRegularItem() && item.getField('DOI') === target.DOI) + ); + let assertSavedTarget = async (libraryID) => { + let items = await regularItemsForTarget(libraryID); + assert.lengthOf(items, 1); + let item = items[0]; + for (let field of [ + 'title', 'date', 'publicationTitle', 'volume', 'pages', 'DOI', + ]) { + assert.equal(item.getField(field), target[field], `Saved ${field} differs from provider metadata`); + } + assert.equal(item.getField('PMID'), targetPMID); + let normalizeCreator = creator => ( + creator.name || [creator.firstName, creator.lastName].filter(Boolean).join(' ') + ).normalize('NFKC').replace(/\s+/g, ' ').trim(); + assert.deepEqual( + item.getCreators().map(normalizeCreator), + target.creators.map(normalizeCreator) + ); + assert.deepEqual( + BibliographyMatcher.createCandidateSnapshot(item.toJSON()), + expectedMetadata + ); + assert.deepEqual(item.getAttachments(), []); + assert.deepEqual(item.getNotes(), []); + return item; + }; + let findOnline = async (session, local, suffix) => { + let response = await session.findOnline({ + requestID: `saving-online-${suffix}`, + referenceKey: local.referenceKey, + }); + assert.equal(response.status, 'strong'); + assert.isObject(response.result); + assert.equal(response.result.libraryState, 'unsaved'); + assert.include(response.result.actions, 'add-to-library'); + assert.notProperty(response.result.display, 'identifiers'); + assert.deepEqual( + session._references.get(response.referenceKey).result.candidateJSON, + expectedMetadata + ); + return response; + }; + + try { + Zotero.Prefs.set(downloadPref, true); + let primary = await createEmptyLibraryCase('Reference saving source'); + groups.push(primary.group); + let session = new ReaderReferenceSession(primary.attachment); + sessions.push(session); + assert.isFunction(session.readerOptions.onFindReferenceOnline); + let local = await session.matchReference({ + requestID: 'saving-local-primary', + sourceKey: 'saving-reference-primary', + referenceParts: parts, + }); + assert.equal(local.status, 'no-match'); + assert.equal(provider.callCount, 0); + assert.isTrue(network.every(spy => spy.callCount === 0)); + assert.equal(translate?.callCount || 0, 0); + + let online = await findOnline(session, local, 'primary'); + assert.equal(provider.callCount, 1); + let added = await session.performAction({ + requestID: 'saving-add-primary', + referenceKey: local.referenceKey, + resultID: online.result.resultID, + action: 'add-to-library', + }); + assert.equal(added.status, 'saved'); + assert.equal(added.result.libraryState, 'existing'); + await assertSavedTarget(primary.group.libraryID); + + let repeatSession = new ReaderReferenceSession(primary.attachment); + sessions.push(repeatSession); + let repeat = await repeatSession.matchReference({ + requestID: 'saving-local-repeat', + sourceKey: 'saving-reference-repeat', + referenceParts: parts, + }); + assert.equal(repeat.status, 'strong'); + assert.isObject(repeat.result); + assert.equal(repeat.result.libraryState, 'existing'); + assert.notInclude(repeat.result.actions, 'add-to-library'); + assert.equal(provider.callCount, 1); + + let concurrent = await createEmptyLibraryCase('Concurrent reference saving source'); + groups.push(concurrent.group); + let concurrentSessions = Array.from({ length: 2 }, () => { + let concurrentSession = new ReaderReferenceSession(concurrent.attachment); + sessions.push(concurrentSession); + return concurrentSession; + }); + let concurrentLocals = await Promise.all(concurrentSessions.map((current, index) => ( + current.matchReference({ + requestID: `saving-local-concurrent-${index}`, + sourceKey: `saving-reference-concurrent-${index}`, + referenceParts: parts, + }) + ))); + assert.deepEqual(concurrentLocals.map(result => result.status), ['no-match', 'no-match']); + let concurrentOnline = await Promise.all(concurrentSessions.map((current, index) => ( + findOnline(current, concurrentLocals[index], `concurrent-${index}`) + ))); + let actions = await Promise.all(concurrentSessions.map((current, index) => ( + current.performAction({ + requestID: `saving-add-concurrent-${index}`, + referenceKey: concurrentLocals[index].referenceKey, + resultID: concurrentOnline[index].result.resultID, + action: 'add-to-library', + }) + ))); + assert.deepEqual(actions.map(result => result.status).sort(), ['existing', 'saved']); + await assertSavedTarget(concurrent.group.libraryID); + assert.equal(referenceSaver._metadataSaveTails.size, 0); + assert.equal(provider.callCount, 3); + assert.equal(availableFileLookup.callCount, 2); + assert.isTrue(network.every(spy => spy.callCount === 0)); + assert.isFalse(translate.called); + } + finally { + for (let session of sessions) session.dispose(); + + if (hadDownloadPref) Zotero.Prefs.set(downloadPref, originalDownloadPref); + else Zotero.Prefs.clear(downloadPref); + sandbox.restore(); + for (let group of groups.reverse()) await group.eraseTx(); + } + }); +}); + + +describe("Reader reference popup integration", function () { + let win; + + before(async function () { + win = await loadZoteroPane(); + }); + + after(function () { + win.Zotero_Tabs.closeAll(); + win.close(); + }); + + it("finds explicitly, restores online metadata, and adds through the packaged Reader", async function () { + this.timeout(15000); + let sandbox = sinon.createSandbox(); + let reader, attachment, saved; + let previousDownload = Zotero.Prefs.get('downloadAssociatedFiles'); + let candidate = { + itemType: 'journalArticle', DOI: '10.1234/popup-integration', + title: 'Node.js reference integration', date: '2021', + creators: [{ creatorType: 'author', firstName: 'Ada', lastName: 'Example' }], + abstractNote: 'An abstract retained across popup reopening.', + }; + try { + Zotero.Prefs.set('downloadAssociatedFiles', false); + attachment = await importFileAttachment('test.pdf'); + reader = await Zotero.Reader.open(attachment.id); + await reader._initPromise; + let view = reader._internalReader._primaryView; + await view.initializedPromise; + let provider = sandbox.stub(onlineReferenceLookup, '_translateIdentifier').resolves([candidate]); + let network = sandbox.stub(Zotero.HTTP, 'request').rejects(new Error('Unexpected external request')); + // The popup loads packaged item icons through the HTTP helper. + network.withArgs('GET', sinon.match(/^resource:\/\/zotero\//)).callThrough(); + let save = sandbox.spy(referenceSaver, 'saveCandidate'); + let doc = reader._iframeWindow.document; + let button = label => [...doc.querySelectorAll('.reference-actions button')] + .find(node => node.textContent.trim() === label); + let title = () => doc.querySelector('.reference-popup .item-preview .title'); + // Supply the semantic overlay directly: this covers the packaged UI and + // client bridge, not PDF reference extraction. + let popup = Components.utils.cloneInto({ + type: 'citation', rect: [100, 100, 130, 120], persistent: true, + references: [{ sourceKey: 'integration', text: 'doi:' + candidate.DOI, chars: [ + { c: 'https://doi.org/10.1234/popup-', lineBreakAfter: true, url: 'https://doi.org/' + candidate.DOI }, + { c: 'integration', url: 'https://doi.org/' + candidate.DOI }, + ] }], + }, reader._iframeWindow); + let open = () => view._onSetOverlayPopup(popup); + let close = async () => { + view._onSetOverlayPopup(null); + await waitForCallback(() => !doc.querySelector('.reference-popup'), 50, 5); + }; + open(); + (await waitForCallback(() => button('Find Online') && !button('Find Online').disabled && button('Find Online'), 50, 5)).click(); + await waitForCallback(title, 50, 5); + assert.equal(provider.firstCall.args[0].DOI, candidate.DOI, 'The wrapped visible URL reaches the normal DOI route'); + assert.equal(title().textContent, candidate.title); + assert.include(doc.querySelector('.reference-popup .abstract').textContent, candidate.abstractNote); + await close(); + open(); + await waitForCallback(title, 50, 5); + assert.equal(title().textContent, candidate.title); + assert.isTrue(provider.calledOnce, 'Reopening must reuse the online result'); + assert.isUndefined(button('Find Online')); + button('Add to Library').click(); + await waitForCallback(() => button('Show in Library'), 50, 5); + saved = (await save.firstCall.returnValue).item; + assert.equal(saved.getField('abstractNote'), candidate.abstractNote); + await close(); + open(); + await waitForCallback(() => title() && button('Show in Library'), 50, 5); + assert.equal(title().textContent, saved.getField('title')); + assert.isUndefined(button('Add to Library')); + let navigate = sandbox.spy(reader._referenceSession, 'performAction'); + let loadURI = sandbox.stub(win.ZoteroPane, 'loadURI'); + button('View Online').click(); + await waitForCallback(() => loadURI.calledOnce && button('View Online') && !button('View Online').disabled, 50, 5); + let response = await navigate.firstCall.returnValue; + assert.equal(response.status, 'ok'); + assert.notProperty(response, 'result'); + assert.equal(title().textContent, saved.getField('title')); + assert.isDefined(button('Show in Library')); + assert.isTrue(provider.calledOnce); + assert.isTrue(network.getCalls().every(call => ( + call.args[0] === 'GET' && call.args[1].startsWith('resource://zotero/') + ))); + let internal = reader._internalReader; + assert.isTrue(internal._state.previewedReferences.has('integration')); + internal.reload(internal._data); + assert.equal(internal._state.previewedReferences.size, 0, 'Reload cannot reuse visited keys from the old document'); + await internal._primaryView.initializedPromise; + } + finally { + sandbox.restore(); + reader?.close(); + if (saved?.id) await saved.eraseTx(); + if (attachment?.id) await attachment.eraseTx(); + Zotero.Prefs.set('downloadAssociatedFiles', previousDownload); + } + }); + + it('should deliver saved metadata before attachment refresh and suppress refresh after teardown', async function () { + let sandbox = sinon.createSandbox(); + let reader, attachment; + let hostRequests, unsubscribe; + let pending = Zotero.Promise.defer(); + try { + attachment = await importFileAttachment('test.pdf'); + reader = await Zotero.Reader.open(attachment.id); + await reader._initPromise; + let session = reader._referenceSession; + let bridge = reader._internalReader._referencePopupBridge; + let response = { status: 'saved', referenceKey: 'reference', + result: { resultID: 'saved-result', libraryState: 'existing', actions: ['show-in-library'], + display: { itemType: 'journalArticle', title: 'Saved metadata', firstCreator: '', year: '', abstract: null } } }; + let update = { referenceKey: 'reference', result: { ...response.result, + actions: ['open-attachment', 'show-in-library'] } }; + sandbox.stub(session, 'performAction').callsFake(async request => ({ ...response, requestID: request.requestID })); + hostRequests = sandbox.spy(session, '_handleRequest'); + let events = []; + let updates = []; + let refresh = sandbox.stub(session, 'waitForAttachmentUpdate').resolves(update); + unsubscribe = bridge.subscribeResultUpdates((value) => { + updates.push(value); + events.push('updated'); + }); + let request = { referenceKey: 'reference', resultID: 'saved-result', action: 'add-to-library' }; + let saved; + bridge.startAction(Components.utils.cloneInto(request, reader._iframeWindow), (value) => { + saved = value; + events.push('saved'); + }); + await hostRequests.lastCall.returnValue; + assert.equal(saved.status, 'saved'); + assert.isTrue(refresh.calledOnce, 'Host must ask for attachment update'); + await waitForCallback(() => updates.length === 1, 10, 5); + assert.equal(saved.status, 'saved'); + assert.deepEqual(saved.result, response.result); + assert.deepEqual(updates, [update]); + assert.isTrue(refresh.calledOnceWithExactly(request.referenceKey, request.resultID)); + assert.deepEqual(events, ['saved', 'updated'], 'Already-complete acquisition still follows Saved'); + refresh.callsFake(() => pending.promise); + let delivered; + bridge.startAction(Components.utils.cloneInto(request, reader._iframeWindow), value => delivered = value); + let running = hostRequests.lastCall.returnValue; + await waitForCallback(() => delivered, 10, 5); + assert.equal(delivered.status, 'saved', 'Slow acquisition cannot delay Saved'); + reader.close(); + pending.resolve(update); + await running; + assert.lengthOf(updates, 1, 'Closed Reader must not receive the late refresh'); + } + finally { + unsubscribe?.(); + pending.resolve(); + await Promise.allSettled(hostRequests?.getCalls().map(call => call.returnValue) || []); + sandbox.restore(); + reader?.close(); + if (attachment?.id && Zotero.Items.exists(attachment.id)) await attachment.eraseTx(); + } + }); + + it('should expose the smart-reference capability and preserve DTO contracts across the bridge', async function () { + let sandbox = sinon.createSandbox(); + let reader; + let item; + let attachment; + try { + item = await createDataObject('item', { + itemType: 'journalArticle', + title: 'Reliable matching across diverse bibliographies', + }); + item.setField('DOI', '10.1234/general.42'); + item.setField('url', 'https://example.invalid/article'); + await item.saveTx(); + attachment = await importFileAttachment('test.pdf'); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves({ + status: 'matched', item, + }); + + reader = await Zotero.Reader.open(attachment.itemID); + await reader._initPromise; + let bridge = reader._internalReader._referencePopupBridge; + assert.isTrue(bridge.canFindOnline); + sandbox.stub(Zotero.HTTP, 'browserIsOffline').returns(true); + + let runBridgeRequest = async (method, request) => { + let operation; + let response = await Promise.race([ + new Promise(resolve => operation = bridge[method](Components.utils.cloneInto(request, reader._iframeWindow), resolve)), + Zotero.Promise.delay(5000).then(() => { + throw new Error(`Timed out waiting for ${method}`); + }), + ]); + assert.equal(response.requestID, operation.requestID); + return response; + }; + let lookup = await runBridgeRequest('startLocalLookup', { + sourceKey: 'reference-1', + referenceParts: [{ text: 'doi:10.1234/general.42' }], + }); + lookup = JSON.parse(JSON.stringify(lookup)); + assert.equal(lookup.status, 'strong'); + assert.notProperty(lookup.result.display, 'url'); + assert.notProperty(lookup.result, 'itemID'); + assert.notProperty(lookup.result, 'libraryID'); + let provider = sandbox.stub(onlineReferenceLookup, 'findOnline'); + let offlineResult = await runBridgeRequest('startOnlineLookup', { + referenceKey: lookup.referenceKey, + }); + assert.equal(offlineResult.status, 'offline'); + assert.isFalse(provider.called); + assert.isTrue(reader._internalReader._referencePopupBridge.canFindOnline); + + let action = await runBridgeRequest('startAction', { + referenceKey: lookup.referenceKey, + resultID: 'expired-or-foreign-token', + action: 'show-in-library', + }); + action = JSON.parse(JSON.stringify(action)); + assert.equal(action.referenceKey, lookup.referenceKey); + assert.equal(action.status, 'stale'); + } + finally { + sandbox.restore(); + reader?.close(); + if (attachment?.id && Zotero.Items.exists(attachment.id)) await attachment.eraseTx(); + if (item?.id && Zotero.Items.exists(item.id)) await item.eraseTx(); + } + }); + + it('should cancel an online lookup through the Reader bridge without delivering or caching its result', async function () { + let sandbox = sinon.createSandbox(); + let reader; + let attachment; + let hostRequests; + let releaseProvider = Zotero.Promise.defer(); + try { + attachment = await importFileAttachment('test.pdf'); + sandbox.stub(BibliographyMatcher, 'matchLibrary').resolves({ + status: 'unmatched', + reason: 'no-candidates', + }); + let candidate = new Zotero.Item('journalArticle'); + candidate.fromJSON({ + itemType: 'journalArticle', + title: 'A late online result', + DOI: '10.1234/late-result', + }); + let providerStarted = Zotero.Promise.defer(); + let providerSignal; + sandbox.stub(onlineReferenceLookup, 'findOnline').callsFake( + (reference, { signal }) => { + providerSignal = signal; + providerStarted.resolve(); + return releaseProvider.promise.then(() => BibliographyMatcher.matchCandidates(reference, [candidate])); + } + ); + + reader = await Zotero.Reader.open(attachment.itemID); + await reader._initPromise; + let bridge = reader._internalReader._referencePopupBridge; + hostRequests = sandbox.spy(reader._referenceSession, '_handleRequest'); + let local = await Promise.race([ + new Promise(resolve => bridge.startLocalLookup(Components.utils.cloneInto({ + sourceKey: 'cancel-reference', + referenceParts: [{ text: 'doi:10.1234/late-result' }], + }, reader._iframeWindow), resolve)), + Zotero.Promise.delay(3000).then(() => { + throw new Error('Timed out waiting for the local lookup'); + }), + ]); + assert.equal(local.status, 'no-match'); + let delivered = []; + let operation = bridge.startOnlineLookup(Components.utils.cloneInto({ + referenceKey: local.referenceKey, + }, reader._iframeWindow), response => delivered.push(JSON.parse(JSON.stringify(response)))); + let onlineFinished = hostRequests.lastCall.returnValue; + await Promise.race([ + providerStarted.promise, + Zotero.Promise.delay(3000).then(() => { + throw new Error('Timed out waiting for the online provider'); + }), + ]); + operation.cancel(); + assert.isTrue(providerSignal.aborted); + releaseProvider.resolve(); + await onlineFinished; + await Zotero.Promise.delay(0); + + assert.isEmpty(delivered, 'The bridge must suppress cancelled delivery to the popup'); + assert.notProperty(reader._referenceSession._references.get(local.referenceKey), 'result'); + assert.equal(reader._referenceSession._onlineMatchCache.size, 0); + } + finally { + reader?.close(); + releaseProvider.resolve(); + await Promise.allSettled(hostRequests?.getCalls().map(call => call.returnValue) || []); + sandbox.restore(); + if (attachment?.id && Zotero.Items.exists(attachment.id)) await attachment.eraseTx(); + } + }); +}); diff --git a/test/tests/readerTest.js b/test/tests/readerTest.js index 4979ca0b9f..6cbf9cd3a4 100644 --- a/test/tests/readerTest.js +++ b/test/tests/readerTest.js @@ -200,6 +200,46 @@ describe("Reader", function () { reader.close(); }); + it('should persist the Citation and Link Previews switch', async function () { + let originalValue = Zotero.Prefs.get('reader.citationAndLinkPreviews'); + let reader; + try { + Zotero.Prefs.set('reader.citationAndLinkPreviews', false); + let attachment = await importFileAttachment('test.pdf'); + reader = await Zotero.Reader.open(attachment.itemID); + await reader._initPromise; + + reader._internalReader.toggleAppearancePopup(true); + let input = await waitForCallback( + () => reader._iframeWindow.document.getElementById('citation-and-link-previews-enabled'), + 50, + 5 + ); + assert.isFalse(input.checked); + + input.click(); + await waitForCallback( + () => Zotero.Prefs.get('reader.citationAndLinkPreviews') === true, + 50, + 5 + ); + assert.isTrue(reader._internalReader._state.citationAndLinkPreviewsEnabled); + + Zotero.Prefs.set('reader.citationAndLinkPreviews', false); + await waitForCallback( + () => reader._internalReader._state.citationAndLinkPreviewsEnabled === false && !input.checked, + 50, + 5 + ); + assert.isFalse(reader._internalReader._state.citationAndLinkPreviewsEnabled); + assert.isFalse(input.checked); + } + finally { + Zotero.Prefs.set('reader.citationAndLinkPreviews', originalValue); + await cleanupReaders(reader); + } + }); + async function cleanupReaders(...readers) { for (let reader of readers.filter(Boolean)) { reader.close();