Retraction Watch integration

- Check for retracted items using data from Retraction Watch
- Show an X next to retracted items in the items list, and show a
  scary message at the top of the item pane with more info and links.
- Lookup is done in a privacy-preserving manner using k-anonymity --
  the server is unable to determine the specific items that exist in
  the client, so people who don't sync don't need to share any library
  data (though the server doesn't log the lookups anyway).

TODO:

- Pop up an alert when new items are found
- Show a confirmation prompt when citing a retracted item
- Support items without DOIs or PMIDs
- Add a proper PMID field and expand DOI to more item types so these
  values don't need to be parsed out of Extra
- Clear the banner immediately when all possible fields are cleared
  instead of waiting a few seconds
This commit is contained in:
Dan Stillman 2019-06-06 08:47:43 -04:00
parent 897a042ee0
commit 48580c49d1
15 changed files with 969 additions and 107 deletions

View file

@ -28,8 +28,8 @@
<!-- <!DOCTYPE bindings SYSTEM "chrome://zotero/locale/itembox.dtd"> --> <!-- <!DOCTYPE bindings SYSTEM "chrome://zotero/locale/itembox.dtd"> -->
<bindings xmlns="http://www.mozilla.org/xbl" <bindings xmlns="http://www.mozilla.org/xbl"
xmlns:xbl="http://www.mozilla.org/xbl" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> xmlns:html="http://www.w3.org/1999/xhtml">
<binding id="item-box"> <binding id="item-box">
<resources> <resources>
@ -291,6 +291,8 @@
return; return;
} }
this.updateRetracted();
if (this.clickByItem) { if (this.clickByItem) {
var itemBox = document.getAnonymousNodes(this)[0]; var itemBox = document.getAnonymousNodes(this)[0];
itemBox.setAttribute('onclick', itemBox.setAttribute('onclick',
@ -2374,6 +2376,125 @@
</method> </method>
<method name="updateRetracted">
<body><![CDATA[
this._id('retraction-box').hidden = true;
return (async function () {
var htmlNS = 'http://www.w3.org/1999/xhtml';
var data = await Zotero.Retractions.getData(this.item);
if (!data) {
this._id('retraction-box').hidden = true;
return;
}
this._id('retraction-box').hidden = false;
this._id('retraction-banner').textContent = Zotero.getString('retraction.banner');
// Date
if (data.date) {
this._id('retraction-date').hidden = false;
this._id('retraction-date').textContent = Zotero.getString(
'retraction.date',
data.date.toLocaleDateString()
);
}
else {
this._id('retraction-date').hidden = true;
}
// Reasons
if (data.reasons.length) {
let elem = this._id('retraction-reasons');
elem.hidden = false;
elem.textContent = '';
for (let reason of data.reasons) {
let dt = document.createElementNS(htmlNS, 'dt');
let dd = document.createElementNS(htmlNS, 'dd');
dt.textContent = reason;
dd.textContent = Zotero.Retractions.getReasonDescription(reason);
elem.appendChild(dt);
elem.appendChild(dd);
}
}
else {
this._id('retraction-reasons').hidden = true;
}
// Retraction DOI or PubMed ID
if (data.doi || data.pmid) {
let div = this._id('retraction-notice');
div.textContent = '';
let a = document.createElementNS(htmlNS, 'a');
a.textContent = Zotero.getString('retraction.notice');
if (data.doi) {
a.href = 'https://doi.org/' + data.doi;
}
else {
a.href = `https://www.ncbi.nlm.nih.gov/pubmed/${data.pmid}/`;
}
div.appendChild(a);
}
else {
this._id('retraction-notice').hidden = true;
}
// Links
if (data.urls.length) {
let div = this._id('retraction-links');
div.hidden = false;
div.textContent = '';
let p = document.createElementNS(htmlNS, 'p');
p.textContent = Zotero.getString('retraction.details');
let ul = document.createElementNS(htmlNS, 'ul');
for (let url of data.urls) {
let li = document.createElementNS(htmlNS, 'li');
let a = document.createElementNS(htmlNS, 'a');
url = url.replace(/^http:/, 'https:');
a.href = url;
a.textContent = url;
li.appendChild(a);
ul.appendChild(li);
}
div.appendChild(p);
div.appendChild(ul);
}
else {
this._id('retraction-links').hidden = true;
}
let creditElem = this._id('retraction-credit');
if (!creditElem.childNodes.length) {
let text = Zotero.getString(
'retraction.credit',
'<a href="https://retractionwatch.com">Retraction Watch</a>'
);
let parts = Zotero.Utilities.parseMarkup(text);
for (let part of parts) {
if (part.type == 'text') {
creditElem.appendChild(document.createTextNode(part.text));
}
else if (part.type == 'link') {
let a = document.createElementNS(htmlNS, 'a');
a.href = part.attributes.href;
a.textContent = part.text;
creditElem.appendChild(a);
}
}
}
Zotero.Utilities.Internal.updateHTMLInXUL(this._id('retraction-box'));
}.bind(this))();
]]></body>
</method>
<method name="_id"> <method name="_id">
<parameter name="id"/> <parameter name="id"/>
<body> <body>
@ -2484,6 +2605,22 @@
</menupopup> </menupopup>
<zoteroguidancepanel id="zotero-author-guidance" about="authorMenu" position="after_end" x="-25"/> <zoteroguidancepanel id="zotero-author-guidance" about="authorMenu" position="after_end" x="-25"/>
</popupset> </popupset>
<div xmlns="http://www.w3.org/1999/xhtml"
id="retraction-box"
hidden="hidden">
<div id="retraction-banner"/>
<div id="retraction-details">
<p id="retraction-date"/>
<dl id="retraction-reasons"/>
<p id="retraction-notice"/>
<div id="retraction-links"/>
<p id="retraction-credit"/>
</div>
</div>
<grid flex="1"> <grid flex="1">
<columns> <columns>
<column/> <column/>

View file

@ -3717,28 +3717,32 @@ Zotero.Item.prototype.getImageSrcWithTags = Zotero.Promise.coroutine(function* (
var uri = this.getImageSrc(); var uri = this.getImageSrc();
var retracted = Zotero.Retractions.isRetracted(this);
var tags = this.getTags(); var tags = this.getTags();
if (!tags.length) { if (!tags.length && !retracted) {
return uri; return uri;
} }
var tagColors = Zotero.Tags.getColors(this.libraryID);
var colorData = []; var colorData = [];
for (let i=0; i<tags.length; i++) { if (tags.length) {
let tag = tags[i]; let tagColors = Zotero.Tags.getColors(this.libraryID);
let data = tagColors.get(tag.tag); for (let tag of tags) {
if (data) { let data = tagColors.get(tag.tag);
colorData.push(data); if (data) {
colorData.push(data);
}
} }
if (!colorData.length && !retracted) {
return uri;
}
colorData.sort(function (a, b) {
return a.position - b.position;
});
} }
if (!colorData.length) {
return uri;
}
colorData.sort(function (a, b) {
return a.position - b.position;
});
var colors = colorData.map(val => val.color); var colors = colorData.map(val => val.color);
return Zotero.Tags.generateItemsListImage(colors, uri);
return Zotero.Tags.generateItemsListImage(colors, uri, retracted);
}); });

View file

@ -37,7 +37,6 @@ Zotero.Tags = new function() {
var _libraryColors = {}; var _libraryColors = {};
var _libraryColorsByName = {}; var _libraryColorsByName = {};
var _itemsListImagePromises = {}; var _itemsListImagePromises = {};
var _itemsListExtraImagePromises = {};
this.init = Zotero.Promise.coroutine(function* () { this.init = Zotero.Promise.coroutine(function* () {
@ -780,23 +779,29 @@ Zotero.Tags = new function() {
* so we need to generate a composite image containing the existing item type * so we need to generate a composite image containing the existing item type
* icon and one or more tag color swatches. * icon and one or more tag color swatches.
* *
* @params {Array} colors Array of swatch colors * @params {String[]} colors - Array of swatch colors
* @params {String} extraImage Chrome URL of image to add to final image * @params {String} extraImage - chrome:// URL of image to add to final image
* @return {Q Promise} A Q promise for a data: URL for a PNG * @params {Boolean} [retracted = false] - If true, show an icon indicating the item was retracted
* @return {Promise<String>} - A promise for a data: URL for a PNG
*/ */
this.generateItemsListImage = function (colors, extraImage) { this.generateItemsListImage = async function (colors, extraImage, retracted) {
var multiplier = Zotero.hiDPI ? 2 : 1; var multiplier = Zotero.hiDPI ? 2 : 1;
var swatchWidth = 8 * multiplier; var canvasHeight = 16 * multiplier;
var separator = 3 * multiplier;
var extraImageSeparator = 1 * multiplier;
var extraImageWidth = 16 * multiplier; var extraImageWidth = 16 * multiplier;
var extraImageHeight = 16 * multiplier; var extraImageHeight = 16 * multiplier;
var canvasHeight = 16 * multiplier; var retractionImage = `chrome://zotero/skin/cross${Zotero.hiDPI ? '@1.5x' : ''}.png`;
var retractionImageLeftPadding = 1 * multiplier;
var retractionImageWidth = 16 * multiplier;
var retractionImageHeight = 16 * multiplier;
var retractionImageScaledWidth = 12 * multiplier;
var retractionImageScaledHeight = 12 * multiplier;
var tagsLeftPadding = 3 * multiplier;
var swatchSeparator = 3 * multiplier;
var swatchWidth = 8 * multiplier;
var swatchHeight = 8 * multiplier; var swatchHeight = 8 * multiplier;
var prependExtraImage = true;
var hash = colors.join("") + (extraImage ? extraImage : ""); var hash = colors.join("") + (extraImage ? extraImage : "") + (retracted ? "retracted" : "");
if (_itemsListImagePromises[hash]) { if (_itemsListImagePromises[hash]) {
return _itemsListImagePromises[hash]; return _itemsListImagePromises[hash];
@ -808,86 +813,79 @@ Zotero.Tags = new function() {
var doc = win.document; var doc = win.document;
var canvas = doc.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); var canvas = doc.createElementNS('http://www.w3.org/1999/xhtml', 'canvas');
var width = colors.length * (swatchWidth + separator); var width = extraImageWidth
if (extraImage) { + (retracted
width += (colors.length ? extraImageSeparator : 0) + extraImageWidth; ? (retractionImageLeftPadding
} + ((retractionImageWidth - retractionImageScaledWidth) / 2)
else if (colors.length) { + retractionImageScaledWidth)
width -= separator; : 0)
} + (colors.length ? tagsLeftPadding : 0)
+ (colors.length * (swatchWidth + swatchSeparator));
canvas.width = width; canvas.width = width;
canvas.height = canvasHeight; canvas.height = canvasHeight;
var swatchTop = Math.floor((canvasHeight - swatchHeight) / 2); var swatchTop = Math.floor((canvasHeight - swatchHeight) / 2);
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
var x = prependExtraImage ? extraImageWidth + separator + extraImageSeparator : 0; // If extra image hasn't started loading, start now
for (let i=0, len=colors.length; i<len; i++) { if (_itemsListImagePromises[extraImage] === undefined) {
let ios = Services.io;
let uri = ios.newURI(extraImage, null, null);
let img = new win.Image();
img.src = uri.spec;
_itemsListImagePromises[extraImage] = new Zotero.Promise((resolve) => {
img.onload = function () {
resolve(img);
};
});
}
// If retraction image hasn't started loading, start now
if (_itemsListImagePromises[retractionImage] === undefined) {
let ios = Services.io;
let uri = ios.newURI(retractionImage, null, null);
let img = new win.Image();
img.src = uri.spec;
_itemsListImagePromises[retractionImage] = new Zotero.Promise((resolve) => {
img.onload = function () {
resolve(img);
};
});
}
var x = extraImageWidth
+ (retracted ? retractionImageLeftPadding + retractionImageWidth: 0)
+ tagsLeftPadding;
for (let i = 0, len = colors.length; i < len; i++) {
ctx.fillStyle = colors[i]; ctx.fillStyle = colors[i];
_canvasRoundRect(ctx, x, swatchTop + 1, swatchWidth, swatchHeight, 2, true, false) _canvasRoundRect(ctx, x, swatchTop + 1, swatchWidth, swatchHeight, 2, true, false);
x += swatchWidth + separator; x += swatchWidth + swatchSeparator;
} }
// If there's no extra iamge, resolve a promise now if (retracted) {
if (!extraImage) { let [img1, img2] = await Zotero.Promise.all([
var dataURI = canvas.toDataURL("image/png"); _itemsListImagePromises[extraImage],
var dataURIPromise = Zotero.Promise.resolve(dataURI); _itemsListImagePromises[retractionImage]
_itemsListImagePromises[hash] = dataURIPromise; ]);
return dataURIPromise; ctx.drawImage(img1, 0, 0, extraImageWidth, extraImageHeight);
} ctx.drawImage(
img2,
// Add an extra image to the beginning or end of the swatches extraImageWidth + retractionImageLeftPadding
if (prependExtraImage) { + ((retractionImageWidth - retractionImageScaledWidth) / 2),
x = 0; (retractionImageHeight - retractionImageScaledHeight) / 2 + 1, // Lower by 1
retractionImageScaledWidth,
retractionImageScaledHeight
);
} }
else { else {
x += extraImageSeparator; let img = await _itemsListImagePromises[extraImage];
ctx.drawImage(img, 0, 0, extraImageWidth, extraImageHeight);
} }
// If extra image hasn't started loading, start now var dataURI = canvas.toDataURL("image/png");
if (typeof _itemsListExtraImagePromises[extraImage] == 'undefined') { _itemsListImagePromises[hash] = Zotero.Promise.resolve(dataURI);
let ios = Components.classes['@mozilla.org/network/io-service;1'] return dataURI;
.getService(Components.interfaces["nsIIOService"]); };
let uri = ios.newURI(extraImage, null, null);
var img = new win.Image();
img.src = uri.spec;
// Mark that we've started loading
var deferred = Zotero.Promise.defer();
var extraImageDeferred = Zotero.Promise.defer();
_itemsListExtraImagePromises[extraImage] = extraImageDeferred.promise;
// When extra image has loaded, draw it
img.onload = function () {
ctx.drawImage(img, x, 0, extraImageWidth, extraImageHeight);
var dataURI = canvas.toDataURL("image/png");
var dataURIPromise = Zotero.Promise.resolve(dataURI);
_itemsListImagePromises[hash] = dataURIPromise;
// Fulfill the promise for this call
deferred.resolve(dataURI);
// And resolve the extra image's promise to fulfill
// other promises waiting on it
extraImageDeferred.resolve(img);
}
return deferred.promise;
}
// If extra image has already started loading, return a promise
// for the composite image once it's ready
return _itemsListExtraImagePromises[extraImage]
.then(function (img) {
ctx.drawImage(img, x, 0, extraImageWidth, extraImageHeight);
var dataURI = canvas.toDataURL("image/png");
var dataURIPromise = Zotero.Promise.resolve(dataURI);
_itemsListImagePromises[hash] = dataURIPromise;
return dataURIPromise;
});
}
/** /**

View file

@ -593,14 +593,23 @@ Zotero.ItemTreeView.prototype.notify = Zotero.Promise.coroutine(function* (actio
} }
// If refreshing a single item, clear caches and then deselect and reselect row // If refreshing a single item, clear caches and then deselect and reselect row
else if (savedSelection.length == 1 && savedSelection[0] == ids[0]) { else if (savedSelection.length == 1 && savedSelection[0] == ids[0]) {
let row = this._rowMap[ids[0]]; let id = ids[0];
let row = this._rowMap[id];
delete this._cellTextCache[row]; delete this._cellTextCache[row];
delete this._itemImages[id];
this._treebox.invalidateRow(row);
this.selection.clearSelection(); this.selection.clearSelection();
this.rememberSelection(savedSelection); this.rememberSelection(savedSelection);
} }
else { else {
this._cellTextCache = {}; for (let id of ids) {
let row = this._rowMap[id];
if (row === undefined) continue;
delete this._cellTextCache[row];
delete this._itemImages[id];
this._treebox.invalidateRow(row);
}
} }
// For a refresh on an item in the trash, check if the item still belongs // For a refresh on an item in the trash, check if the item still belongs

View file

@ -0,0 +1,623 @@
Zotero.Retractions = {
TYPE_DOI: 'd',
TYPE_PMID: 'p',
TYPE_NAMES: ['DOI', 'PMID'],
_version: 1,
_cacheFile: null,
_cacheETag: null,
_cacheDOIPrefixLength: null,
_cachePMIDPrefixLength: null,
_cachePrefixList: new Set(), // Prefix strings from server
_queuedItems: new Set(),
_queuedPrefixStrings: new Set(),
_keyItems: {},
_itemKeys: {},
init: async function () {
this._cacheFile = OS.Path.join(Zotero.Profile.dir, 'retractions.json');
await this._cacheItems();
Zotero.Notifier.registerObserver(this, ['item'], 'retractions');
try {
await this._loadCacheFile();
}
catch (e) {
Zotero.logError("Error loading retractions cache file");
Zotero.logError(e);
}
var itemIDs = await Zotero.DB.columnQueryAsync("SELECT itemID FROM retractedItems");
this.retractedItems = new Set(itemIDs);
// TEMP
Zotero.Schema.schemaUpdatePromise.then(() => {
this.updateFromServer();
});
},
/**
* @param {Zotero.Item}
* @return {Boolean}
*/
isRetracted: function (item) {
return this.retractedItems.has(item.id);
},
notify: async function (action, type, ids, extraData) {
if (action == 'add') {
for (let id of ids) {
this._updateItem(Zotero.Items.get(id));
}
}
else if (action == 'modify') {
for (let id of ids) {
let item = Zotero.Items.get(id);
for (let type of this.TYPE_NAMES) {
let typeID = this['TYPE_' + type];
let fieldVal = this['_getItem' + type](item);
if (fieldVal) {
// If the item isn't already mapped to the key, re-map
let key = this._itemKeys[typeID].get(item.id);
let newKey = this._valueToKey(typeID, fieldVal);
if (key != newKey) {
this._deleteItemKeyMappings(id);
this._updateItem(item);
continue;
}
}
// If a previous key value was cleared, re-map
else if (this._itemKeys[typeID].get(item.id)) {
this._deleteItemKeyMappings(id);
this._updateItem(item);
continue;
}
}
}
}
else if (action == 'delete') {
for (let id of ids) {
await this.removeEntry(id);
}
}
},
/**
* Check for possible matches for items in the queue (debounced)
*/
checkQueuedItems: Zotero.Utilities.debounce(async function () {
Zotero.debug("Checking updated items for retractions");
// If no possible matches, clear retraction flag on any items that changed
if (!this._queuedPrefixStrings.size) {
for (let item of this._queuedItems) {
await this.removeEntry(item.id);
}
this._queuedItems.clear();
return;
}
var items = [...this._queuedItems];
var prefixStrings = [...this._queuedPrefixStrings];
this._queuedItems.clear();
this._queuedPrefixStrings.clear();
var addedItems = [];
try {
addedItems = await this._downloadPossibleMatches(prefixStrings);
}
catch (e) {
// Add back to queue on failure
for (let item of items) {
this._queuedItems.add(item);
}
for (let prefixStr of prefixStrings) {
this._queuedPrefixStrings.add(prefixStr);
}
throw e;
}
// Remove retraction status for items that were checked but didn't match
for (let item of items) {
if (!addedItems.includes(item.id)) {
await this.removeEntry(item.id);
}
}
}, 5000),
/**
* @return {Number[]} - Array of added item ids
*/
_downloadPossibleMatches: async function (prefixStrings) {
var urlPrefix = (Zotero.Prefs.get("api.url") || ZOTERO_CONFIG.API_URL) + '/retractions/';
var req = await Zotero.HTTP.request(
"POST",
urlPrefix + 'search',
{
body: JSON.stringify(prefixStrings),
responseType: 'json'
}
);
var results = req.response;
Zotero.debug(`Retrieved ${results.length} possible `
+ Zotero.Utilities.pluralize(results.length, ['match', 'matches']));
// Look for local items that match
var addedItems = new Set();
for (let row of results) {
if (row.doi) {
let ids = this._keyItems[this.TYPE_DOI].get(row.doi);
if (ids) {
for (let id of ids) {
addedItems.add(id);
await this.addEntry(id, row);
}
}
}
if (row.pmid) {
let ids = this._keyItems[this.TYPE_PMID].get(row.pmid.toString());
if (ids) {
for (let id of ids) {
addedItems.add(id);
await this.addEntry(id, row);
}
}
}
}
Zotero.debug(`Found ${addedItems.size} retracted `
+ Zotero.Utilities.pluralize(addedItems.size, 'item'));
return [...addedItems];
},
getData: async function (item) {
var data = await Zotero.DB.valueQueryAsync(
"SELECT data FROM retractedItems WHERE itemID=?", item.id
);
if (!data) {
return false;
}
try {
data = JSON.parse(data);
}
catch (e) {
Zotero.logError(e);
return false;
}
try {
if (data.date) {
data.date = Zotero.Date.sqlToDate(data.date);
}
else {
data.date = null;
}
}
catch (e) {
Zotero.logError("Error parsing retraction date: " + data.date);
data.date = null;
}
return data;
},
getReasonDescription: function (reason) {
var description = this._reasonDescriptions[reason];
if (!description) {
Zotero.warn(`Description not found for retraction reason "${reason}"`);
return '';
}
return description;
},
addEntry: async function (itemID, data) {
var o = {};
Object.assign(o, data);
// Replace original ids with retraction ids
if (data.retractionDOI) o.doi = data.retractionDOI;
if (data.retractionPMID) o.pmid = data.retractionPMID;
delete o.retractionDOI;
delete o.retractionPMID;
var sql = "REPLACE INTO retractedItems VALUES (?, ?)";
await Zotero.DB.queryAsync(sql, [itemID, JSON.stringify(o)]);
this.retractedItems.add(itemID);
await Zotero.Notifier.trigger('refresh', 'item', [itemID]);
},
removeEntry: async function (itemID) {
this._deleteItemKeyMappings(itemID);
if (this.retractedItems.has(itemID)) {
await Zotero.DB.queryAsync("DELETE FROM retractedItems WHERE itemID=?", itemID);
}
this.retractedItems.delete(itemID);
await Zotero.Notifier.trigger('refresh', 'item', [itemID]);
},
updateFromServer: async function () {
var urlPrefix = (Zotero.Prefs.get("api.url") || ZOTERO_CONFIG.API_URL) + '/retractions/';
// Download list
var headers = {};
if (this._cacheETag) {
headers["If-None-Match"] = this._cacheETag;
}
var req = await Zotero.HTTP.request(
"GET",
urlPrefix + 'list',
{
headers,
noCache: true,
successCodes: [200, 304]
}
);
if (req.status == 304) {
Zotero.debug("Retraction list is up to date");
return;
}
var etag = req.getResponseHeader('ETag');
var list = req.response.trim().split('\n');
// Calculate prefix length automatically
var doiPrefixLength;
var pmidPrefixLength = 0;
for (let row of list) {
let [prefixStr, _date] = row.split(' ');
let type = prefixStr[0];
let prefix = prefixStr.substr(1);
if (type == this.TYPE_DOI && !doiPrefixLength) {
doiPrefixLength = prefix.length;
}
else if (type == this.TYPE_PMID) {
pmidPrefixLength = Math.max(pmidPrefixLength, prefix.length);
}
}
// Get possible local matches
var prefixStrings = new Set([
...Object.keys(this._keyItems[this.TYPE_DOI])
.map(x => this.TYPE_DOI + x.substr(0, doiPrefixLength)),
...Object.keys(this._keyItems[this.TYPE_PMID])
.map(x => this.TYPE_PMID + x.substr(0, pmidPrefixLength))
]);
var prefixesToSend = new Set();
for (let row of list) {
let [prefixStr, date] = row.split(' ');
let type = prefixStr[0];
let prefix = prefixStr.substr(1);
if (!type || !prefix || !date) {
Zotero.warn("Bad line in retractions data: " + row);
continue;
}
if (prefixStrings.has(prefixStr)) {
prefixesToSend.add(prefixStr);
}
}
if (!prefixesToSend.size) {
Zotero.debug("No possible retractions");
await Zotero.DB.queryAsync("DELETE FROM retractedItems");
this.retractedItems.clear();
await this._saveCacheFile(list, etag, doiPrefixLength, pmidPrefixLength);
return;
}
// TODO: Diff list
await this.downloadPossibleMatches([...prefixesToSend]);
await this._cacheList(list, etag, doiPrefixLength, pmidPrefixLength);
// TODO: Prompt
},
_getItemDOI: function (item) {
var itemDOI = item.getField('DOI') || item.getExtraField('doi');
if (itemDOI) {
itemDOI = Zotero.Utilities.cleanDOI(itemDOI);
}
return itemDOI || null;
},
_getItemPMID: function (item) {
// TEMP
return this._extractPMID(item.getField('extra')) || null;
},
// TEMP
_extractPMID: function (str) {
if (!str) {
return false;
}
var lines = str.split(/\n+/g);
var fields = new Map();
for (let line of lines) {
let parts = line.match(/^([a-z \-]+):(.+)/i);
if (!parts) {
continue;
}
let [_, originalField, value] = parts;
let field = originalField.trim().toLowerCase()
// Strip spaces
.replace(/\s+/g, '')
// Old citeproc.js cheater syntax
.replace(/{:([^:]+):([^}]+)}/);
value = value.trim();
if (field == 'pmid' || field == 'pubmedid') {
return value;
}
}
return false;
},
_valueToKey: function (type, value) {
if (type == this.TYPE_DOI) {
return Zotero.Utilities.Internal.sha1(value);
}
return value;
},
_getDOIPrefix: function (value, length) {
var hash = this._valueToKey(this.TYPE_DOI, value);
return hash.substr(0, length);
},
_getPMIDPrefix: function (value, length) {
return value.substr(0, length);
},
_cacheItems: async function () {
await this._cacheDOIItems();
await this._cachePMIDItems();
},
_cacheDOIItems: async function () {
this._keyItems[this.TYPE_DOI] = new Map();
this._itemKeys[this.TYPE_DOI] = new Map();
var sql = "SELECT itemID AS id, value FROM itemData "
+ "JOIN itemDataValues USING (valueID) WHERE fieldID=?";
var rows = await Zotero.DB.queryAsync(sql, Zotero.ItemFields.getID('DOI'));
for (let row of rows) {
let value = Zotero.Utilities.cleanDOI(row.value);
if (!value) continue;
this._addItemKeyMapping(this.TYPE_DOI, value, row.id);
}
// Extract from Extract field
sql = "SELECT itemID AS id, value FROM itemData "
+ "JOIN itemDataValues USING (valueID) WHERE fieldID=?";
rows = await Zotero.DB.queryAsync(sql, Zotero.ItemFields.getID('extra'));
for (let row of rows) {
let fields = Zotero.Utilities.Internal.extractExtraFields(row.value);
let doi = fields.get('doi');
if (!doi || !doi.value) continue;
let value = Zotero.Utilities.cleanDOI(doi.value);
if (!value) continue;
this._addItemKeyMapping(this.TYPE_DOI, value, row.id);
}
},
_cachePMIDItems: async function () {
this._keyItems[this.TYPE_PMID] = new Map();
this._itemKeys[this.TYPE_PMID] = new Map();
var sql = "SELECT itemID AS id, value FROM itemData "
+ "JOIN itemDataValues USING (valueID) WHERE fieldID=?";
var rows = await Zotero.DB.queryAsync(sql, Zotero.ItemFields.getID('extra'));
for (let row of rows) {
/*
let fields = Zotero.Utilities.Internal.extractExtraFields(row.value);
let pmid = fields.get('pmid') || fields.get('pubmedID');
if (!pmid || !pmid.value) continue;
this._addItemKeyMapping(this.TYPE_PMID, pmid.value, row.id);
*/
let pmid = this._extractPMID(row.value);
if (!pmid) continue;
this._addItemKeyMapping(this.TYPE_PMID, pmid, row.id);
}
},
_addItemKeyMapping: function (type, value, itemID) {
var key = this._valueToKey(type, value);
// Map key to item id
var ids = this._keyItems[type].get(key);
if (!ids) {
ids = new Set();
this._keyItems[type].set(key, ids);
}
ids.add(itemID);
// Map item id to key so we can clear on change
this._itemKeys[type].set(itemID, key);
},
_deleteItemKeyMappings: function (itemID) {
for (let type of [this.TYPE_DOI, this.TYPE_PMID]) {
var key = this._itemKeys[type].get(itemID);
if (key) {
this._keyItems[type].get(key).delete(itemID);
this._itemKeys[type].delete(itemID);
}
}
},
/**
* Add new key mappings for an item, check if it matches a cached prefix, and queue it for full
* checking if so
*/
_updateItem: function (item) {
if (!item.isRegularItem()) {
return;
}
this._queuedItems.add(item);
var doi = this._getItemDOI(item);
if (doi) {
this._addItemKeyMapping(this.TYPE_DOI, doi, item.id);
let prefixStr = this.TYPE_DOI + this._getDOIPrefix(doi, this._cacheDOIPrefixLength);
if (this._cachePrefixList.has(prefixStr)) {
this._queuedPrefixStrings.add(prefixStr);
}
}
var pmid = this._getItemPMID(item);
if (pmid) {
this._addItemKeyMapping(this.TYPE_PMID, pmid, item.id);
let prefixStr = this.TYPE_PMID + this._getPMIDPrefix(pmid, this._cachePMIDPrefixLength);
if (this._cachePrefixList.has(prefixStr)) {
this._queuedPrefixStrings.add(prefixStr);
}
}
this.checkQueuedItems();
},
_loadCacheFile: async function () {
if (!await OS.File.exists(this._cacheFile)) {
return;
}
var data = JSON.parse(await Zotero.File.getContentsAsync(this._cacheFile));
if (data) {
this._processCacheData(data);
}
},
_processCacheData: function (data) {
this._cacheETag = data.etag;
this._cacheDOIPrefixLength = data.doiPrefixLength;
this._cachePMIDPrefixLength = data.pmidPrefixLength;
this._cachePrefixList = new Set();
for (let row of data.data) {
this._cachePrefixList.add(row.split(' ')[0]);
}
},
/**
* Cache prefix list in profile directory
*/
_saveCacheFile: async function (data, etag, doiPrefixLength, pmidPrefixLength) {
var cacheJSON = {
version: this._version,
etag,
doiPrefixLength,
pmidPrefixLength,
data
};
try {
await Zotero.File.putContentsAsync(this._cacheFile, JSON.stringify(cacheJSON));
this._processCacheData(cacheJSON);
}
catch (e) {
Zotero.logError("Error caching retractions data: " + e);
}
},
// https://retractionwatch.com/retraction-watch-database-user-guide/retraction-watch-database-user-guide-appendix-b-reasons/
_reasonDescriptions: {
"Author Unresponsive": "Author(s) lack of communication after prior contact by Journal, Publisher or other original Authors",
"Breach of Policy by Author": "A violation of the Journal, Publisher or Institutional accepted practices by the author",
"Breach of Policy by Third Party": "A violation of the Journal, Publisher or Institutional accepted practices by a person or company/institution not the authors",
"Cites Prior Retracted Work": "A retracted item is used in citations or referencing",
"Civil Proceedings": "Non-criminal litigation arising from the publication of the original article or the related notice(s)",
"Complaints about Author": "Allegations made strictly about the author without respect to the original article",
"Complaints about Company/Institution": "Allegations made strictly about the authors affiliation(s) without respect to the original article",
"Complaints about Third Party": "Allegations made strictly about those not the author or the authors affiliation(s) without respect to the original article",
"Concerns/Issues About Authorship": "Any question, controversy or dispute over the rightful claim to authorship, excluding forged authorship",
"Concerns/Issues About Data": "Any question, controversy or dispute over the validity of the data",
"Concerns/Issues About Image": "Any question, controversy or dispute over the validity of the image",
"Concerns/Issues about Referencing/Attributions": "Any question, controversy or dispute over whether ideas, analyses, text or data are properly credited to the originator",
"Concerns/Issues About Results": "Any question, controversy or dispute over the validity of the results",
"Concerns/Issues about Third Party Involvement": "Any question, controversy or dispute over the rightful claim to authorship, excluding forged authorship",
"Conflict of Interest": "Authors having affiliations with companies, associations, or institutions that may serve to influence their belief about their findings",
"Contamination of Cell Lines/Tissues": "Impurities found within cell lines or tissues",
"Contamination of Materials (General)": "Impurities found within compounds or solutions used in experiments",
"Contamination of Reagents": "Impurities found within compounds or solutions used to drive experimental outcomes",
"Copyright Claims": "Dispute concerning right of ownership of a publication",
"Criminal Proceedings": "Court actions that may result in incarceration or fines arising from the publication of the original article or the related notice(s)",
"Date of Retraction/Other Unknown": "A lack of publishing date given on the notice or the date on the notice is not representative of the actual notice date. Commonly found when Publishers overwrite the original articles HTML page with the retraction notice, without changing the publication date to reflect such.",
"Doing the Right Thing": "An attribution made by co-founders of Retraction Watch indicating admirable behavior by one of the involved parties",
"Duplication of Article": "Also known as “self-plagiarism”. Used when an entire published item, or undefined sections of it, written by one or all authors of the original article, are repeated in the original article without appropriate citation.",
"Duplication of Data": "Also known as “self-plagiarism”. Used when the all or part of the data from an item written by one or all authors of the original article, are repeated in the original article without appropriate citation.",
"Duplication of Image": "Also known as “self-plagiarism”. Used when an image from an item written by one or all authors of the original article is repeated in the original article without appropriate citation.",
"Duplication of Text": "Also known as “self-plagiarism”. Used when sections of text from an item written by one or all authors of the original article, are repeated in the original article without appropriate citation.",
"Error by Journal/Publisher": "A mistake attributed to a Journal Editor or Publisher",
"Error by Third Party": "A mistake attributed to a person or other, who is not an author or representative of the Journal or Publisher",
"Error in Analyses": "A mistake made in the evaluation of the data or calculations",
"Error in Cell Lines/Tissues": "A mistake made in the identification of cell lines or tissues, or the choice of an incorrect cell line or tissue",
"Error in Data": "A mistake made in the data, either in data entry, gathering or identification",
"Error in Image": "A mistake made in the preparation or printing of an image",
"Error in Materials (General)": "A mistake made in the choice of materials in the performance of experiments (eg., reagents, mixing bowls, etc)",
"Error in Methods": "A mistake made in the experimental protocol, either in following the wrong protocol, or in erring during the performance of the protocol",
"Error in Results and/or Conclusions": "A mistake made in determining the results or establishing conclusions from an experiment or analysis",
"Error in Text": "A mistake made in the written portion of the item",
"Ethical Violations by Author": "When an author performs an action contrary to accepted standards of behavior. Generally used only when stated as such in the notice and no other specific reason (e.g., duplication of image) is given.",
"Ethical Violations by Third Party": "When any person not an author performs an action contrary to accepted standards of behavior. Generally used only when stated as such in the notice and no other specific reason (e.g., duplication of image) is given.",
"Euphemisms for Duplication": "The notice does not clearly state that the authors reused ideas, text, or images from one of their previously published items without suitable citation",
"Euphemisms for Misconduct": "The notice does not clearly state that the reason for the notice is due to fabrication, falsification, or plagiarism by one or all the authors, despite an institutional report stating such.",
"Euphemisms for Plagiarism": "The notice does not clearly state that the authors reused ideas, text, or images, without suitable citation, from items published by those not the authors",
"Fake Peer Review": "The peer review was intentionally not performed in accordance with the journals guidelines or ethical standards",
"Falsification/Fabrication of Data": "Intentional changes to data so that it is not representative of the actual finding",
"Falsification/Fabrication of Image": "Intentional changes to an image so that it is not representative of the actual data",
"Falsification/Fabrication of Results": "Intentional changes to results so that it is not representative of the actual finding",
"Forged Authorship": "The fraudulent use of an author name in submitting a manuscript for publication",
"Hoax Paper": "Paper intentionally drafted with fraudulent data or information with the specific intent of testing a journals or publishers manuscript acceptance policies",
"Informed/Patient Consent None/Withdrawn": "When the full risks and benefits from being in an experiment are not provided to and accepted by the participant, or the participant chooses to later recant their approval",
"Investigation by Company/Institution": "An evaluation of allegations by the affiliations of one or all of the authors",
"Investigation by Journal/Publisher": "An evaluation of allegations by the Journal or Publisher",
"Investigation by ORI": "An evaluation of allegations by the United State Office of Research Integrity",
"Investigation by Third Party": "An evaluation of allegations by a person, company or institution not the Authors, Journal, Publisher or ORI",
"Lack of Approval from Author": "Failure to obtain agreement from original author(s)",
"Lack of Approval from Company/Institution": "Failure to obtain agreement from original author(s)",
"Lack of Approval from Third Party": "Failure to obtain agreement from original author(s)",
"Lack Of Balance/Bias Issues": "Failure to maintain objectivity in the presentation or analysis of information",
"Lack of IRB/IACUC Approval": "Failure to obtain consent from the institutional ethical review board overseeing human or animal experimentation",
"Legal Reasons/Legal Threats": "Actions taken to avoid or foster litigation",
"Manipulation of Images": "The changing of the presentation of an image by reversal, rotation or similar action",
"Manipulation of Results": "The changing of the presentation of results which may lead to conclusions not otherwise warranted",
"Miscommunication by Author": "Error in messaging from or to author",
"Miscommunication by Company/Institution": "Error in messaging from or to authors affiliations",
"Miscommunication by Journal/Publisher": "Error in messaging from or to Journal or Publisher",
"Miscommunication by Third Party": "Error in messaging from or to any party not the author, affiliations, journal of publisher",
"Misconduct Official Investigation/Finding": "Finding of misconduct after investigation by incorporated company, institution of governmental agency",
"Misconduct by Author": "Statement Journal, Publisher, Company, Institution, Governmental Agency, or Author that author committed misconduct",
"Misconduct by Company/Institution": "Statement Journal, Publisher, Company, Institution, or Governmental Agency that Company/Institution committed misconduct",
"Misconduct by Third Party": "Statement Journal, Publisher, Company, Institution, or Governmental Agency that a third party committed misconduct",
"No Further Action": "Generally applicable to Expressions of Concern Statement by Editor or Publisher that",
"Nonpayment of Fees/Refusal to Pay": "Lack of payment of full amount due for services rendered or for rights of access",
"Notice Lack of": "No Notice was published by the Journal or Publisher, and the article is removed from the publishing platform.",
"Notice Limited or No Information": "A notice provides minimal information as to the cause of the notice, or the original item is watermaked as retracted or corrected without explanation",
"Notice Unable to Access via current resources": "The notice is paywalled, only in print, or in some form unavailable for inspection at this time.",
"Objections by Author(s)": "A complaint by any of the original authors or refusal to agree actions taken by the Journal or Publisher",
"Objections by Company/Institution": "A complaint by any of the original authors affiliation(s) or refusal by same to agree actions taken by the Journal or Publisher",
"Objections by Third Party": "A complaint by any person, company or institution not of the original authors, or refusal by same to agree actions taken by the Journal or Publisher",
"Plagiarism of Article": "Used when an entire published item, or undefined sections of it, and not written by one or all authors of the original article, are repeated in the original article without appropriate citation.",
"Plagiarism of Data": "Used when the all or part of the data from an item not written by one or all authors of the original article, are repeated in the original article without appropriate citation.",
"Plagiarism of Image": "Used when an image from an item not written by one or all authors of the original article is repeated in the original article without appropriate citation.",
"Plagiarism of Text": "Used when sections of text from an item not written by one or all authors of the original article, are repeated in the original article without appropriate citation.",
"Publishing Ban": "A Journal or Publisher states that no manuscripts will be acceptance from one or all the authors of the original article. It can be for a limited time, or indefinitely.",
"Results Not Reproducible": "Experiments conducted, using the same materials and methods, that fail to replicate the finding of the original article",
"Retract and Replace": "The permanent change of an item to a non-citable status, with a subsequent republication by the same journal after substantial changes to the item",
"Sabotage of Materials": "An intentional action to surreptitiously change or contaminate experimental ingredients in order to artificially change the experimental outcome",
"Sabotage of Methods": "An intentional action to surreptitiously change or contaminate experimental instruments or tools in order to artificially change the experimental outcome",
"Salami Slicing": "The publication of several articles by using the same (small) dataset, but by breaking it into sections, with the intent of exploiting a limited data set for the production of several published works This does not apply to large multi-group studies such as the Framingham Heart Study.",
"Temporary Removal": "An original article is removed from the Journals publishing platform for an undefined period of time, after which, if returned to the publishing platform, it appears with minimal or no substantial changes",
"Unreliable Data": "The accuracy or validity of the data is questionable",
"Unreliable Image": "The accuracy or validity of the image is questionable",
"Unreliable Results": "The accuracy or validity of the results is questionable",
"Updated to Correction": "A prior notice has been changed to the status of a Correction",
"Updated to Retraction": "A prior notice has been changed to the status of a Retraction",
"Upgrade/Update of Prior Notice": "Either a change to or affirmation of a prior notice",
Withdrawal: "The original article is removed from access on the Journals publishing platform."
}
};

View file

@ -1127,6 +1127,8 @@ Zotero.Schema = new function(){
yield Zotero.DB.waitForTransaction(); yield Zotero.DB.waitForTransaction();
} }
yield Zotero.Retractions.updateFromServer();
// Get the last timestamp we got from the server // Get the last timestamp we got from the server
var lastUpdated = yield this.getDBVersion('repository'); var lastUpdated = yield this.getDBVersion('repository');
var updated = false; var updated = false;
@ -2506,6 +2508,10 @@ Zotero.Schema = new function(){
} }
} }
else if (i == 103) {
yield Zotero.DB.queryAsync("CREATE TABLE retractedItems (\n itemID INTEGER PRIMARY KEY,\n data TEXT,\n FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE\n);");
}
// If breaking compatibility or doing anything dangerous, clear minorUpdateFrom // If breaking compatibility or doing anything dangerous, clear minorUpdateFrom
} }

View file

@ -202,6 +202,35 @@ Zotero.Utilities.Internal = {
}, },
/*
* Adapted from http://developer.mozilla.org/en/docs/nsICryptoHash
*
* @param {String} str
* @return {String}
*/
sha1: function (str) {
var converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"]
.createInstance(Components.interfaces.nsIScriptableUnicodeConverter);
converter.charset = "UTF-8";
var result = {};
var data = converter.convertToByteArray(str, result);
var ch = Components.classes["@mozilla.org/security/hash;1"]
.createInstance(Components.interfaces.nsICryptoHash);
ch.init(ch.SHA1);
ch.update(data, data.length);
var hash = ch.finish(false);
// Return the two-digit hexadecimal code for a byte
function toHexString(charCode) {
return ("0" + charCode.toString(16)).slice(-2);
}
// Convert the binary hash data to a hex string.
var s = Array.from(hash, (c, i) => toHexString(hash.charCodeAt(i))).join("");
return s;
},
gzip: async function (data) { gzip: async function (data) {
var deferred = Zotero.Promise.defer(); var deferred = Zotero.Promise.defer();
@ -590,15 +619,7 @@ Zotero.Utilities.Internal = {
let href = a.getAttribute('href'); let href = a.getAttribute('href');
a.setAttribute('tooltiptext', href); a.setAttribute('tooltiptext', href);
a.onclick = function (event) { a.onclick = function (event) {
try { Zotero.launchURL(href);
let wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
let win = wm.getMostRecentWindow("navigator:browser");
win.ZoteroPane_Local.loadURI(href, options.linkEvent || event)
}
catch (e) {
Zotero.logError(e);
}
return false; return false;
}; };
} }

View file

@ -745,6 +745,7 @@ Services.scriptloader.loadSubScript("resource://zotero/polyfill.js");
yield Zotero.Creators.init(); yield Zotero.Creators.init();
yield Zotero.Groups.init(); yield Zotero.Groups.init();
yield Zotero.Relations.init(); yield Zotero.Relations.init();
yield Zotero.Retractions.init();
// Load all library data except for items, which are loaded when libraries are first // Load all library data except for items, which are loaded when libraries are first
// clicked on or if otherwise necessary // clicked on or if otherwise necessary

View file

@ -1198,3 +1198,9 @@ licenses.cc-by-sa = Creative Commons Attribution-ShareAlike 4.0 International Li
licenses.cc-by-nc = Creative Commons Attribution-NonCommercial 4.0 International License licenses.cc-by-nc = Creative Commons Attribution-NonCommercial 4.0 International License
licenses.cc-by-nc-nd = Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License licenses.cc-by-nc-nd = Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License
licenses.cc-by-nc-sa = Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License licenses.cc-by-nc-sa = Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License
retraction.banner = This work has been retracted.
retraction.date = Retracted on %S
retraction.notice = Retraction Notice
retraction.details = More details:
retraction.credit = Data from %S

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

After

Width:  |  Height:  |  Size: 476 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

View file

@ -45,6 +45,56 @@
margin-left: 5px; margin-left: 5px;
} }
#retraction-banner {
padding: 1.5em 1em;
background: #e02a20;
width: 100%;
color: white;
font-weight: bold;
}
#retraction-details {
background: #fbf0f0;
padding: .5em 1.5em;
margin-top: 0;
margin-bottom: 1em;
}
#retraction-details dt {
font-weight: bold;
}
#retraction-details dt:not(:first-child) {
margin-top: .5em;
}
#retraction-details dd {
margin-left: 2em;
}
#retraction-details a {
text-decoration: underline;
}
#retraction-links ul {
padding-left: 0;
}
#retraction-links li {
list-style: none;
}
#retraction-links li:not(:first-child) {
margin-top: .75em;
}
#retraction-credit {
text-align: right;
margin-top: 1.5em;
margin-right: -.9em;
margin-bottom: .2em;
}
/* Buttons in trash and feed views */ /* Buttons in trash and feed views */
.zotero-item-pane-top-buttons { .zotero-item-pane-top-buttons {
-moz-appearance: toolbar; -moz-appearance: toolbar;

View file

@ -110,6 +110,7 @@ const xpcomFilesLocal = [
'quickCopy', 'quickCopy',
'recognizePDF', 'recognizePDF',
'report', 'report',
'retractions',
'router', 'router',
'schema', 'schema',
'server', 'server',

View file

@ -1,4 +1,4 @@
-- 102 -- 103
-- Copyright (c) 2009 Center for History and New Media -- Copyright (c) 2009 Center for History and New Media
-- George Mason University, Fairfax, Virginia, USA -- George Mason University, Fairfax, Virginia, USA
@ -286,6 +286,12 @@ CREATE TABLE publicationsItems (
itemID INTEGER PRIMARY KEY itemID INTEGER PRIMARY KEY
); );
CREATE TABLE retractedItems (
itemID INTEGER PRIMARY KEY,
data TEXT,
FOREIGN KEY (itemID) REFERENCES items(itemID) ON DELETE CASCADE
);
CREATE TABLE fulltextItems ( CREATE TABLE fulltextItems (
itemID INTEGER PRIMARY KEY, itemID INTEGER PRIMARY KEY,
indexedPages INT, indexedPages INT,