-
-
Save PaulVanSchayck/3e61439f55ea425dcdb9477dfa329c86 to your computer and use it in GitHub Desktop.
Readcube-Overleaf integration: Adds an "Update Library" button to Overleaf that allows you to import your Readcube library.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Readcube-Overleaf integration | |
// @namespace https://tillahoffmann.github.io/ | |
// @version 0.4 | |
// @description Adds an "Update Library" button to Overleaf that allows you to import your Readcube library. | |
// @author Till Hoffmann, Michal Ptacek, Paul van Schayck | |
// @match https://www.overleaf.com/* | |
// @connect readcube.com | |
// @grant GM_xmlhttpRequest | |
// ==/UserScript== | |
function formatPageNumbers(x) { | |
// Remove spaces | |
x = x.replaceAll(/\s/g, ''); | |
// Replace single dashes with double dashes | |
x = x.replaceAll(/(?<=\d)-(?=\d)/g, '--'); | |
return x; | |
} | |
function formatType(x) { | |
const types = { | |
'conference_paper' : 'inproceedings', | |
'poster': 'inproceedings', | |
'webpage': 'misc', | |
'report': 'techreport' | |
} | |
if( x in types ) { | |
return types[x] | |
} else { | |
return x | |
} | |
} | |
function formatRaw(x) { | |
return x; | |
} | |
const formattingLookup = { | |
'title': 'article/title', | |
'journal': 'article/journal', | |
'pages': { | |
'path': 'article/pagination', | |
'format': formatPageNumbers, | |
}, | |
'volume': 'article/volume', | |
'year': 'article/year', | |
'doi': { | |
'path': 'ext_ids/doi', | |
'format': formatRaw | |
}, | |
'url': { | |
'path': 'custom_metadata/url', | |
'format': formatRaw | |
}, | |
'publisher': 'custom_metadata/publisher' | |
}; | |
const escapes = { | |
"{": "\\{", | |
"}": "\\}", | |
"\\": "\\textbackslash{}", | |
"#": "\\#", | |
"$": "\\$", | |
"%": "\\%", | |
"&": "\\&", | |
"^": "\\textasciicircum{}", | |
"_": "\\_", | |
"~": "\\textasciitilde{}", | |
}; | |
// Inspired on https://github.com/dangmai/escape-latex | |
function escapeLatex(x) { | |
const escapeKeys = Object.keys(escapes); | |
let runningStr = String(x); | |
let result = ""; | |
// Algorithm: Go through the string character by character, if it matches | |
// with one of the special characters then we'll replace it with the escaped | |
// version. | |
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) { | |
var citekey = item.user_data.citekey; | |
// Cite key is available | |
if (citekey) { | |
citekey = citekey.replaceAll("'", ""); | |
} | |
// Generate a cite key based on the author | |
else if (item.article.authors && item.article.authors.length) { | |
var author = item.article.authors[0].split(/\s+/); | |
// Get the last name of the author (naively, anyway) | |
citekey = author[author.length - 1]; | |
// Add the year if it's available | |
if (item.article.year) { | |
citekey = citekey + item.article.year; | |
} | |
// Add a suffix if it's not unique | |
if (usedKeys[citekey]) { | |
usedKeys[citekey] += 1; | |
citekey += String.fromCharCode(95 + usedKeys[citekey]); | |
} else { | |
usedKeys[citekey] = 1; | |
} | |
} | |
// Just generate something random | |
else { | |
var randomInt = Math.floor(Math.random() * 0xffffffff); | |
citekey = randomInt.toString(16); | |
} | |
var lines = [ | |
'@' + formatType(item.item_type) + '{' + 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); | |
} | |
lines.push(' ' + key + ' = {' + x + '},'); | |
} | |
} | |
lines.push('}'); | |
return lines.join('\n'); | |
} | |
function fetchItems(config) { | |
// Construct the url | |
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); | |
// Continue recursively | |
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 link = entries.shift(); | |
const parsedEntries = entries.map(entry => { | |
const lines = entry.split('\n'); | |
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 key = lines[i].split('=')[0].trim(); | |
let value = lines[i].split('=')[1].trim(); | |
value = value.substring(1, value.length - 2); | |
fields[key] = value; | |
} | |
return {type, citationKey, fields}; | |
}); | |
return [link, parsedEntries]; | |
} | |
function sortBibTex(bibtex) { | |
const parsed= parseBibTex(bibtex); | |
const link = parsed[0]; | |
const parsedEntries = parsed[1] | |
parsedEntries.sort((a, b) => { | |
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 link + '\n\n' + sortedBibTex; | |
} | |
function scrollToTop() { | |
var scroller = document.querySelector('div.cm-scroller'); | |
scroller.scrollTop = 0; | |
} | |
function updateLibrary() { | |
// Get the first editor and fetch the code | |
//for (let element of document.getElementsByClassName('editor')) { | |
//editor = ace.edit(element); | |
//editor = element | |
//break; | |
//} | |
var editor = document.getElementsByClassName('cm-content')[0]; | |
//var code = editor.getValue(); | |
var code = editor.innerText; | |
var chbox = document.getElementById('check1'); | |
// Try to match the URL pattern and load the items | |
var pattern = /%% https?:\/\/app.readcube.com\/library\/([\w-]+)\/list\/([\w-]+)/gi; | |
for (var match of code.matchAll(pattern)) { | |
console.log('library: ', match[1]); | |
console.log('list: ', match[2]); | |
var url = 'https://sync.readcube.com/collections/' + match[1] + '/lists/' + match[2] + '/items'; | |
console.log('fetching ' + url); | |
fetchItems({ | |
'baseUrl': url, | |
'match': match, | |
'editor': editor, | |
'callback': function(config) { | |
// Format the code and add it to the editor | |
//console.log(config); | |
// Sort the items for consistent cite key generation | |
config.items.sort(function(a, b) { | |
if (a.id < b.id) { | |
return -1; | |
} | |
return 1; | |
}); | |
var lines = [config.match[0]]; | |
var usedKeys = {}; | |
for (var item of config.items) { | |
lines.push(formatItem(item, usedKeys)); | |
} | |
//editor.setValue(lines.join('\n\n')); | |
if (chbox.checked) { | |
editor.innerText = sortBibTex(lines.join('\n\n')) | |
} else { | |
editor.innerText = lines.join('\n\n') | |
}; | |
}, | |
}); | |
// We only process one library (for now anyway) | |
break; | |
} | |
} | |
(function() { | |
'use strict'; | |
// Inject the update button in the toolbar | |
setInterval(function() { | |
// Selecting toolbar | |
var parent = document.querySelector('div.toolbar-right'); | |
if (parent.getAttribute('readcube')) { | |
return; | |
} | |
// Creating proper toolbar button | |
var sect = document.createElement('div'); | |
sect.classList.add('toolbar-item','layout-dropdown','dropdown','btn-group'); | |
// Creating button itself | |
var button = document.createElement('button'); | |
button.classList.add('btn', 'btn-info', 'btn-full-height'); | |
button.onclick = function() { | |
scrollToTop(); | |
updateLibrary(); | |
} | |
var label = document.createElement('p'); | |
label.classList.add('toolbar-label'); | |
label.innerText = "Update ReadCube"; | |
var icon = document.createElement('i'); | |
icon.classList.add('fa','fa-cube','fa-fw'); | |
//icon.innerText = "::before"; | |
button.appendChild(icon); | |
button.appendChild(label); | |
sect.appendChild(button); | |
// | |
//var dropdownMenu = document.createElement('div') | |
// create the dropdown menu (list) | |
var dropdown = document.createElement('ul'); | |
dropdown.classList.add('layout-dropdown-list','dropdown-menu','dropdown-menu-right'); | |
// create first item | |
var firstItem = document.createElement('li'); | |
dropdown.appendChild(firstItem); | |
var firstItemA = document.createElement('a'); | |
firstItem.appendChild(firstItemA); | |
var firstItemD = document.createElement('div'); | |
firstItemD.classList.add('layout-menu-item'); | |
firstItemA.appendChild(firstItemD); | |
var firstItemDiv = document.createElement('div'); | |
firstItemDiv.classList.add('layout-menu-item-start'); | |
firstItemD.appendChild(firstItemDiv); | |
// Create the sort checkbox | |
var sortLabel = document.createElement('div'); | |
sortLabel.innerText = 'Sort alphabetically'; | |
var sortIcon = document.createElement('i'); | |
sortIcon.classList.add('fa','fa-fw','fa-sort'); | |
var sortCheckbox = document.createElement('input'); | |
sortCheckbox.type = "checkbox"; | |
sortCheckbox.setAttribute('id','check1'); | |
// Default value for checkbox - sort alphabetically | |
sortCheckbox.checked = true; | |
firstItemDiv.appendChild(sortCheckbox); | |
firstItemDiv.appendChild(sortIcon); | |
firstItemDiv.appendChild(sortLabel); | |
// Add the drop-down menu to the sect element | |
sect.appendChild(dropdown); | |
// Show the drop-down menu when the user hovers over the button | |
button.onmouseover = function() { | |
dropdown.style.display = "block"; | |
} | |
button.onmouseout = function() { | |
dropdown.style.display = "none"; | |
} | |
// timeout: | |
var timeout; | |
button.onmouseover = function() { | |
timeout = setTimeout(function() { | |
dropdown.style.display = "block"; | |
}, 1500); | |
} | |
button.onmouseout = function() { | |
clearTimeout(timeout); | |
} | |
document.addEventListener("click", function(event) { | |
if (!dropdown.contains(event.target)) { | |
dropdown.style.display = "none"; | |
} | |
}); | |
// | |
var second = parent.getElementsByTagName('div')[1] | |
parent.insertBefore(sect, second); | |
parent.setAttribute('readcube', 'injected'); | |
}, 1500); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment