Skip to content

Instantly share code, notes, and snippets.

@MichalPt
Forked from tillahoffmann/readleaf.user.js
Last active August 3, 2026 14:27
Show Gist options
  • Select an option

  • Save MichalPt/0d08085321d03a63aca07720785849c8 to your computer and use it in GitHub Desktop.

Select an option

Save MichalPt/0d08085321d03a63aca07720785849c8 to your computer and use it in GitHub Desktop.
Readcube-Overleaf integration: support for new GUI of Overleaf 6.2.1
// ==UserScript==
// @name Readcube-Overleaf integration
// @namespace https://tillahoffmann.github.io/
// @version 0.6.1
// @description Adds an "Update Library" button to Overleaf that allows you to import your Readcube library.
// @author Till Hoffmann, Michal Ptacek, Gemini Pro
// @match https://www.overleaf.com/*
// @connect readcube.com
// @grant GM_xmlhttpRequest
// ==/UserScript==
function formatPageNumbers(x) {
x = x.replaceAll(/\s/g, '');
x = x.replaceAll(/(?<=\d)-(?=\d)/g, '--');
return x;
}
const formattingLookup = {
'title': 'article/title',
'journal': 'article/journal',
'pages': {
'path': 'article/pagination',
'format': formatPageNumbers,
},
'volume': 'article/volume',
'year': 'article/year',
'doi': 'ext_ids/doi',
'url': 'custom_metadata/url',
'publisher': 'custom_metadata/publisher',
'accessed':'custom_metadata/accessed',
'language':'custom_metadata/language'
};
const escapes = {
"{": "\\{",
"}": "\\}",
"\\": "\\textbackslash{}",
"#": "\\#",
"$": "\\$",
"%": "\\%",
"&": "\\&",
"^": "\\textasciicircum{}",
"_": "\\_",
"~": "\\textasciitilde{}",
};
function escapeLatex(x) {
const escapeKeys = Object.keys(escapes);
let runningStr = String(x);
let result = "";
while (runningStr) {
let specialCharFound = false;
escapeKeys.forEach(function(key, index) {
if (specialCharFound) return;
if (
runningStr.length >= key.length &&
runningStr.slice(0, key.length) === key
) {
result += escapes[escapeKeys[index]];
runningStr = runningStr.slice(key.length, runningStr.length);
specialCharFound = true;
}
});
if (!specialCharFound) {
result += runningStr.slice(0, 1);
runningStr = runningStr.slice(1, runningStr.length);
}
}
return result;
}
function formatItem(item, usedKeys, replacements) {
var citekey = item.user_data.citekey;
if (citekey) {
citekey = citekey.replaceAll("'", "");
}
else if (item.article.authors && item.article.authors.length) {
var author = item.article.authors[0].split(/\s+/);
citekey = author[author.length - 1];
if (item.article.year) citekey = citekey + item.article.year;
if (usedKeys[citekey]) {
usedKeys[citekey] += 1;
citekey += String.fromCharCode(95 + usedKeys[citekey]);
} else {
usedKeys[citekey] = 1;
}
} else {
var randomInt = Math.floor(Math.random() * 0xffffffff);
citekey = randomInt.toString(16);
}
var itemtype = item.item_type
if (item.user_data.notes) {
if (item.user_data.notes.includes("Master")) itemtype = 'mastersthesis'
if (item.user_data.notes.includes("Bachelor")) itemtype = 'bachelorsthesis'
if (item.user_data.notes.includes("PhD")) itemtype = 'phdthesis'
}
var lines = [
'@' + itemtype + '{' + citekey + ',',
' author = {' + (item.article.authors || []).join(' and ') + '},',
];
for (var [key, value] of Object.entries(formattingLookup)) {
if (typeof value === "string") {
value = {'path': value};
}
var x = item;
for (var subpath of value.path.split('/')) {
x = x[subpath];
if (x === undefined) break;
}
if (x) {
if (value.format) {
x = value.format(x);
} else {
x = escapeLatex(x);
}
if (key === 'title') {
for (var replacement of replacements) {
x = x.replaceAll(RegExp(replacement[1], 'gi'), replacement[2])
}
}
lines.push(' ' + key + ' = {' + x + '},');
}
}
lines.push('}');
return lines.join('\n');
}
function fetchItems(config) {
var url = config.baseUrl;
if (config.scrollId) {
url = url + '?scroll_id=' + config.scrollId;
}
GM_xmlhttpRequest({
method: "GET",
url: url,
onload: function(response) {
unsafeWindow.config = config;
var data = JSON.parse(response.responseText);
if (data.items.length > 0) {
config.items = (config.items || []).concat(data.items);
fetchItems(Object.assign({}, config, {scrollId: data.scroll_id}));
} else if (config.callback) {
config.callback(config);
}
}
});
}
function parseBibTex(bibtex) {
const entries = bibtex.split('\n\n');
const parsedEntries = entries.map(entry => {
const lines = entry.split('\n');
if (lines.length < 2) return null;
const type = lines[0].split('{')[0].substring(1);
const citationKey = lines[0].split('{')[1].split(',')[0];
const fields = {};
for (let i = 1; i < lines.length - 1; i++) {
const splitLine = lines[i].split('=');
if(splitLine.length < 2) continue;
const key = splitLine[0].trim();
let value = splitLine.slice(1).join('=').trim();
value = value.substring(1, value.length - 2);
fields[key] = value;
}
return {type, citationKey, fields};
}).filter(e => e !== null);
return parsedEntries;
}
function sortBibTex(bibtex) {
const parsedEntries = parseBibTex(bibtex);
parsedEntries.sort((a, b) => {
if (!a.fields.author || !b.fields.author) return 0;
const authorA = a.fields.author.split('and')[0].trim().split(' ').pop();
const authorB = b.fields.author.split('and')[0].trim().split(' ').pop();
return authorA.localeCompare(authorB);
});
let sortedBibTex = '';
for (const entry of parsedEntries) {
sortedBibTex += `@${entry.type}{${entry.citationKey},\n`;
for (const [key, value] of Object.entries(entry.fields)) {
sortedBibTex += ` ${key} = {${value}},\n`;
}
sortedBibTex += '}\n\n';
}
return sortedBibTex;
}
function scrollToTop() {
var scroller = document.querySelector('.cm-scroller');
if(scroller) scroller.scrollTop = 0;
}
function updateLibrary() {
var editor = document.querySelector('.cm-content');
if (!editor) return;
var code = editor.innerText;
var chbox = document.getElementById('readcube-sort-checkbox');
var settingspattern = /%% .*\n/gi;
const settings = [];
for (var setting of code.matchAll(settingspattern)) {
settings.push(setting[0]);
}
var replacepattern = /%% replace (\S+) (\S*)\s*\n/gi;
const replacements = [];
for (var replacematch of code.matchAll(replacepattern)) {
replacements.push(replacematch);
}
var pattern = /%% https?:\/\/(?:new)?app.readcube.com\/library\/([\w-]+)\/list\/([\w-]+)/gi;
for (var match of code.matchAll(pattern)) {
var url = 'https://sync.readcube.com/collections/' + match[1] + '/lists/' + match[2] + '/items';
fetchItems({
'baseUrl': url,
'match': match,
'settings': settings,
'replacements': replacements,
'callback': function(config) {
config.items.sort(function(a, b) {
if (a.id < b.id) return -1;
return 1;
});
var lines = [];
var usedKeys = {};
for (var item of config.items) {
lines.push(formatItem(item, usedKeys, replacements));
}
var bibitems;
if (chbox && chbox.checked) {
bibitems = sortBibTex(lines.join('\n\n'));
} else {
bibitems = lines.join('\n\n');
}
// Helper Guide injected below settings
var guideText = [
"% --- ReadCube Integration Guide ---",
"% You can use regex to automatically modify text in your entry titles.",
"% Syntax: %% replace <find_pattern> <replacement_string>",
"% Example (to keep the capitalization of 'DNA'):",
"% %% replace \\bDNA\\b {DNA}",
"%",
"% Note: Any custom line you start with '%% ' will be kept safe during updates.",
"% ----------------------------------"
].join('\n');
var newText = settings.join('') + '\n' + guideText + '\n\n' + bibitems;
editor.focus();
var selection = window.getSelection();
var range = document.createRange();
range.selectNodeContents(editor);
selection.removeAllRanges();
selection.addRange(range);
document.execCommand("insertText", false, newText);
},
});
break;
}
}
(function() {
'use strict';
var dropdown = document.createElement('ul');
dropdown.style.display = 'none';
dropdown.style.position = 'fixed';
dropdown.style.backgroundColor = 'var(--editor-toolbar-bg, #ffffff)';
dropdown.style.border = '1px solid var(--editor-toolbar-popover-border-color, #cccccc)';
dropdown.style.borderRadius = '4px';
dropdown.style.padding = '8px';
dropdown.style.zIndex = '999999';
dropdown.style.listStyle = 'none';
dropdown.style.margin = '0';
dropdown.style.boxShadow = '0 5px 10px rgba(0, 0, 0, 0.2)';
dropdown.style.color = 'var(--toolbar-btn-color, #333333)';
var firstItem = document.createElement('li');
firstItem.style.display = 'flex';
firstItem.style.alignItems = 'center';
firstItem.style.gap = '8px';
firstItem.style.whiteSpace = 'nowrap';
var sortCheckbox = document.createElement('input');
sortCheckbox.type = "checkbox";
sortCheckbox.setAttribute('id','readcube-sort-checkbox');
sortCheckbox.checked = true;
sortCheckbox.style.margin = '0';
sortCheckbox.style.cursor = 'pointer';
var sortLabel = document.createElement('span');
sortLabel.innerText = 'Sort alphabetically';
sortLabel.style.fontSize = '13px';
sortLabel.style.cursor = 'pointer';
sortLabel.onclick = function() {
sortCheckbox.checked = !sortCheckbox.checked;
};
firstItem.appendChild(sortCheckbox);
firstItem.appendChild(sortLabel);
dropdown.appendChild(firstItem);
document.body.appendChild(dropdown);
document.addEventListener("click", function(event) {
if (!dropdown.contains(event.target)) {
dropdown.style.display = "none";
}
});
window.addEventListener("resize", function() { dropdown.style.display = "none"; });
window.addEventListener("scroll", function() { dropdown.style.display = "none"; }, true);
setInterval(function() {
var parent = document.querySelector('.ol-cm-toolbar');
if (!parent || parent.getAttribute('readcube')) {
return;
}
var btnGroup = document.createElement('div');
btnGroup.classList.add('ol-cm-toolbar-button-group');
btnGroup.style.display = 'flex';
var mainButton = document.createElement('button');
mainButton.classList.add('ol-cm-toolbar-button');
mainButton.title = "Update ReadCube Library";
mainButton.style.display = 'flex';
mainButton.style.alignItems = 'center';
mainButton.style.justifyContent = 'center';
mainButton.style.minWidth = '32px';
mainButton.style.padding = '0 6px';
mainButton.onclick = function(e) {
e.preventDefault();
scrollToTop();
updateLibrary();
}
var mainIcon = document.createElement('div');
mainIcon.style.display = 'flex';
mainIcon.style.pointerEvents = 'none';
mainIcon.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>';
mainButton.appendChild(mainIcon);
var arrowButton = document.createElement('button');
arrowButton.classList.add('ol-cm-toolbar-button');
arrowButton.title = "Settings";
arrowButton.style.display = 'flex';
arrowButton.style.alignItems = 'center';
arrowButton.style.justifyContent = 'center';
arrowButton.style.minWidth = '20px';
arrowButton.style.padding = '0 4px';
var arrowIcon = document.createElement('div');
arrowIcon.style.display = 'flex';
arrowIcon.style.pointerEvents = 'none';
arrowIcon.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
arrowButton.appendChild(arrowIcon);
btnGroup.appendChild(mainButton);
btnGroup.appendChild(arrowButton);
arrowButton.onclick = function(event) {
event.preventDefault();
event.stopPropagation();
if (dropdown.style.display === "none" || dropdown.style.display === "") {
var rect = btnGroup.getBoundingClientRect();
dropdown.style.top = (rect.bottom + 4) + 'px';
dropdown.style.left = (rect.left + (rect.width / 2)) + 'px';
dropdown.style.transform = 'translateX(-50%)';
dropdown.style.display = "block";
} else {
dropdown.style.display = "none";
}
};
var children = Array.from(parent.children);
var targetNode = null;
for (var i = 0; i < children.length; i++) {
if (children[i].innerText && (children[i].innerText.includes('Code') || children[i].innerText.includes('Visual'))) {
targetNode = children[i];
break;
}
}
if (targetNode) {
parent.insertBefore(btnGroup, targetNode);
} else {
parent.appendChild(btnGroup);
}
parent.setAttribute('readcube', 'injected');
}, 1500);
})();
@MichalPt

