-
-
Save MichalPt/0d08085321d03a63aca07720785849c8 to your computer and use it in GitHub Desktop.
Readcube-Overleaf integration: support for new GUI of Overleaf 6.2.1
This file contains hidden or 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.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); | |
| })(); |
Author
Author
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
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

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.