MichalPt commented Jun 3, 2022

Copy link
Copy Markdown
Author

screen1

The original script had to be edited at line 164 where the '.toolbar-right' was replaced by 'div.toolbar-right' in order to work.
Besides, a more fancy looking version was created where the button is properly embedded into the toolbar, as can be seen in the print-screen snippet.

@MichalPt

Copy link
Copy Markdown
Author

Snímek obrazovky 2024-10-29 174450

Revision 0.4 brings support for regex editing of titles of BibTeX entries. Just make sure you don't use blank spaces in the regex expression as it is used as a delimiter. The update also brings several minor changes to the data fields that are being imported from the ReadCube database.

@epaaso

epaaso commented Jan 23, 2025

Copy link
Copy Markdown

One must only replace line 265-268 with:

var pattern = /%% https?:\/\/(app|newapp).readcube.com\/library\/([\w-]+)\/list\/([\w-]+)/gi;

for (var match of code.matchAll(pattern)) {
      match.shift()

To consider the new app urls

@MichalPt

MichalPt commented Jan 24, 2025

Copy link
Copy Markdown
Author

@epaaso Thanks for letting me know about the new Papers version. I haven't even noticed it's out already. I updated the code with a slightly different regex than you suggested using a non-capturing group that doesn't need the shifting:

var pattern = /%% https?:\/\/(?:new)?app.readcube.com\/library\/([\w-]+)\/list\/([\w-]+)/gi;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment